From 550155011ac6706ce6722d203a3450b547db0f09 Mon Sep 17 00:00:00 2001 From: adam Date: Sat, 21 Mar 2026 15:56:59 +1100 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20Phase=201b=20=E2=80=94=20SSE=20work?= =?UTF-8?q?flow=20step=20events,=20event=20rotation,=20lane=20animation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit workflow.step.started/completed/failed events from the workflow executor so the dashboard can track work items flowing through the pipeline. Add event file rotation (archive at 5k, retain 2.5k) to prevent unbounded growth. Dashboard now renders animated run dots on stages, marching-dash flow connectors, and a HUD run counter. 20+ new JS tests cover run lifecycle, failure paths, and concurrent runs. Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/app.js | 109 ++++++++++++++++++++++++++++-- dashboard/index.html | 1 + dashboard/style.css | 63 +++++++++++++++++ dashboard/test.html | 154 ++++++++++++++++++++++++++++++++++++++++++ src/automation/mod.rs | 62 +++++++++++++++++ src/state/mod.rs | 74 ++++++++++++++++++++ 6 files changed, 459 insertions(+), 4 deletions(-) diff --git a/dashboard/app.js b/dashboard/app.js index f129727..85a5586 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -20,8 +20,72 @@ var appState = { events: [], // recent events (newest first), capped at 50 eventCount: 0, selectedAgent: null, // composite key of currently selected agent + runs: {}, // correlation_id -> run state { stage, status, steps, workflow_name } }; +// ── Run tracking ── +// Maps a step's agent to a pipeline stage so we can position the dot +function runStageFromEvent(evt) { + if (!evt || !evt.data) return null; + var agent = evt.agent || (evt.data && evt.data.agent); + if (agent) return stageFor(agent); + return null; +} + +// Process a workflow event and update run tracking state +function processWorkflowEvent(evt) { + var id = evt.correlation_id; + if (!id) return; + + if (evt.event === "workflow.started") { + appState.runs[id] = { + id: id, + status: "running", + stage: null, + stepIndex: 0, + totalSteps: (evt.data && evt.data.total_steps) || 0, + workflowName: (evt.data && evt.data.workflow_name) || "", + startedAt: evt.timestamp + }; + return; + } + + var run = appState.runs[id]; + if (!run) return; + + if (evt.event === "workflow.step.started") { + run.stage = runStageFromEvent(evt); + run.stepIndex = (evt.data && evt.data.step_index) || run.stepIndex; + run.totalSteps = (evt.data && evt.data.total_steps) || run.totalSteps; + run.status = "running"; + } else if (evt.event === "workflow.step.completed") { + // step done — stage will update when next step starts + } else if (evt.event === "workflow.step.failed") { + run.stage = runStageFromEvent(evt); + run.status = "failed"; + } else if (evt.event === "workflow.completed") { + run.status = "completed"; + // Auto-remove completed runs after 8 seconds so the dot exits + setTimeout(function() { + delete appState.runs[id]; + scheduleRender(); + }, 8000); + } else if (evt.event === "workflow.failed") { + run.status = "failed"; + } +} + +// Get active runs at a given stage +function runsAtStage(stage) { + var result = []; + var ids = Object.keys(appState.runs); + for (var i = 0; i < ids.length; i++) { + var run = appState.runs[ids[i]]; + if (run.stage === stage) result.push(run); + } + return result; +} + // Build a composite key for workspace-scoped agent storage function agentKey(workspace, agent) { return (workspace || "_") + ":" + agent; @@ -127,12 +191,17 @@ function renderPipeline() { // Flow connector between stages if (i > 0) { var conn = el("div", "flow-connector"); - var prevAgents = stageAgents[STAGE_ORDER[i - 1]] || []; + var prevStage = STAGE_ORDER[i - 1]; + var prevAgents = stageAgents[prevStage] || []; var prevActive = false; for (var p = 0; p < prevAgents.length; p++) { if (stateClass(prevAgents[p]) === "working") { prevActive = true; break; } } if (prevActive) conn.classList.add("active"); + // Add flowing animation if a run is transitioning through this connector + if (runsAtStage(prevStage).length > 0 || runsAtStage(stage).length > 0) { + conn.classList.add("flowing"); + } $pipeline.appendChild(conn); } @@ -159,6 +228,20 @@ function renderPipeline() { })(key)); } + // Render work-item dots for active runs at this stage + var stageRuns = runsAtStage(stage); + if (stageRuns.length > 0) { + var dotsRow = el("div", "run-dots"); + for (var r = 0; r < stageRuns.length; r++) { + var dot = el("span", "run-dot"); + if (stageRuns[r].status === "failed") dot.classList.add("run-dot-failed"); + else dot.classList.add("run-dot-active"); + dot.title = stageRuns[r].workflowName + " (step " + stageRuns[r].stepIndex + "/" + stageRuns[r].totalSteps + ")"; + dotsRow.appendChild(dot); + } + card.appendChild(dotsRow); + } + $pipeline.appendChild(card); } @@ -174,6 +257,18 @@ function renderPipeline() { $hudBlock.textContent = blocked + " blocked"; $hudBlock.className = "hud-item" + (blocked > 0 ? " alert" : ""); + // Run count in HUD + var runIds = Object.keys(appState.runs); + var runningCount = 0; + for (i = 0; i < runIds.length; i++) { + if (appState.runs[runIds[i]].status === "running") runningCount++; + } + 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" : ""); + } + // Bottleneck indicator var bottleneck = findBottleneck(); if (bottleneck) { @@ -338,17 +433,23 @@ function connectSSE() { appState.agents[key] = merged; } + // Track workflow runs + if (data.event && data.event.indexOf("workflow.") === 0) { + processWorkflowEvent(data); + } + scheduleRender(); } catch (_) { /* ignore parse errors */ } }; - // Event types actually emitted by the server (state/mod.rs transition_events) + // Event types actually emitted by the server var eventTypes = [ "agent.started", "agent.stopped", "agent.working", "agent.idle", "agent.auth_failed", "agent.auth_recovered", - "agent.rate_limited", "agent.provider_down", "agent.provider_recovered" - // Phase 1b will add: workflow.step, workflow.stage_transition, run.completed + "agent.rate_limited", "agent.provider_down", "agent.provider_recovered", + "workflow.started", "workflow.completed", "workflow.failed", + "workflow.step.started", "workflow.step.completed", "workflow.step.failed" ]; for (var i = 0; i < eventTypes.length; i++) { es.addEventListener(eventTypes[i], handler); diff --git a/dashboard/index.html b/dashboard/index.html index cf9a875..b138ad0 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -15,6 +15,7 @@
0 active 0 blocked +
diff --git a/dashboard/style.css b/dashboard/style.css index 84c7ab4..aa8a8bb 100644 --- a/dashboard/style.css +++ b/dashboard/style.css @@ -214,6 +214,54 @@ main#factory { } .stage.working { animation: pulse-border 2s ease-in-out infinite; } +/* ── run dots — work items flowing through the pipeline ── */ +.run-dots { + display: flex; + gap: 6px; + justify-content: center; + margin-top: 8px; + min-height: 12px; +} +.run-dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; + flex-shrink: 0; +} +.run-dot-active { + background: var(--working); + animation: dot-pulse 1.2s ease-in-out infinite; +} +.run-dot-failed { + background: var(--blocked); + box-shadow: 0 0 6px rgba(239,68,68,0.4); +} +@keyframes dot-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.6; transform: scale(0.85); } +} + +/* flow connector with animated dash when carrying a run */ +.flow-connector.flowing { + background: repeating-linear-gradient( + 90deg, + var(--flow-active) 0px, + var(--flow-active) 6px, + transparent 6px, + transparent 12px + ); + background-size: 24px 2px; + animation: flow-march 0.6s linear infinite; +} +@keyframes flow-march { + from { background-position: 0 0; } + to { background-position: 24px 0; } +} + +/* HUD active-run indicator */ +.hud-item.active-run { color: var(--working); } + /* empty/unknown stage */ .stage.empty { border-style: dashed; @@ -333,6 +381,21 @@ footer#timeline h3 { border-top-color: inherit; } .stage { min-width: auto; min-height: 44px; } + .flow-connector.flowing { + background: repeating-linear-gradient( + 180deg, + var(--flow-active) 0px, + var(--flow-active) 6px, + transparent 6px, + transparent 12px + ); + background-size: 2px 24px; + animation: flow-march-v 0.6s linear infinite; + } + @keyframes flow-march-v { + from { background-position: 0 0; } + to { background-position: 0 24px; } + } #detail-drawer { max-height: 50vh; } footer#timeline { max-height: 120px; } } diff --git a/dashboard/test.html b/dashboard/test.html index 528294f..00e323a 100644 --- a/dashboard/test.html +++ b/dashboard/test.html @@ -207,6 +207,160 @@

tutti dashboard tests

}); assert("single working agent is bottleneck", singleResult.agent, "reviewer"); +// ── Run tracking functions under test ── +function runStageFromEvent(evt) { + if (!evt || !evt.data) return null; + var agent = evt.agent || (evt.data && evt.data.agent); + if (agent) return stageFor(agent); + return null; +} + +function processWorkflowEvent(evt) { + var id = evt.correlation_id; + if (!id) return; + if (evt.event === "workflow.started") { + testRuns[id] = { + id: id, status: "running", stage: null, stepIndex: 0, + totalSteps: (evt.data && evt.data.total_steps) || 0, + workflowName: (evt.data && evt.data.workflow_name) || "", + startedAt: evt.timestamp + }; + return; + } + var run = testRuns[id]; + if (!run) return; + if (evt.event === "workflow.step.started") { + run.stage = runStageFromEvent(evt); + run.stepIndex = (evt.data && evt.data.step_index) || run.stepIndex; + run.totalSteps = (evt.data && evt.data.total_steps) || run.totalSteps; + run.status = "running"; + } else if (evt.event === "workflow.step.failed") { + run.stage = runStageFromEvent(evt); + run.status = "failed"; + } else if (evt.event === "workflow.completed") { + run.status = "completed"; + } else if (evt.event === "workflow.failed") { + run.status = "failed"; + } +} + +function runsAtStage(stage) { + var result = []; + var ids = Object.keys(testRuns); + for (var i = 0; i < ids.length; i++) { + var run = testRuns[ids[i]]; + if (run.stage === stage) result.push(run); + } + return result; +} + +var testRuns = {}; + +suite("runStageFromEvent"); +assert("null event returns null", runStageFromEvent(null), null); +assert("event without data returns null", runStageFromEvent({ event: "test" }), null); +assert("event with agent maps to stage", runStageFromEvent({ agent: "planner", data: {} }), "planner"); +assert("event with data.agent maps to stage", runStageFromEvent({ data: { agent: "implementer" } }), "implementer"); +assertNull("unknown agent returns null", runStageFromEvent({ agent: "conductor", data: {} })); + +suite("processWorkflowEvent — workflow lifecycle"); +testRuns = {}; +processWorkflowEvent({ + event: "workflow.started", correlation_id: "run-1", + data: { workflow_name: "sdlc", total_steps: 5 }, + timestamp: new Date().toISOString() +}); +assertTruthy("workflow.started creates run", testRuns["run-1"]); +assert("run status is running", testRuns["run-1"].status, "running"); +assert("run totalSteps is 5", testRuns["run-1"].totalSteps, 5); +assert("run workflowName is sdlc", testRuns["run-1"].workflowName, "sdlc"); + +processWorkflowEvent({ + event: "workflow.step.started", correlation_id: "run-1", + agent: "planner", data: { step_index: 1, total_steps: 5 } +}); +assert("step.started sets stage to planner", testRuns["run-1"].stage, "planner"); +assert("step.started sets stepIndex", testRuns["run-1"].stepIndex, 1); + +processWorkflowEvent({ + event: "workflow.step.completed", correlation_id: "run-1", + agent: "planner", data: { step_index: 1 } +}); +assert("step.completed keeps status running", testRuns["run-1"].status, "running"); + +processWorkflowEvent({ + event: "workflow.step.started", correlation_id: "run-1", + agent: "implementer", data: { step_index: 2, total_steps: 5 } +}); +assert("second step moves stage to implementer", testRuns["run-1"].stage, "implementer"); + +processWorkflowEvent({ + event: "workflow.completed", correlation_id: "run-1", + data: { success: true } +}); +assert("workflow.completed sets status completed", testRuns["run-1"].status, "completed"); + +suite("processWorkflowEvent — failure"); +testRuns = {}; +processWorkflowEvent({ + event: "workflow.started", correlation_id: "run-2", + data: { workflow_name: "test-wf", total_steps: 3 }, + timestamp: new Date().toISOString() +}); +processWorkflowEvent({ + event: "workflow.step.started", correlation_id: "run-2", + agent: "tester", data: { step_index: 1, total_steps: 3 } +}); +processWorkflowEvent({ + event: "workflow.step.failed", correlation_id: "run-2", + agent: "tester", data: { step_index: 1, message: "timed out" } +}); +assert("step.failed sets status to failed", testRuns["run-2"].status, "failed"); +assert("step.failed keeps stage at tester", testRuns["run-2"].stage, "tester"); + +suite("processWorkflowEvent — ignores unknown runs"); +testRuns = {}; +processWorkflowEvent({ + event: "workflow.step.started", correlation_id: "unknown-run", + agent: "planner", data: { step_index: 1 } +}); +assertNull("no run created for unknown correlation_id", testRuns["unknown-run"]); + +suite("runsAtStage"); +testRuns = {}; +processWorkflowEvent({ + event: "workflow.started", correlation_id: "r1", + data: { workflow_name: "a", total_steps: 2 }, + timestamp: new Date().toISOString() +}); +processWorkflowEvent({ + event: "workflow.step.started", correlation_id: "r1", + agent: "planner", data: { step_index: 1 } +}); +processWorkflowEvent({ + event: "workflow.started", correlation_id: "r2", + data: { workflow_name: "b", total_steps: 2 }, + timestamp: new Date().toISOString() +}); +processWorkflowEvent({ + event: "workflow.step.started", correlation_id: "r2", + agent: "planner", data: { step_index: 1 } +}); +assert("two runs at planner stage", runsAtStage("planner").length, 2); +assert("zero runs at implementer stage", runsAtStage("implementer").length, 0); +processWorkflowEvent({ + event: "workflow.step.started", correlation_id: "r1", + agent: "implementer", data: { step_index: 2 } +}); +assert("one run moved to implementer", runsAtStage("implementer").length, 1); +assert("one run still at planner", runsAtStage("planner").length, 1); + +suite("processWorkflowEvent — no correlation_id"); +testRuns = {}; +var prevKeys = Object.keys(testRuns).length; +processWorkflowEvent({ event: "workflow.started", data: {} }); +assert("no run created without correlation_id", Object.keys(testRuns).length, prevKeys); + // ── Summary ── var $summary = document.getElementById("summary"); $summary.className = failed > 0 ? "fail" : "pass"; diff --git a/src/automation/mod.rs b/src/automation/mod.rs index 4896b30..4d55786 100644 --- a/src/automation/mod.rs +++ b/src/automation/mod.rs @@ -560,6 +560,17 @@ fn step_type_name(step: &ResolvedStep) -> &'static str { } } +fn step_agent_name(step: &ResolvedStep) -> Option<&str> { + match step { + ResolvedStep::Prompt { agent, .. } => Some(agent), + ResolvedStep::Command { agent, .. } => agent.as_deref(), + ResolvedStep::EnsureRunning { agent, .. } => Some(agent), + ResolvedStep::Workflow { .. } => None, + ResolvedStep::Land { agent, .. } => Some(agent), + ResolvedStep::Review { agent, .. } => Some(agent), + } +} + fn sanitize_step_key(input: &str) -> String { input .chars() @@ -775,6 +786,22 @@ impl<'a> WorkflowExecutor<'a> { None }; attempted_steps.insert(step_index); + let _ = append_control_event( + self.project_root, + &ControlEvent { + event: "workflow.step.started".to_string(), + workspace: self.config.workspace.name.clone(), + agent: step_agent_name(step).map(|s| s.to_string()), + timestamp: Utc::now(), + correlation_id: run_id.clone(), + data: Some(json!({ + "workflow_name": workflow.name, + "step_index": step_index, + "step_type": step_type_name(step), + "total_steps": workflow.steps.len() + })), + }, + ); if let Err(err) = record_step_intent(self.project_root, &run_id, &workflow.name, step_index, step) { @@ -2056,6 +2083,41 @@ impl<'a> WorkflowExecutor<'a> { } } + // Emit step completion events for all steps attempted in this run + for sr in &step_results { + if !attempted_steps.contains(&sr.index) { + continue; + } + let step_agent = workflow + .steps + .get(sr.index.saturating_sub(1)) + .and_then(|s| step_agent_name(s).map(|a| a.to_string())); + let event_name = if sr.status == StepStatus::Success { + "workflow.step.completed" + } else { + "workflow.step.failed" + }; + let _ = append_control_event( + self.project_root, + &ControlEvent { + event: event_name.to_string(), + workspace: self.config.workspace.name.clone(), + agent: step_agent, + timestamp: Utc::now(), + correlation_id: run_id.clone(), + data: Some(json!({ + "workflow_name": workflow.name, + "step_index": sr.index, + "step_type": sr.step_type, + "total_steps": workflow.steps.len(), + "duration_ms": sr.duration_ms, + "timed_out": sr.timed_out, + "message": sr.message + })), + }, + ); + } + let result = ExecutionResult { run_id, workflow_name: workflow.name.clone(), diff --git a/src/state/mod.rs b/src/state/mod.rs index d0e6824..bd11a68 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -902,6 +902,12 @@ pub fn load_run_steps(project_root: &Path, run_id: &str) -> Result Result<()> { let state_dir = project_root.join(".tutti").join("state"); std::fs::create_dir_all(&state_dir)?; @@ -913,6 +919,29 @@ pub fn append_control_event(project_root: &Path, event: &ControlEvent) -> Result let line = serde_json::to_string(event)?; use std::io::Write; writeln!(file, "{line}")?; + drop(file); + + // Rotate if the file has grown too large (best-effort, non-blocking) + let _ = maybe_rotate_events(&state_dir); + Ok(()) +} + +fn maybe_rotate_events(state_dir: &Path) -> Result<()> { + let path = state_dir.join("events.jsonl"); + let body = std::fs::read_to_string(&path)?; + let lines: Vec<&str> = body.lines().filter(|l| !l.trim().is_empty()).collect(); + if lines.len() <= MAX_EVENTS_BEFORE_ROTATE { + return Ok(()); + } + + // Archive the old events + let split = lines.len() - MAX_EVENTS_RETAIN; + let archive_name = format!("events-{}.jsonl", chrono::Utc::now().format("%Y%m%d%H%M%S")); + let archive_path = state_dir.join(archive_name); + std::fs::write(&archive_path, lines[..split].join("\n") + "\n")?; + + // Rewrite the active file with only the recent events + std::fs::write(&path, lines[split..].join("\n") + "\n")?; Ok(()) } @@ -1315,6 +1344,51 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + #[test] + fn control_events_rotation() { + let dir = + std::env::temp_dir().join(format!("tutti-test-events-rotate-{}", std::process::id())); + ensure_tutti_dir(&dir).unwrap(); + + // Write more than MAX_EVENTS_BEFORE_ROTATE events + let count = MAX_EVENTS_BEFORE_ROTATE + 100; + for i in 0..count { + let event = ControlEvent { + event: "agent.working".to_string(), + workspace: "ws".to_string(), + agent: Some("test".to_string()), + timestamp: Utc::now(), + correlation_id: format!("evt-{i}"), + data: None, + }; + append_control_event(&dir, &event).unwrap(); + } + + // After rotation, the active file should be smaller than what we wrote. + // Events appended after the rotation point remain in the active file. + let loaded = load_control_events(&dir).unwrap(); + assert!( + loaded.len() < count, + "rotation should reduce event count from {count}, got {}", + loaded.len() + ); + + // An archive file should exist + let state_dir = dir.join(".tutti").join("state"); + let archives: Vec<_> = std::fs::read_dir(&state_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_name().to_string_lossy().starts_with("events-") + && e.file_name().to_string_lossy().ends_with(".jsonl") + && e.file_name().to_string_lossy() != "events.jsonl" + }) + .collect(); + assert!(!archives.is_empty(), "archive file should exist"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn policy_decisions_append_and_load() { let dir = From 03a0b8ef4a4cc5dc5782ca201de51ceaf9d71e61 Mon Sep 17 00:00:00 2001 From: adam Date: Sat, 21 Mar 2026 16:03:19 +1100 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20dispatch=20panel=20=E2=80=94=20trig?= =?UTF-8?q?ger=20workflow=20runs=20from=20the=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "+ run" button in the dashboard that opens a dispatch form with workflow selector, optional issue number input, and a dispatch button. Calls POST /v1/actions/run to trigger the workflow. Workflows are fetched from GET /v1/workflows on panel open. Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/app.js | 68 ++++++++++++++++++++++++++++++++++++++++++++ dashboard/index.html | 10 +++++++ dashboard/style.css | 67 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+) diff --git a/dashboard/app.js b/dashboard/app.js index 85a5586..9006d7a 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -459,6 +459,74 @@ function connectSSE() { es.onmessage = handler; } +// ── Dispatch panel ── +var $dispatchToggle = document.getElementById("dispatch-toggle"); +var $dispatchForm = document.getElementById("dispatch-form"); +var $dispatchWf = document.getElementById("dispatch-workflow"); +var $dispatchIssue = document.getElementById("dispatch-issue"); +var $dispatchGo = document.getElementById("dispatch-go"); +var $dispatchStatus = document.getElementById("dispatch-status"); + +if ($dispatchToggle) { + $dispatchToggle.addEventListener("click", function() { + $dispatchForm.classList.toggle("open"); + if ($dispatchForm.classList.contains("open") && $dispatchWf.options.length <= 1) { + loadWorkflows(); + } + }); +} + +function loadWorkflows() { + fetch("/v1/workflows").then(function(res) { return res.json(); }).then(function(json) { + var wfs = (json.data && json.data.workflows) || json.data || []; + $dispatchWf.innerHTML = ""; + if (wfs.length === 0) { + $dispatchWf.appendChild(new Option("no workflows", "")); + return; + } + for (var i = 0; i < wfs.length; i++) { + var name = typeof wfs[i] === "string" ? wfs[i] : (wfs[i].name || ""); + if (name) $dispatchWf.appendChild(new Option(name, name)); + } + }).catch(function() { + $dispatchWf.innerHTML = ""; + $dispatchWf.appendChild(new Option("error loading", "")); + }); +} + +if ($dispatchGo) { + $dispatchGo.addEventListener("click", function() { + var wf = $dispatchWf.value; + if (!wf) return; + $dispatchGo.disabled = true; + $dispatchStatus.textContent = "dispatching…"; + $dispatchStatus.className = "dispatch-status"; + + var body = { workflow: wf }; + var issue = ($dispatchIssue.value || "").trim(); + if (issue) body.issue = issue; + + fetch("/v1/actions/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }).then(function(res) { return res.json(); }).then(function(json) { + $dispatchGo.disabled = false; + if (json.status === "ok") { + $dispatchStatus.textContent = "dispatched"; + $dispatchStatus.className = "dispatch-status ok"; + } else { + $dispatchStatus.textContent = (json.error && json.error.message) || "failed"; + $dispatchStatus.className = "dispatch-status err"; + } + }).catch(function(e) { + $dispatchGo.disabled = false; + $dispatchStatus.textContent = "error: " + e.message; + $dispatchStatus.className = "dispatch-status err"; + }); + }); +} + // ── Boot ── fetchHealth().then(function() { connectSSE(); diff --git a/dashboard/index.html b/dashboard/index.html index b138ad0..b17606a 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -36,6 +36,16 @@
    +
    + +
    + + + + +
    +
    +