diff --git a/dashboard/app.js b/dashboard/app.js index 40a5361..30985cf 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -20,6 +20,7 @@ var appState = { events: [], // recent events (newest first), capped at 50 eventCount: 0, selectedAgent: null, // composite key of currently selected agent + selectedRun: null, // correlation_id of currently selected run runs: {}, // correlation_id -> run state { stage, status, steps, workflow_name } }; @@ -32,6 +33,17 @@ function runStageFromEvent(evt) { return null; } +// Format milliseconds as human-readable duration +function formatDuration(ms) { + if (!ms && ms !== 0) return "—"; + if (ms < 1000) return ms + "ms"; + var s = Math.floor(ms / 1000); + if (s < 60) return s + "s"; + var m = Math.floor(s / 60); + s = s % 60; + return m + "m " + s + "s"; +} + // Process a workflow event and update run tracking state function processWorkflowEvent(evt) { var id = evt.correlation_id; @@ -45,7 +57,8 @@ function processWorkflowEvent(evt) { stepIndex: 0, totalSteps: (evt.data && evt.data.total_steps) || 0, workflowName: (evt.data && evt.data.workflow_name) || "", - startedAt: evt.timestamp + startedAt: evt.timestamp, + steps: [] // step timeline entries }; return; } @@ -58,13 +71,39 @@ function processWorkflowEvent(evt) { run.stepIndex = (evt.data && evt.data.step_index) || run.stepIndex; run.totalSteps = (evt.data && evt.data.total_steps) || run.totalSteps; run.status = "running"; + // Record step start in timeline + var idx = evt.data && evt.data.step_index; + if (idx) { + run.steps[idx] = { + index: idx, + type: (evt.data && evt.data.step_type) || "unknown", + agent: evt.agent || null, + stage: run.stage, + status: "running", + startedAt: evt.timestamp, + durationMs: null, + message: null + }; + } } else if (evt.event === "workflow.step.completed") { - // step done — stage will update when next step starts + var cidx = evt.data && evt.data.step_index; + if (cidx && run.steps[cidx]) { + run.steps[cidx].status = "completed"; + run.steps[cidx].durationMs = (evt.data && evt.data.duration_ms != null) ? evt.data.duration_ms : null; + run.steps[cidx].message = (evt.data && evt.data.message) || null; + } } else if (evt.event === "workflow.step.failed") { run.stage = runStageFromEvent(evt); run.status = "failed"; + var fidx = evt.data && evt.data.step_index; + if (fidx && run.steps[fidx]) { + run.steps[fidx].status = "failed"; + run.steps[fidx].durationMs = (evt.data && evt.data.duration_ms) || null; + run.steps[fidx].message = (evt.data && evt.data.message) || null; + } } else if (evt.event === "workflow.completed") { run.status = "completed"; + run.finishedAt = evt.timestamp; // Auto-remove completed runs after 8 seconds so the dot exits setTimeout(function() { delete appState.runs[id]; @@ -72,6 +111,7 @@ function processWorkflowEvent(evt) { }, 8000); } else if (evt.event === "workflow.failed") { run.status = "failed"; + run.finishedAt = evt.timestamp; } } @@ -233,10 +273,14 @@ function renderPipeline() { if (stageRuns.length > 0) { var dotsRow = el("div", "run-dots"); for (var r = 0; r < stageRuns.length; r++) { - var dot = el("span", "run-dot"); + var dot = el("button", "run-dot"); if (stageRuns[r].status === "failed") dot.classList.add("run-dot-failed"); else dot.classList.add("run-dot-active"); + if (appState.selectedRun === stageRuns[r].id) dot.classList.add("run-dot-selected"); dot.title = stageRuns[r].workflowName + " (step " + stageRuns[r].stepIndex + "/" + stageRuns[r].totalSteps + ")"; + dot.addEventListener("click", (function(rid) { + return function(e) { e.stopPropagation(); selectRun(rid); }; + })(stageRuns[r].id)); dotsRow.appendChild(dot); } card.appendChild(dotsRow); @@ -265,8 +309,22 @@ function renderPipeline() { } var $hudRuns = document.getElementById("hud-runs"); if ($hudRuns) { - $hudRuns.textContent = runningCount > 0 ? runningCount + " run" + (runningCount > 1 ? "s" : "") : ""; - $hudRuns.className = "hud-item" + (runningCount > 0 ? " active-run" : ""); + if (runningCount > 0) { + // Show run count + current stage of first running run + var runStageLabel = ""; + for (i = 0; i < runIds.length; i++) { + var r = appState.runs[runIds[i]]; + if (r.status === "running" && r.stage) { + runStageLabel = " \u2192 " + r.stage + " " + r.stepIndex + "/" + r.totalSteps; + break; + } + } + $hudRuns.textContent = runningCount + " run" + (runningCount > 1 ? "s" : "") + runStageLabel; + $hudRuns.className = "hud-item active-run"; + } else { + $hudRuns.textContent = ""; + $hudRuns.className = "hud-item"; + } } // Bottleneck indicator @@ -336,12 +394,89 @@ function selectAgent(key) { function closeDrawer() { appState.selectedAgent = null; + appState.selectedRun = null; $drawer.classList.remove("open"); renderPipeline(); } $detailClose.addEventListener("click", closeDrawer); +// ── Run detail drawer (step timeline) ── +function selectRun(runId) { + if (appState.selectedRun === runId) { + closeDrawer(); + return; + } + appState.selectedRun = runId; + appState.selectedAgent = null; + var run = appState.runs[runId]; + if (!run) { closeDrawer(); return; } + + $detailName.textContent = run.workflowName + " — " + run.status; + + // Meta info + $detailMeta.innerHTML = ""; + var meta = [ + ["run", runId.substring(0, 12)], + ["status", run.status], + ["step", run.stepIndex + "/" + run.totalSteps], + ["started", timeAgo(run.startedAt)], + ]; + if (run.finishedAt) meta.push(["finished", timeAgo(run.finishedAt)]); + if (run.stage) meta.push(["stage", run.stage]); + for (var i = 0; i < meta.length; i++) { + var s = el("span", null, null); + var label = el("span", null, meta[i][0] + ":"); + s.appendChild(label); + s.appendChild(document.createTextNode(" " + meta[i][1])); + $detailMeta.appendChild(s); + } + + // Step timeline + while ($detailEvts.firstChild) $detailEvts.removeChild($detailEvts.firstChild); + var hasSteps = false; + for (var j = 1; j <= run.totalSteps; j++) { + var step = run.steps[j]; + if (!step) continue; + hasSteps = true; + var li = document.createElement("li"); + li.className = "step-row"; + + // Step index badge + var badge = el("span", "step-badge", String(step.index)); + if (step.status === "completed") badge.classList.add("step-ok"); + else if (step.status === "failed") badge.classList.add("step-fail"); + else badge.classList.add("step-running"); + li.appendChild(badge); + + // Step type + agent + var desc = step.type; + if (step.agent) desc += " \u2192 " + step.agent; + li.appendChild(el("span", "step-desc", desc)); + + // Duration + if (step.durationMs !== null) { + li.appendChild(el("span", "step-dur", formatDuration(step.durationMs))); + } else if (step.status === "running") { + li.appendChild(el("span", "step-dur running-text", "running\u2026")); + } + + // Failure message + if (step.status === "failed" && step.message) { + var msg = el("div", "step-msg", step.message); + li.appendChild(msg); + } + + $detailEvts.appendChild(li); + } + if (!hasSteps) { + $detailEvts.appendChild(el("li", null, "no steps recorded yet")); + } + + $drawer.classList.add("open"); + renderPipeline(); +} + // ── Render the event timeline ── function renderTimeline() { while ($eventList.firstChild) $eventList.removeChild($eventList.firstChild); @@ -403,8 +538,18 @@ function fetchHealth() { function connectSSE() { var es = new EventSource("/v1/events/stream"); + var wasConnected = false; + es.onopen = function() { $connDot.className = "conn-dot connected"; + // On reconnect, refetch health snapshot only — don't replay the full + // event log into live state as it can duplicate run entries and refire + // side effects. The SSE stream will deliver any events missed during + // the disconnection window. + if (wasConnected) { + fetchHealth(); + } + wasConnected = true; }; es.onerror = function() { @@ -527,8 +672,48 @@ if ($dispatchGo) { }); } +// ── Historical run reconstruction ── +// Fetch past events from /v1/events and replay workflow events to rebuild run state +function reconstructRuns() { + return fetch("/v1/events").then(function(res) { + return res.json(); + }).then(function(json) { + var events = json.data || []; + // Events come oldest-first from the API; replay in order + for (var i = 0; i < events.length; i++) { + var evt = events[i]; + if (evt.event && evt.event.indexOf("workflow.") === 0) { + processWorkflowEvent(evt); + } + // Also populate the event timeline (newest first) + appState.events.unshift(evt); + if (appState.events.length > 50) appState.events.length = 50; + } + // Clean up orphan runs: if a run was "started" but no terminal event + // was found in the event window, mark it as stale. The /v1/events + // endpoint only returns recent events, so old runs without a matching + // completed/failed event are zombies. + var runIds = Object.keys(appState.runs); + var cutoff = Date.now() - 30 * 60 * 1000; // 30 min age threshold + for (var j = 0; j < runIds.length; j++) { + var run = appState.runs[runIds[j]]; + if (run.status === "running") { + var startedMs = new Date(run.startedAt).getTime(); + if (startedMs < cutoff) { + delete appState.runs[runIds[j]]; + } + } + } + scheduleRender(); + }).catch(function(e) { + console.warn("event reconstruction failed:", e); + }); +} + // ── Boot ── fetchHealth().then(function() { + return reconstructRuns(); +}).then(function() { connectSSE(); // Re-fetch health periodically to stay in sync setInterval(fetchHealth, 15000); diff --git a/dashboard/style.css b/dashboard/style.css index a508ca4..81a1d4b 100644 --- a/dashboard/style.css +++ b/dashboard/style.css @@ -326,6 +326,46 @@ main#factory { #detail-drawer .detail-events .evt-type { color: var(--text-secondary); font-weight: 500; } #detail-drawer .detail-events .evt-time { float: right; font-size: 0.625rem; } +/* step timeline rows */ +.step-row { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} +.step-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + font-size: 0.5625rem; + font-weight: 600; + flex-shrink: 0; + border: 1px solid var(--border); + color: var(--dim); +} +.step-badge.step-ok { border-color: var(--working); color: var(--working); } +.step-badge.step-fail { border-color: var(--blocked); color: var(--blocked); background: rgba(239,68,68,0.1); } +.step-badge.step-running { border-color: var(--working); color: var(--working); animation: dot-pulse 1.2s ease-in-out infinite; } +.step-desc { color: var(--text-secondary); flex: 1; min-width: 0; } +.step-dur { color: var(--dim); font-size: 0.625rem; flex-shrink: 0; } +.step-dur.running-text { color: var(--working); } +.step-msg { + width: 100%; + color: var(--blocked); + font-size: 0.625rem; + padding: 2px 0 2px 28px; + overflow-wrap: anywhere; +} + +/* selected run dot */ +.run-dot-selected { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + /* ── dispatch panel ── */ #dispatch-panel { border-top: 1px solid var(--border); diff --git a/dashboard/test.html b/dashboard/test.html index 00e323a..8dc3dfc 100644 --- a/dashboard/test.html +++ b/dashboard/test.html @@ -215,6 +215,16 @@