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>
This commit is contained in:
36
index.html
Normal file
36
index.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WHOOSH UI</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header>
|
||||
<h1>WHOOSH</h1>
|
||||
<nav>
|
||||
<a href="#dashboard">Dashboard</a>
|
||||
<a href="#councils">Councils</a>
|
||||
<a href="#tasks">Tasks</a>
|
||||
<a href="#repositories">Repositories</a>
|
||||
<a href="#analysis">Analysis</a>
|
||||
</nav>
|
||||
<div id="auth-controls">
|
||||
<span id="auth-status" title="Authorization status">Guest</span>
|
||||
<input type="password" id="auth-token-input" placeholder="Paste token" autocomplete="off">
|
||||
<button class="button" id="save-auth-token">Save</button>
|
||||
<button class="button danger" id="clear-auth-token">Clear</button>
|
||||
</div>
|
||||
</header>
|
||||
<main id="main-content">
|
||||
<!-- Content will be loaded here -->
|
||||
</main>
|
||||
<div id="loading-spinner" class="hidden">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
package.json
Normal file
1
package.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
599
script.js
Normal file
599
script.js
Normal file
@@ -0,0 +1,599 @@
|
||||
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 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);
|
||||
});
|
||||
364
styles.css
Normal file
364
styles.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user