Files
whoosh-ui/script.js
anthonyrawlins ae59ed8608 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 <noreply@anthropic.com>
2025-10-13 08:17:26 +11:00

600 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

document.addEventListener('DOMContentLoaded', () => {
const mainContent = document.getElementById('main-content');
// Simple router
const routes = {
'#dashboard': '<h2>Dashboard</h2><div id="dashboard-content"></div>',
'#councils': '<h2>Councils</h2><div id="councils-content"></div>',
'#tasks': '<h2>Tasks</h2><div id="tasks-content"></div>',
'#repositories': '<h2>Repositories</h2><div id="repositories-content"></div>',
'#analysis': '<h2>Analysis</h2><div id="analysis-content"></div>',
};
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] || '<h2>Page Not Found</h2>';
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 = `
<div class="dashboard-grid">
<div class="card">
<h3>System Status</h3>
<p>Status: ${health.status}</p>
<p>Version: ${health.version}</p>
</div>
<div class="card">
<h3>Metrics</h3>
<pre>${metrics.slice(0, 1000)}...</pre>
</div>
</div>
`;
} catch (error) {
dashboardContent.innerHTML = `<p class="error">Error loading dashboard: ${error.message}</p>`;
}
}
async function loadCouncils() {
const councilsContent = document.getElementById('councils-content');
try {
const data = await apiFetch('/v1/councils');
councilsContent.innerHTML = `
<div class="grid">
${data.councils.map(council => `
<div class="card">
<h3><a href="#councils/${council.id}">${council.project_name}</a></h3>
<p>Status: ${council.status}</p>
<div style="margin-top: 0.75rem;">
<button class="button danger delete-project-btn" data-project-id="${council.id}" data-project-name="${council.project_name}">Delete</button>
</div>
</div>
`).join('')}
</div>
`;
// 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 = `<p class="error">Error loading councils: ${error.message}</p>`;
}
}
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 => `
<tr>
<td>${agent.role_name}</td>
<td>${agent.required ? 'Core' : 'Optional'}</td>
<td>${agent.status || 'unknown'}</td>
<td>${agent.agent_id || '—'}</td>
<td>${agent.agent_name || '—'}</td>
</tr>
`).join('');
const artifactItems = artifacts.artifacts && artifacts.artifacts.length
? artifacts.artifacts.map(artifact => `<li>${artifact.artifact_name} - ${artifact.status}</li>`).join('')
: '<li>No artifacts recorded yet</li>';
councilContent.innerHTML = `
<h2>${council.project_name || ''}</h2>
<div class="grid">
<div class="card">
<h3>Council Details</h3>
<p>Status: ${council.status || 'unknown'}</p>
${council.repository ? `<p>Repository: <a href="${council.repository}">${council.repository}</a></p>` : ''}
${council.project_brief ? `<p>Project Brief: ${council.project_brief}</p>` : ''}
<div style="margin-top: 0.75rem;">
<button class="button danger" id="delete-project-detail" data-project-id="${council.id}" data-project-name="${council.project_name}">Delete Project</button>
</div>
</div>
<div class="card full-width">
<h3>Role Fulfilment</h3>
<div class="table-wrapper">
<table class="role-table">
<thead>
<tr>
<th>Role</th>
<th>Type</th>
<th>Status</th>
<th>Agent ID</th>
<th>Agent Name</th>
</tr>
</thead>
<tbody>
${agentRows || '<tr><td colspan="5">No agents yet</td></tr>'}
</tbody>
</table>
</div>
</div>
</div>
<div class="card">
<h3>Artifacts</h3>
<ul>
${artifactItems}
</ul>
</div>
`;
// 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 = `<p class="error">Error loading council details: ${error.message}</p>`;
}
}
async function loadTasks() {
const tasksContent = document.getElementById('tasks-content');
try {
const data = await apiFetch('/v1/tasks');
tasksContent.innerHTML = `
<div class="task-list">
${data.tasks.map(task => `
<div class="task-card">
<h3><a href="#tasks/${task.id}">${task.title}</a></h3>
<div class="task-meta">
<span class="badge status-${task.status}">${task.status}</span>
<span class="badge priority-${task.priority}">${task.priority}</span>
${task.repository ? `<span class="repo-badge">${task.repository}</span>` : ''}
</div>
${task.tech_stack?.length ? `
<div class="tags">
${task.tech_stack.slice(0, 3).map(tech => `
<span class="tag tech">${tech}</span>
`).join('')}
${task.tech_stack.length > 3 ? `<span class="tag">+${task.tech_stack.length - 3} more</span>` : ''}
</div>
` : ''}
${task.description ? `
<p class="task-description">${task.description.substring(0, 150)}${task.description.length > 150 ? '...' : ''}</p>
` : ''}
</div>
`).join('')}
</div>
`;
} catch (error) {
tasksContent.innerHTML = `<p class="error">Error loading tasks: ${error.message}</p>`;
}
}
async function loadTaskDetail(taskId) {
const taskContent = document.getElementById('main-content');
try {
const task = await apiFetch(`/v1/tasks/${taskId}`);
taskContent.innerHTML = `
<h2>${task.title}</h2>
<div class="card">
<h3>Task Details</h3>
<!-- Basic Info -->
<div class="grid">
<div>
<p><strong>Status:</strong> <span class="badge status-${task.status}">${task.status}</span></p>
<p><strong>Priority:</strong> <span class="badge priority-${task.priority}">${task.priority}</span></p>
<p><strong>Source:</strong> ${task.source_type || 'N/A'}</p>
</div>
<div>
<p><strong>Repository:</strong> ${task.repository || 'N/A'}</p>
<p><strong>Project ID:</strong> ${task.project_id || 'N/A'}</p>
${task.external_url ? `<p><strong>Issue:</strong> <a href="${task.external_url}" target="_blank">View on GITEA</a></p>` : ''}
</div>
</div>
<!-- Estimation & Complexity -->
${task.estimated_hours || task.complexity_score ? `
<hr>
<div class="grid">
${task.estimated_hours ? `<p><strong>Estimated Hours:</strong> ${task.estimated_hours}</p>` : ''}
${task.complexity_score ? `<p><strong>Complexity Score:</strong> ${task.complexity_score.toFixed(2)}</p>` : ''}
</div>
` : ''}
<!-- Labels & Tech Stack -->
${task.labels?.length || task.tech_stack?.length ? `
<hr>
<div class="grid">
${task.labels?.length ? `
<div>
<p><strong>Labels:</strong></p>
<div class="tags">
${task.labels.map(label => `<span class="tag">${label}</span>`).join('')}
</div>
</div>
` : ''}
${task.tech_stack?.length ? `
<div>
<p><strong>Tech Stack:</strong></p>
<div class="tags">
${task.tech_stack.map(tech => `<span class="tag tech">${tech}</span>`).join('')}
</div>
</div>
` : ''}
</div>
` : ''}
<!-- Requirements -->
${task.requirements?.length ? `
<hr>
<p><strong>Requirements:</strong></p>
<ul>
${task.requirements.map(req => `<li>${req}</li>`).join('')}
</ul>
` : ''}
<!-- Description -->
<hr>
<p><strong>Description:</strong></p>
<div class="description">
${task.description || '<em>No description provided</em>'}
</div>
<!-- Assignment Info -->
${task.assigned_team_id || task.assigned_agent_id ? `
<hr>
<p><strong>Assignment:</strong></p>
<div class="grid">
${task.assigned_team_id ? `<p>Team: ${task.assigned_team_id}</p>` : ''}
${task.assigned_agent_id ? `<p>Agent: ${task.assigned_agent_id}</p>` : ''}
</div>
` : ''}
<!-- Timestamps -->
<hr>
<div class="grid timestamps">
<p><strong>Created:</strong> ${new Date(task.created_at).toLocaleString()}</p>
${task.claimed_at ? `<p><strong>Claimed:</strong> ${new Date(task.claimed_at).toLocaleString()}</p>` : ''}
${task.started_at ? `<p><strong>Started:</strong> ${new Date(task.started_at).toLocaleString()}</p>` : ''}
${task.completed_at ? `<p><strong>Completed:</strong> ${new Date(task.completed_at).toLocaleString()}</p>` : ''}
</div>
</div>
`;
} catch (error) {
taskContent.innerHTML = `<p class="error">Error loading task details: ${error.message}</p>`;
}
}
async function loadRepositories() {
const repositoriesContent = document.getElementById('repositories-content');
try {
const data = await apiFetch('/v1/repositories');
repositoriesContent.innerHTML = `
<div class="card">
<h3>Add Repository</h3>
<form id="add-repo-form">
<label for="repo-name">Repository Name (e.g., owner/repo):</label>
<input type="text" id="repo-name" name="repo-name" required>
<button type="submit" class="button">Add</button>
</form>
</div>
<div class="grid">
${data.repositories.map(repo => `
<div class="card">
<h3>${repo.full_name}</h3>
<p>Status: ${repo.sync_status}</p>
<button class="button sync-repo-btn" data-repo-id="${repo.id}">Sync</button>
<button class="button danger delete-repo-btn" data-repo-id="${repo.id}" style="margin-left: 0.5rem;">Delete</button>
</div>
`).join('')}
</div>
`;
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 = `<p class="error">Error loading repositories: ${error.message}</p>`;
}
}
function loadAnalysis() {
const analysisContent = document.getElementById('analysis-content');
analysisContent.innerHTML = `
<div class="card">
<h3>Project Analysis</h3>
<form id="analysis-form">
<label for="repo-url">Repository URL:</label>
<input type="text" id="repo-url" name="repo-url" required>
<button type="submit" class="button">Analyze</button>
</form>
<div id="analysis-results"></div>
</div>
`;
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 = '<p>Analyzing...</p>';
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 = `
<div class="card">
<h4>Analysis Initiated</h4>
<p>Analysis ID: ${result.analysis_id}</p>
<p>Status: ${result.status}</p>
<p>Estimated Completion: ${result.estimated_completion_minutes} minutes</p>
<p>Tracking URL: <a href="${result.tracking_url}" target="_blank">${result.tracking_url}</a></p>
<p>Please check the tracking URL for updates.</p>
</div>
`;
} catch (error) {
resultsContainer.innerHTML = `<p class="error">Analysis failed: ${error.message}</p>`;
} finally {
decSpinner();
}
});
}
// Safety: ensure spinner cant 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);
});