commit ae59ed8608a0d269650cc49345af01d81ab65a92 Author: anthonyrawlins Date: Mon Oct 13 08:17:26 2025 +1100 Initial commit: WHOOSH UI application This is the web UI for the WHOOSH task ingestion system. **Features**: - Real-time dashboard for CHORUS tasks - Repository monitoring status - Council management interface - Agent availability tracking **Tech Stack**: - Vanilla JavaScript - HTML5/CSS3 - REST API integration with WHOOSH backend 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/index.html b/index.html new file mode 100644 index 0000000..5688d07 --- /dev/null +++ b/index.html @@ -0,0 +1,36 @@ + + + + + + WHOOSH UI + + + +
+
+

WHOOSH

+ +
+ Guest + + + +
+
+
+ +
+ +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{} diff --git a/script.js b/script.js new file mode 100644 index 0000000..074b095 --- /dev/null +++ b/script.js @@ -0,0 +1,599 @@ +document.addEventListener('DOMContentLoaded', () => { + const mainContent = document.getElementById('main-content'); + + // Simple router + const routes = { + '#dashboard': '

Dashboard

', + '#councils': '

Councils

', + '#tasks': '

Tasks

', + '#repositories': '

Repositories

', + '#analysis': '

Analysis

', + }; + + function router() { + const hash = window.location.hash || '#dashboard'; + const [route, param] = hash.split('/'); + + if (route === '#councils' && param) { + loadCouncilDetail(param); + } else if (route === '#tasks' && param) { + loadTaskDetail(param); + } else { + mainContent.innerHTML = routes[hash] || '

Page Not Found

'; + loadContent(hash); + } + } + + async function loadContent(hash) { + switch (hash) { + case '#dashboard': + loadDashboard(); + break; + case '#councils': + loadCouncils(); + break; + case '#tasks': + loadTasks(); + break; + case '#repositories': + loadRepositories(); + break; + case '#analysis': + loadAnalysis(); + break; + } + } + + const loadingSpinner = document.getElementById('loading-spinner'); + let activeSpinners = 0; + + function showSpinner() { + loadingSpinner.classList.remove('hidden'); + } + + function hideSpinner() { + loadingSpinner.classList.add('hidden'); + } + + function incSpinner() { + activeSpinners += 1; + if (activeSpinners === 1) showSpinner(); + } + + function decSpinner() { + if (activeSpinners > 0) { + activeSpinners -= 1; + } + if (activeSpinners === 0) hideSpinner(); + } + + async function apiFetch(endpoint) { + incSpinner(); + try { + const authHeaders = getAuthHeaders(); + const response = await fetch(`/api${endpoint}`, { headers: authHeaders }); + if (!response.ok) { + throw new Error(`API request failed: ${response.statusText}`); + } + return response.json(); + } finally { + decSpinner(); + } + } + + async function fetchText(url, options = {}) { + incSpinner(); + try { + const authHeaders = getAuthHeaders(); + const merged = { + ...options, + headers: { ...(options.headers || {}), ...authHeaders }, + }; + const response = await fetch(url, merged); + if (!response.ok) { + throw new Error(`Request failed: ${response.status} ${response.statusText}`); + } + return response.text(); + } finally { + decSpinner(); + } + } + + function getAuthHeaders() { + const token = (localStorage.getItem('whoosh_token') || getCookie('whoosh_token') || '').trim(); + if (!token) return {}; + return { 'Authorization': `Bearer ${token}` }; + } + + function getCookie(name) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop().split(';').shift(); + return ''; + } + + async function loadDashboard() { + const dashboardContent = document.getElementById('dashboard-content'); + try { + const health = await apiFetch('/admin/health/details'); + const metrics = await fetchText('/metrics'); + + // A real app would parse the metrics properly + dashboardContent.innerHTML = ` +
+
+

System Status

+

Status: ${health.status}

+

Version: ${health.version}

+
+
+

Metrics

+
${metrics.slice(0, 1000)}...
+
+
+ `; + } catch (error) { + dashboardContent.innerHTML = `

Error loading dashboard: ${error.message}

`; + } + } + + async function loadCouncils() { + const councilsContent = document.getElementById('councils-content'); + try { + const data = await apiFetch('/v1/councils'); + councilsContent.innerHTML = ` +
+ ${data.councils.map(council => ` +
+

${council.project_name}

+

Status: ${council.status}

+
+ +
+
+ `).join('')} +
+ `; + + // Wire delete buttons for projects (councils) + const deleteBtns = document.querySelectorAll('.delete-project-btn'); + deleteBtns.forEach(btn => { + btn.addEventListener('click', async (event) => { + const projectId = event.target.dataset.projectId; + const projectName = event.target.dataset.projectName || projectId; + if (!confirm(`Delete project "${projectName}"? This removes the council record.`)) return; + try { + await fetchText(`/api/v1/projects/${projectId}`, { method: 'DELETE' }); + loadCouncils(); + } catch (error) { + alert(`Error deleting project: ${error.message}`); + } + }); + }); + } catch (error) { + councilsContent.innerHTML = `

Error loading councils: ${error.message}

`; + } + } + + async function loadCouncilDetail(councilId) { + const councilContent = document.getElementById('main-content'); + try { + const council = await apiFetch(`/v1/councils/${councilId}`); + const artifacts = await apiFetch(`/v1/councils/${councilId}/artifacts`); + + // Normalize server composition (core_agents / optional_agents) into a flat list for the UI table + const core = (council.core_agents || []).map(a => ({ + role_name: a.role_name, + required: true, + status: a.status, + agent_id: a.agent_id, + agent_name: a.agent_name, + })); + const optional = (council.optional_agents || []).map(a => ({ + role_name: a.role_name, + required: false, + status: a.status, + agent_id: a.agent_id, + agent_name: a.agent_name, + })); + const agents = [...core, ...optional]; + + const agentRows = agents.map(agent => ` + + ${agent.role_name} + ${agent.required ? 'Core' : 'Optional'} + ${agent.status || 'unknown'} + ${agent.agent_id || '—'} + ${agent.agent_name || '—'} + + `).join(''); + + const artifactItems = artifacts.artifacts && artifacts.artifacts.length + ? artifacts.artifacts.map(artifact => `
  • ${artifact.artifact_name} - ${artifact.status}
  • `).join('') + : '
  • No artifacts recorded yet
  • '; + + councilContent.innerHTML = ` +

    ${council.project_name || ''}

    +
    +
    +

    Council Details

    +

    Status: ${council.status || 'unknown'}

    + ${council.repository ? `

    Repository: ${council.repository}

    ` : ''} + ${council.project_brief ? `

    Project Brief: ${council.project_brief}

    ` : ''} +
    + +
    +
    +
    +

    Role Fulfilment

    +
    + + + + + + + + + + + + ${agentRows || ''} + +
    RoleTypeStatusAgent IDAgent Name
    No agents yet
    +
    +
    +
    +
    +

    Artifacts

    +
      + ${artifactItems} +
    +
    + `; + + // Wire delete in detail view + const delBtn = document.getElementById('delete-project-detail'); + if (delBtn) { + delBtn.addEventListener('click', async (event) => { + const projectId = event.target.dataset.projectId; + const projectName = event.target.dataset.projectName || projectId; + if (!confirm(`Delete project "${projectName}"?`)) return; + try { + await fetchText(`/api/v1/projects/${projectId}`, { method: 'DELETE' }); + window.location.hash = '#councils'; + loadCouncils(); + } catch (error) { + alert(`Error deleting project: ${error.message}`); + } + }); + } + } catch (error) { + councilContent.innerHTML = `

    Error loading council details: ${error.message}

    `; + } + } + + async function loadTasks() { + const tasksContent = document.getElementById('tasks-content'); + try { + const data = await apiFetch('/v1/tasks'); + tasksContent.innerHTML = ` +
    + ${data.tasks.map(task => ` +
    +

    ${task.title}

    +
    + ${task.status} + ${task.priority} + ${task.repository ? `${task.repository}` : ''} +
    + ${task.tech_stack?.length ? ` +
    + ${task.tech_stack.slice(0, 3).map(tech => ` + ${tech} + `).join('')} + ${task.tech_stack.length > 3 ? `+${task.tech_stack.length - 3} more` : ''} +
    + ` : ''} + ${task.description ? ` +

    ${task.description.substring(0, 150)}${task.description.length > 150 ? '...' : ''}

    + ` : ''} +
    + `).join('')} +
    + `; + } catch (error) { + tasksContent.innerHTML = `

    Error loading tasks: ${error.message}

    `; + } + } + + async function loadTaskDetail(taskId) { + const taskContent = document.getElementById('main-content'); + try { + const task = await apiFetch(`/v1/tasks/${taskId}`); + + taskContent.innerHTML = ` +

    ${task.title}

    +
    +

    Task Details

    + + +
    +
    +

    Status: ${task.status}

    +

    Priority: ${task.priority}

    +

    Source: ${task.source_type || 'N/A'}

    +
    +
    +

    Repository: ${task.repository || 'N/A'}

    +

    Project ID: ${task.project_id || 'N/A'}

    + ${task.external_url ? `

    Issue: View on GITEA

    ` : ''} +
    +
    + + + ${task.estimated_hours || task.complexity_score ? ` +
    +
    + ${task.estimated_hours ? `

    Estimated Hours: ${task.estimated_hours}

    ` : ''} + ${task.complexity_score ? `

    Complexity Score: ${task.complexity_score.toFixed(2)}

    ` : ''} +
    + ` : ''} + + + ${task.labels?.length || task.tech_stack?.length ? ` +
    +
    + ${task.labels?.length ? ` +
    +

    Labels:

    +
    + ${task.labels.map(label => `${label}`).join('')} +
    +
    + ` : ''} + ${task.tech_stack?.length ? ` +
    +

    Tech Stack:

    +
    + ${task.tech_stack.map(tech => `${tech}`).join('')} +
    +
    + ` : ''} +
    + ` : ''} + + + ${task.requirements?.length ? ` +
    +

    Requirements:

    +
      + ${task.requirements.map(req => `
    • ${req}
    • `).join('')} +
    + ` : ''} + + +
    +

    Description:

    +
    + ${task.description || 'No description provided'} +
    + + + ${task.assigned_team_id || task.assigned_agent_id ? ` +
    +

    Assignment:

    +
    + ${task.assigned_team_id ? `

    Team: ${task.assigned_team_id}

    ` : ''} + ${task.assigned_agent_id ? `

    Agent: ${task.assigned_agent_id}

    ` : ''} +
    + ` : ''} + + +
    +
    +

    Created: ${new Date(task.created_at).toLocaleString()}

    + ${task.claimed_at ? `

    Claimed: ${new Date(task.claimed_at).toLocaleString()}

    ` : ''} + ${task.started_at ? `

    Started: ${new Date(task.started_at).toLocaleString()}

    ` : ''} + ${task.completed_at ? `

    Completed: ${new Date(task.completed_at).toLocaleString()}

    ` : ''} +
    +
    + `; + } catch (error) { + taskContent.innerHTML = `

    Error loading task details: ${error.message}

    `; + } + } + + async function loadRepositories() { + const repositoriesContent = document.getElementById('repositories-content'); + try { + const data = await apiFetch('/v1/repositories'); + repositoriesContent.innerHTML = ` +
    +

    Add Repository

    +
    + + + +
    +
    +
    + ${data.repositories.map(repo => ` +
    +

    ${repo.full_name}

    +

    Status: ${repo.sync_status}

    + + +
    + `).join('')} +
    + `; + + const addRepoForm = document.getElementById('add-repo-form'); + addRepoForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const repoName = event.target['repo-name'].value; + try { + // Expect input like "owner/repo"; build WHOOSH payload + const parts = (repoName || '').split('/').map(s => s.trim()).filter(Boolean); + if (parts.length !== 2) { + throw new Error('Please enter repository as "owner/repo"'); + } + const [owner, name] = parts; + const url = `https://gitea.chorus.services/${owner}/${name}`; + + const payload = { + name, + owner, + url, + source_type: 'gitea', + monitor_issues: true, + monitor_pull_requests: true, + enable_chorus_integration: true, + default_branch: 'main', + is_private: false, + topics: [], + }; + + await fetchText('/api/v1/repositories', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + loadRepositories(); // Refresh the list + } catch (error) { + alert(`Error adding repository: ${error.message}`); + } + }); + + const syncRepoBtns = document.querySelectorAll('.sync-repo-btn'); + syncRepoBtns.forEach(btn => { + btn.addEventListener('click', async (event) => { + const repoId = event.target.dataset.repoId; + try { + await fetchText(`/api/v1/repositories/${repoId}/sync`, { method: 'POST' }); + loadRepositories(); // Refresh the list + } catch (error) { + alert(`Error syncing repository: ${error.message}`); + } + }); + }); + + const deleteRepoBtns = document.querySelectorAll('.delete-repo-btn'); + deleteRepoBtns.forEach(btn => { + btn.addEventListener('click', async (event) => { + const repoId = event.target.dataset.repoId; + if (!confirm('Delete this repository from monitoring?')) return; + try { + await fetchText(`/api/v1/repositories/${repoId}`, { method: 'DELETE' }); + loadRepositories(); + } catch (error) { + alert(`Error deleting repository: ${error.message}`); + } + }); + }); + + } catch (error) { + repositoriesContent.innerHTML = `

    Error loading repositories: ${error.message}

    `; + } + } + + function loadAnalysis() { + const analysisContent = document.getElementById('analysis-content'); + analysisContent.innerHTML = ` +
    +

    Project Analysis

    +
    + + + +
    +
    +
    + `; + + const analysisForm = document.getElementById('analysis-form'); + analysisForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const repoUrl = event.target['repo-url'].value; + const resultsContainer = document.getElementById('analysis-results'); + resultsContainer.innerHTML = '

    Analyzing...

    '; + incSpinner(); + + try { + const response = await fetch('/api/projects/analyze', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ repository_url: repoUrl, project_name: 'Analysis' }), + }); + + if (!response.ok) { + throw new Error(`Analysis request failed: ${response.statusText}`); + } + + const result = await response.json(); + resultsContainer.innerHTML = ` +
    +

    Analysis Initiated

    +

    Analysis ID: ${result.analysis_id}

    +

    Status: ${result.status}

    +

    Estimated Completion: ${result.estimated_completion_minutes} minutes

    +

    Tracking URL: ${result.tracking_url}

    +

    Please check the tracking URL for updates.

    +
    + `; + } catch (error) { + resultsContainer.innerHTML = `

    Analysis failed: ${error.message}

    `; + } finally { + decSpinner(); + } + }); + } + + // Safety: ensure spinner can’t get stuck on due to unhandled errors + window.addEventListener('error', () => { activeSpinners = 0; hideSpinner(); }); + window.addEventListener('unhandledrejection', () => { activeSpinners = 0; hideSpinner(); }); + + // Auth UI + function initAuthUI() { + const statusEl = document.getElementById('auth-status'); + const inputEl = document.getElementById('auth-token-input'); + const saveBtn = document.getElementById('save-auth-token'); + const clearBtn = document.getElementById('clear-auth-token'); + + function updateStatus() { + const token = (localStorage.getItem('whoosh_token') || getCookie('whoosh_token') || '').trim(); + if (token) { + statusEl.textContent = 'Authed'; + statusEl.classList.add('authed'); + } else { + statusEl.textContent = 'Guest'; + statusEl.classList.remove('authed'); + } + } + + saveBtn.addEventListener('click', () => { + const v = (inputEl.value || '').trim(); + if (!v) { alert('Token is empty'); return; } + localStorage.setItem('whoosh_token', v); + updateStatus(); + inputEl.value = ''; + alert('Token saved. Actions requiring auth should now work.'); + }); + + clearBtn.addEventListener('click', () => { + localStorage.removeItem('whoosh_token'); + document.cookie = 'whoosh_token=; Max-Age=0; path=/'; + updateStatus(); + }); + + updateStatus(); + } + + // Initial load + initAuthUI(); + router(); + window.addEventListener('hashchange', router); +}); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..811bc90 --- /dev/null +++ b/styles.css @@ -0,0 +1,364 @@ +/* Basic Styles */ +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + margin: 0; + background-color: #f4f7f6; + color: #333; + line-height: 1.6; +} + +#app { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +header { + background-color: #2c3e50; /* Darker header for contrast */ + padding: 1rem 2rem; + border-bottom: 1px solid #34495e; + display: flex; + justify-content: space-between; + align-items: center; + color: #ecf0f1; +} + +header h1 { + margin: 0; + font-size: 1.8rem; + color: #ecf0f1; +} + +nav a { + margin: 0 1rem; + text-decoration: none; + color: #bdc3c7; /* Lighter grey for navigation */ + font-weight: 500; + transition: color 0.3s ease; +} + +nav a:hover { + color: #ecf0f1; +} + +#auth-controls { + display: flex; + align-items: center; + gap: 0.5rem; +} + +#auth-status { + font-size: 0.9rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + background: #7f8c8d; +} + +#auth-status.authed { + background: #2ecc71; +} + +#auth-token-input { + width: 220px; + padding: 0.4rem 0.6rem; + border: 1px solid #95a5a6; + border-radius: 4px; + background: #ecf0f1; + color: #2c3e50; +} + +main { + flex-grow: 1; + padding: 2rem; + max-width: 1200px; + margin: 0 auto; + width: 100%; +} + +/* Reusable Components */ +.card { + background-color: #fff; + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0,0,0,0.05); + padding: 1.5rem; + margin-bottom: 2rem; + animation: card-fade-in 0.5s ease-in-out; + border: 1px solid #e0e0e0; +} + +@keyframes card-fade-in { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.button { + background-color: #3498db; /* A vibrant blue */ + color: #fff; + padding: 0.75rem 1.5rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + transition: background-color 0.3s ease; +} + +.button:hover { + background-color: #2980b9; +} + +.button.danger { + background-color: #e74c3c; +} + +.button.danger:hover { + background-color: #c0392b; +} + +.error { + color: #e74c3c; + font-weight: bold; +} + +/* Grid Layouts */ +.dashboard-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); + grid-gap: 2rem; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + grid-gap: 2rem; +} + +.card.full-width { + grid-column: 1 / -1; +} + +.table-wrapper { + width: 100%; + overflow-x: auto; +} + +.role-table { + width: 100%; + border-collapse: collapse; + font-size: 0.95rem; +} + +.role-table th, +.role-table td { + padding: 0.75rem 0.5rem; + border-bottom: 1px solid #e0e0e0; + text-align: left; +} + +.role-table th { + background-color: #f2f4f7; + font-weight: 600; + color: #2c3e50; +} + +.role-table tr:hover td { + background-color: #f8f9fb; +} + +/* Forms */ +form label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; +} + +form input[type="text"] { + width: 100%; + padding: 0.8rem; + margin-bottom: 1rem; + border: 1px solid #ccc; + border-radius: 4px; + box-sizing: border-box; +} + +/* Loading Spinner */ +#loading-spinner { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(255, 255, 255, 0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: 9999; +} + +.spinner { + border: 8px solid #f3f3f3; + border-top: 8px solid #3498db; + border-radius: 50%; + width: 60px; + height: 60px; + animation: spin 2s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.hidden { + display: none; +} + +/* Task Display Styles */ +.badge { + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.875rem; + font-weight: 500; + display: inline-block; +} + +.status-open { background-color: #3b82f6; color: white; } +.status-claimed { background-color: #8b5cf6; color: white; } +.status-in_progress { background-color: #f59e0b; color: white; } +.status-completed { background-color: #10b981; color: white; } +.status-closed { background-color: #6b7280; color: white; } +.status-blocked { background-color: #ef4444; color: white; } + +.priority-critical { background-color: #dc2626; color: white; } +.priority-high { background-color: #f59e0b; color: white; } +.priority-medium { background-color: #3b82f6; color: white; } +.priority-low { background-color: #6b7280; color: white; } + +.tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.tag { + padding: 0.25rem 0.75rem; + background-color: #e5e7eb; + border-radius: 12px; + font-size: 0.875rem; +} + +.tag.tech { + background-color: #dbeafe; + color: #1e40af; +} + +.description { + white-space: pre-wrap; + line-height: 1.6; + padding: 1rem; + background-color: #f9fafb; + border-radius: 4px; + margin-top: 0.5rem; +} + +.timestamps { + font-size: 0.875rem; + color: #6b7280; +} + +.task-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 1.5rem; +} + +.task-card { + background-color: #fff; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); + padding: 1.25rem; + border: 1px solid #e0e0e0; + transition: box-shadow 0.3s ease, transform 0.2s ease; +} + +.task-card:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + transform: translateY(-2px); +} + +.task-card h3 { + margin-top: 0; + margin-bottom: 0.75rem; +} + +.task-card h3 a { + color: #2c3e50; + text-decoration: none; +} + +.task-card h3 a:hover { + color: #3498db; +} + +.task-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.repo-badge { + padding: 0.25rem 0.5rem; + background-color: #f3f4f6; + border-radius: 4px; + font-size: 0.875rem; + color: #6b7280; +} + +.task-description { + font-size: 0.9rem; + color: #6b7280; + line-height: 1.5; + margin-top: 0.5rem; + margin-bottom: 0; +} + +/* Responsive Design */ +@media (max-width: 768px) { + header { + flex-direction: column; + padding: 1rem; + } + + nav { + margin-top: 1rem; + } + + nav a { + margin: 0 0.5rem; + } + + main { + padding: 1rem; + } + + .dashboard-grid, + .grid { + grid-template-columns: 1fr; + grid-gap: 1rem; + } + + .card { + margin-bottom: 1rem; + } + + .task-list { + grid-template-columns: 1fr; + } +}