mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
fix(server): show running stage immediately via checkpoint next_node_id
The stages API used checkpoint.current_node to identify the running stage, but current_node is the last *completed* node — always already in completed_nodes, so the running-stage check was always false. Switch to checkpoint.next_node_id which correctly identifies the currently-executing stage. Also move SSE subscription from run-detail parent layout into the StageSidebar component with since_seq=1 to replay all events and close the race between loader fetch and SSE connection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
95e7165034
commit
130d76729c
3 changed files with 48 additions and 40 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Link, useRevalidator } from "react-router";
|
||||
import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
|
|
@ -29,11 +29,41 @@ interface StageSidebarProps {
|
|||
activeLink?: "settings" | "graph";
|
||||
}
|
||||
|
||||
const STAGE_EVENTS = new Set([
|
||||
"stage.started", "stage.completed", "stage.failed",
|
||||
"run.completed", "run.failed",
|
||||
]);
|
||||
|
||||
export function StageSidebar({ stages, runId, selectedStageId, activeLink }: StageSidebarProps) {
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
// Track when we first observed each running stage (for ticking timer)
|
||||
const runningStartRef = useRef<Map<string, number>>(new Map());
|
||||
const [, setTick] = useState(0);
|
||||
|
||||
// Subscribe to run-specific SSE for live stage updates
|
||||
useEffect(() => {
|
||||
const source = new EventSource(`/api/v1/runs/${runId}/attach?since_seq=1`);
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
source.onmessage = (msg) => {
|
||||
try {
|
||||
const payload = JSON.parse(msg.data);
|
||||
if (STAGE_EVENTS.has(payload.event)) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => revalidator.revalidate(), 300);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed events
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
clearTimeout(debounceTimer);
|
||||
source.close();
|
||||
};
|
||||
}, [runId]);
|
||||
|
||||
// Track start times for running stages
|
||||
useEffect(() => {
|
||||
const running = new Set<string>(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect } from "react";
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||
import { Link, Outlet, useFetcher, useLocation, useRevalidator } from "react-router";
|
||||
import { Link, Outlet, useFetcher, useLocation } from "react-router";
|
||||
import { mapRunSummaryToRunItem, runStatusDisplay, isRunStatus } from "../data/runs";
|
||||
import type { RunSummaryResponse } from "../data/runs";
|
||||
import { apiJson } from "../api";
|
||||
|
|
@ -59,11 +59,6 @@ export function meta({ data }: any) {
|
|||
return [{ title: run ? `${run.title} — Fabro` : "Run — Fabro" }];
|
||||
}
|
||||
|
||||
const RUN_EVENTS = new Set([
|
||||
"stage.started", "stage.completed", "stage.failed",
|
||||
"run.completed", "run.failed", "run.running", "run.paused",
|
||||
]);
|
||||
|
||||
export default function RunDetail({ loaderData, params }: any) {
|
||||
const { run } = loaderData;
|
||||
const { pathname } = useLocation();
|
||||
|
|
@ -71,30 +66,6 @@ export default function RunDetail({ loaderData, params }: any) {
|
|||
const previewFetcher = useFetcher<PreviewUrlResponse>();
|
||||
const demoMode = useDemoMode();
|
||||
const tabs = allTabs.filter((t) => !t.broken && (!t.demoOnly || demoMode));
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
// Subscribe to SSE for live updates (stages + run status)
|
||||
useEffect(() => {
|
||||
const source = new EventSource("/api/v1/attach");
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
source.onmessage = (msg) => {
|
||||
try {
|
||||
const payload = JSON.parse(msg.data);
|
||||
if (payload.run_id === params.id && RUN_EVENTS.has(payload.event)) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => revalidator.revalidate(), 300);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed events
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
clearTimeout(debounceTimer);
|
||||
source.close();
|
||||
};
|
||||
}, [params.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previewFetcher.data?.url) {
|
||||
|
|
|
|||
|
|
@ -2144,15 +2144,22 @@ async fn list_run_stages(
|
|||
});
|
||||
}
|
||||
|
||||
// Add current node as running if the run is still active.
|
||||
if run_is_active && !checkpoint.completed_nodes.contains(&checkpoint.current_node) {
|
||||
stages.push(RunStage {
|
||||
id: checkpoint.current_node.clone(),
|
||||
name: checkpoint.current_node.clone(),
|
||||
status: ApiStageStatus::Running,
|
||||
duration_secs: None,
|
||||
dot_id: Some(checkpoint.current_node.clone()),
|
||||
});
|
||||
// Add next node as running if the run is still active.
|
||||
// The checkpoint's current_node is the last *completed* stage; next_node_id
|
||||
// is the stage that is currently executing.
|
||||
if let Some(next_id) = &checkpoint.next_node_id {
|
||||
if run_is_active
|
||||
&& next_id != "exit"
|
||||
&& !checkpoint.completed_nodes.contains(next_id)
|
||||
{
|
||||
stages.push(RunStage {
|
||||
id: next_id.clone(),
|
||||
name: next_id.clone(),
|
||||
status: ApiStageStatus::Running,
|
||||
duration_secs: None,
|
||||
dot_id: Some(next_id.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(ListResponse::new(stages))).into_response()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue