diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/app.js | 187 | ||||
| -rw-r--r-- | web/style.css | 117 |
2 files changed, 301 insertions, 3 deletions
@@ -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; +} |
