summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-19 23:03:56 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-19 23:03:56 +0000
commit7e8967decbc8221694953abf1435fda8aaf18824 (patch)
tree3cee147c32da1565ec1e5ea72b0ddf131077dd66 /web
parente2f5379e00747f17d91ee1c90828d4494c2eb4d8 (diff)
feat: agent status dashboard with availability timeline and Gemini quota detection
- Detect Gemini TerminalQuotaError (daily quota) as BUDGET_EXCEEDED, not generic FAILED - Surface container stderr tail in error so quota/rate-limit classifiers can match it - Add agent_events table to persist rate-limit start/recovery events across restarts - Add GET /api/agents/status endpoint returning live agent state + 24h event history - Stats dashboard: agent status cards, 24h availability timeline, per-run execution table Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'web')
-rw-r--r--web/app.js187
-rw-r--r--web/style.css117
2 files changed, 301 insertions, 3 deletions
diff --git a/web/app.js b/web/app.js
index 6ddb23c..5d99984 100644
--- a/web/app.js
+++ b/web/app.js
@@ -1175,8 +1175,11 @@ function renderActiveTab(allTasks) {
renderAllPanel(allTasks);
break;
case 'stats':
- fetchRecentExecutions(BASE_PATH, fetch)
- .then(execs => renderStatsPanel(allTasks, execs))
+ Promise.all([
+ fetchRecentExecutions(BASE_PATH, fetch),
+ fetch(`${BASE_PATH}/api/agents/status?since=${encodeURIComponent(new Date(Date.now() - 24*60*60*1000).toISOString())}`).then(r => r.ok ? r.json() : { agents: [], events: [] }),
+ ])
+ .then(([execs, agentData]) => renderStatsPanel(allTasks, execs, agentData))
.catch(() => {});
break;
case 'drops':
@@ -2432,7 +2435,7 @@ function formatDurationMs(ms) {
return rm > 0 ? `${h}h ${rm}m` : `${h}h`;
}
-function renderStatsPanel(tasks, executions) {
+function renderStatsPanel(tasks, executions, agentData = { agents: [], events: [] }) {
const panel = document.querySelector('[data-panel="stats"]');
if (!panel) return;
@@ -2566,7 +2569,185 @@ function renderStatsPanel(tasks, executions) {
execSection.appendChild(chartSection);
}
+ // ── Per-execution detail table ─────────────────────────────────────────────
+ if (executions.length > 0) {
+ const tableWrap = document.createElement('div');
+ tableWrap.className = 'stats-exec-table-wrap';
+
+ const tableLabel = document.createElement('p');
+ tableLabel.className = 'stats-bar-chart-label';
+ tableLabel.textContent = 'Recent runs';
+ tableWrap.appendChild(tableLabel);
+
+ const table = document.createElement('table');
+ table.className = 'stats-exec-table';
+ table.innerHTML = '<thead><tr><th>Task</th><th>Outcome</th><th>Cost</th><th>Duration</th><th>Started</th></tr></thead>';
+ const tbody = document.createElement('tbody');
+ for (const ex of executions.slice(0, 20)) {
+ const tr = document.createElement('tr');
+ const durationMs = ex.duration_ms != null ? formatDurationMs(ex.duration_ms) : '—';
+ const cost = ex.cost_usd > 0 ? `$${ex.cost_usd.toFixed(3)}` : '—';
+ const started = ex.started_at ? new Date(ex.started_at).toLocaleTimeString() : '—';
+ const state = (ex.state || '').toUpperCase();
+ tr.innerHTML = `<td class="stats-exec-name">${ex.task_name || ex.task_id}</td><td><span class="state-badge" data-state="${state}">${state.replace(/_/g,' ')}</span></td><td>${cost}</td><td>${durationMs}</td><td>${started}</td>`;
+ tbody.appendChild(tr);
+ }
+ table.appendChild(tbody);
+ tableWrap.appendChild(table);
+ execSection.appendChild(tableWrap);
+ }
+
panel.appendChild(execSection);
+
+ // ── Agent Status ───────────────────────────────────────────────────────────
+ const agentSection = document.createElement('div');
+ agentSection.className = 'stats-section';
+
+ const agentHeading = document.createElement('h2');
+ agentHeading.textContent = 'Agent Status';
+ agentSection.appendChild(agentHeading);
+
+ const agents = agentData.agents || [];
+ const agentEvents = agentData.events || [];
+
+ if (agents.length === 0) {
+ const none = document.createElement('p');
+ none.className = 'task-meta';
+ none.textContent = 'No agents registered.';
+ agentSection.appendChild(none);
+ } else {
+ // Status cards row
+ const cardsRow = document.createElement('div');
+ cardsRow.className = 'stats-agent-cards';
+ for (const ag of agents) {
+ const card = document.createElement('div');
+ card.className = 'stats-agent-card';
+ const statusClass = ag.rate_limited ? 'agent-rate-limited' : 'agent-available';
+ card.classList.add(statusClass);
+
+ const nameEl = document.createElement('span');
+ nameEl.className = 'stats-agent-name';
+ nameEl.textContent = ag.agent;
+
+ const statusEl = document.createElement('span');
+ statusEl.className = 'stats-agent-status';
+ if (ag.rate_limited && ag.until) {
+ const untilDate = new Date(ag.until);
+ const minsLeft = Math.max(0, Math.round((untilDate - Date.now()) / 60000));
+ statusEl.textContent = `Rate limited — ${minsLeft}m remaining`;
+ } else {
+ statusEl.textContent = ag.active_tasks > 0 ? `Active (${ag.active_tasks} running)` : 'Available';
+ }
+
+ card.appendChild(nameEl);
+ card.appendChild(statusEl);
+ cardsRow.appendChild(card);
+ }
+ agentSection.appendChild(cardsRow);
+
+ // Availability timeline (last 24h)
+ const now = Date.now();
+ const windowMs = 24 * 60 * 60 * 1000;
+ const windowStart = now - windowMs;
+
+ const timelineHeading = document.createElement('p');
+ timelineHeading.className = 'stats-bar-chart-label';
+ timelineHeading.textContent = 'Availability last 24h';
+ agentSection.appendChild(timelineHeading);
+
+ // Group events by agent
+ const eventsByAgent = {};
+ for (const ev of agentEvents) {
+ if (!eventsByAgent[ev.agent]) eventsByAgent[ev.agent] = [];
+ eventsByAgent[ev.agent].push(ev);
+ }
+
+ for (const ag of agents) {
+ const evs = (eventsByAgent[ag.agent] || []).slice().sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
+
+ const row = document.createElement('div');
+ row.className = 'stats-timeline-row';
+
+ const label = document.createElement('span');
+ label.className = 'stats-timeline-label';
+ label.textContent = ag.agent;
+ row.appendChild(label);
+
+ const track = document.createElement('div');
+ track.className = 'stats-timeline-track';
+
+ // Build segments: walk events and produce [start, end, state] intervals
+ const segments = [];
+ let cursor = windowStart;
+ // Reconstruct: before first event, assume available unless currently rate-limited with an until before window
+ let inRateLimit = false;
+
+ for (const ev of evs) {
+ const evTime = Math.max(windowStart, new Date(ev.timestamp).getTime());
+ if (evTime > cursor) {
+ segments.push({ start: cursor, end: evTime, limited: inRateLimit });
+ }
+ cursor = evTime;
+ if (ev.event === 'rate_limited') {
+ inRateLimit = true;
+ } else if (ev.event === 'available') {
+ inRateLimit = false;
+ }
+ }
+ // Tail to now
+ if (cursor < now) {
+ // If currently rate limited use current agent state
+ segments.push({ start: cursor, end: now, limited: ag.rate_limited || inRateLimit });
+ }
+
+ for (const seg of segments) {
+ const pct = ((seg.end - seg.start) / windowMs) * 100;
+ if (pct < 0.01) continue;
+ const span = document.createElement('div');
+ span.className = 'stats-timeline-seg';
+ span.classList.add(seg.limited ? 'seg-limited' : 'seg-available');
+ span.style.width = `${pct.toFixed(2)}%`;
+ const mins = Math.round((seg.end - seg.start) / 60000);
+ span.title = `${seg.limited ? 'Rate limited' : 'Available'} — ${mins}m`;
+ track.appendChild(span);
+ }
+
+ row.appendChild(track);
+
+ // Legend labels
+ const timeLabels = document.createElement('div');
+ timeLabels.className = 'stats-timeline-timelabels';
+ timeLabels.innerHTML = '<span>24h ago</span><span>now</span>';
+ row.appendChild(timeLabels);
+
+ agentSection.appendChild(row);
+ }
+
+ // Rate-limit event log
+ if (agentEvents.length > 0) {
+ const evLogLabel = document.createElement('p');
+ evLogLabel.className = 'stats-bar-chart-label';
+ evLogLabel.textContent = 'Rate-limit events (last 24h)';
+ agentSection.appendChild(evLogLabel);
+
+ const evTable = document.createElement('table');
+ evTable.className = 'stats-exec-table';
+ evTable.innerHTML = '<thead><tr><th>Agent</th><th>Event</th><th>Reason</th><th>Until</th><th>Time</th></tr></thead>';
+ const evTbody = document.createElement('tbody');
+ for (const ev of agentEvents.slice(0, 30)) {
+ const tr = document.createElement('tr');
+ const until = ev.until ? new Date(ev.until).toLocaleTimeString() : '—';
+ const ts = new Date(ev.timestamp).toLocaleTimeString();
+ const eventClass = ev.event === 'rate_limited' ? 'state-badge" data-state="FAILED' : 'state-badge" data-state="COMPLETED';
+ tr.innerHTML = `<td>${ev.agent}</td><td><span class="${eventClass}">${ev.event.replace(/_/g,' ')}</span></td><td>${ev.reason || '—'}</td><td>${until}</td><td>${ts}</td>`;
+ evTbody.appendChild(tr);
+ }
+ evTable.appendChild(evTbody);
+ agentSection.appendChild(evTable);
+ }
+ }
+
+ panel.appendChild(agentSection);
}
// ── Web Push Notifications ────────────────────────────────────────────────────
diff --git a/web/style.css b/web/style.css
index 7a3eb71..37f3b61 100644
--- a/web/style.css
+++ b/web/style.css
@@ -1550,3 +1550,120 @@ dialog label select:focus {
width: 80px;
flex-shrink: 0;
}
+
+/* ── Execution detail table ─────────────────────────────────────────────── */
+.stats-exec-table-wrap {
+ margin-top: 1rem;
+}
+
+.stats-exec-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.8rem;
+}
+
+.stats-exec-table th,
+.stats-exec-table td {
+ padding: 0.35rem 0.5rem;
+ text-align: left;
+ border-bottom: 1px solid var(--border);
+}
+
+.stats-exec-table th {
+ color: var(--text-muted);
+ font-weight: 500;
+}
+
+.stats-exec-name {
+ max-width: 220px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ display: block;
+}
+
+/* ── Agent Status ───────────────────────────────────────────────────────── */
+.stats-agent-cards {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ margin-bottom: 1.25rem;
+}
+
+.stats-agent-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ padding: 0.75rem 1rem;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ min-width: 160px;
+}
+
+.stats-agent-card.agent-available {
+ border-color: var(--state-completed);
+ background: color-mix(in srgb, var(--state-completed) 8%, transparent);
+}
+
+.stats-agent-card.agent-rate-limited {
+ border-color: var(--state-failed);
+ background: color-mix(in srgb, var(--state-failed) 8%, transparent);
+}
+
+.stats-agent-name {
+ font-weight: 600;
+ font-size: 0.9rem;
+ text-transform: capitalize;
+}
+
+.stats-agent-status {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+}
+
+/* ── Availability Timeline ─────────────────────────────────────────────── */
+.stats-timeline-row {
+ margin-bottom: 0.75rem;
+}
+
+.stats-timeline-label {
+ display: block;
+ font-size: 0.78rem;
+ color: var(--text-muted);
+ margin-bottom: 0.2rem;
+ text-transform: capitalize;
+}
+
+.stats-timeline-track {
+ display: flex;
+ height: 18px;
+ border-radius: 4px;
+ overflow: hidden;
+ background: var(--bg-card);
+ width: 100%;
+}
+
+.stats-timeline-seg {
+ height: 100%;
+ transition: opacity 0.1s;
+}
+
+.stats-timeline-seg:hover {
+ opacity: 0.8;
+}
+
+.seg-available {
+ background: var(--state-completed);
+}
+
+.seg-limited {
+ background: var(--state-failed);
+}
+
+.stats-timeline-timelabels {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.68rem;
+ color: var(--text-muted);
+ margin-top: 0.15rem;
+}