Files
AgentHub/client/src/components/workflows/LanePipelinePanel.tsx
T
nntrivi2001 876c6de6d2 fix(lanes): count every pipeline run, not one per lane
Lane Pipeline Pass Rates read `lanes.stages`, which is a snapshot of the
pass in flight: re-entering a node overwrites its record, and moving
backwards deletes every record ahead of the new stage. The report treated
one lane row as one run, so shipping ten features through a lane still
counted as one, and no node could ever exceed 1/1. History survived only
via `lane_features`, which is written when a feature is activated — a
path most users never take. The panel was not wrong, it was structurally
incapable of counting.

Add `lane_stage_events`, an append-only log written by every declaration
and every accepted detection, and aggregate from that. A run is one pass
through the template: the log starts a new one when the lane moves
backwards or leaves the template's last node. Existing snapshots are
seeded as one run each on first boot, so upgrading does not read as
"never run". Templates a lane is configured with are still listed at
zero — a pipeline going unused has to be visible as such.

Count inferred reaches too, in their own column and their own colour.
Detection stays out of the pass rate ("inference is never evidence"), but
reporting a stage the hooks demonstrably saw as "never reached" is what
made the panel look broken to anyone who works without running the
pipeline commands.

Also reject a stage naming no node of the lane's pipeline (400 EBADSTAGE,
listing the ones that exist) instead of storing it with a warning nobody
reads. A real lane had accumulated five such keys, one of them `test` on
a ship-feature lane whose CI node is `gates` — which is why that row read
never-reached while the stage had in fact been declared. Aliases still
resolve, so shorthand is unaffected.
2026-09-09 15:32:09 +07:00

224 lines
8.4 KiB
TypeScript

/**
* @file LanePipelinePanel.tsx
* @description How often each node of this dashboard's OWN pipelines actually
* gets passed, aggregated over every recorded run by
* `GET /api/lanes/pipeline-stats`. One row per node: a bar split into passed
* with evidence / passed without / failed / reached but only inferred, plus the
* raw counts, so a stage that everyone claims but nobody proves is visible at a
* glance. A lane picker narrows the same aggregation to one lane.
* Read-only — the live per-lane view and every lane action stay on the
* Workspace page.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import type { PipelineStats } from "../../lib/types";
/** Percentage of `n` out of `total`, 0 when nothing was recorded. */
function pct(n: number, total: number): number {
return total > 0 ? (n / total) * 100 : 0;
}
/**
* Per-node pass statistics for the selected pipeline template.
* @returns The panel element, or an empty-state hint when nothing was recorded.
*/
export function LanePipelinePanel() {
const { t } = useTranslation("workflows");
const [data, setData] = useState<PipelineStats | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [laneId, setLaneId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
setData(await api.lanes.pipelineStats(laneId));
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : t("failedLoad"));
}
}, [t, laneId]);
useEffect(() => {
void refresh();
}, [refresh]);
// Stages move as agents declare them; debounce so a burst costs one fetch.
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
const unsub = eventBus.subscribe(() => {
clearTimeout(timer);
timer = setTimeout(() => void refresh(), 3000);
});
return () => {
unsub();
clearTimeout(timer);
};
}, [refresh]);
if (error) return <p className="text-xs text-status-danger">{error}</p>;
if (!data) return null;
// Follow the list rather than pinning an id: templates can come and go.
const selected = data.pipelines.find((p) => p.id === selectedId) ?? data.pipelines[0] ?? null;
// The lane picker sits above the empty branch, not inside the populated one:
// narrowing to a lane with no stage records yet must still leave a way back.
const lanePicker = data.lanes.length > 1 && (
<select
data-testid="pipeline-stats-lane-picker"
aria-label={t("lanes.laneLabel")}
className="max-w-[14rem] truncate rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary"
value={laneId ?? ""}
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
>
<option value="">{t("lanes.allLanes")}</option>
{data.lanes.map((l) => (
<option key={l.id} value={l.id}>
{l.title || l.cwd}
</option>
))}
</select>
);
if (!selected) {
return (
<div className="space-y-3">
{lanePicker}
<p className="text-sm text-fg-muted">
{laneId ? (
t("lanes.emptyLane")
) : (
<>
{t("lanes.empty")} <code>agenthub lanes add --cwd $(pwd)</code>
</>
)}
</p>
</div>
);
}
// Detected-only reaches share the scale: a node the hooks saw fifty times and
// nobody ever declared should read as busy, not as empty.
const maxEntered = Math.max(...selected.nodes.map((n) => n.entered + n.detected), 1);
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
{lanePicker}
{data.pipelines.length > 1 && (
<select
data-testid="pipeline-stats-picker"
aria-label={t("lanes.pipelineLabel")}
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary"
value={selected.id}
onChange={(e) => setSelectedId(e.target.value)}
>
{data.pipelines.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
)}
<span className="text-xs text-fg-muted">{t("lanes.runs", { count: selected.runs })}</span>
<div className="ml-auto flex items-center gap-3 text-[10px] text-fg-muted">
<LegendSwatch className="bg-status-success" label={t("lanes.legendEvidence")} />
<LegendSwatch className="bg-status-warning" label={t("lanes.legendClaimed")} />
<LegendSwatch className="bg-status-danger" label={t("lanes.legendFailed")} />
<LegendSwatch className="bg-fg-muted/60" label={t("lanes.legendDetected")} />
</div>
</div>
<div className="space-y-1.5">
{selected.nodes.map((n) => {
const rate = pct(n.passed, n.entered);
return (
<div
key={n.id}
data-testid={`pipeline-stat-${n.id}`}
className="flex items-center gap-2 text-xs"
>
{/* Label over the command that drives this node — the pass rate
to the right answers "how often", this answers "by what". */}
<span className="w-44 shrink-0 overflow-hidden" title={n.label}>
<span className="block truncate text-fg-secondary">
<span className="mr-1">{n.icon}</span>
{n.label}
</span>
{n.command && (
<button
type="button"
data-testid={`pipeline-stat-command-${n.id}`}
title={t("lanes.copyCommand", { command: n.command })}
onClick={() => void navigator.clipboard?.writeText(n.command || "")}
className="block max-w-full truncate font-mono text-[10px] text-fg-muted hover:text-accent"
>
{n.command}
</button>
)}
</span>
{/* Bars are scaled against the busiest node, not against each
node's own total, so a node reached twice never looks as
travelled as one reached fifty times. */}
<div className="flex h-2.5 flex-1 overflow-hidden rounded-full bg-surface-3">
<div
className="h-full bg-status-success"
style={{ width: `${pct(n.withEvidence, maxEntered)}%` }}
title={t("lanes.legendEvidence")}
/>
<div
className="h-full bg-status-warning"
style={{ width: `${pct(n.passed - n.withEvidence, maxEntered)}%` }}
title={t("lanes.legendClaimed")}
/>
<div
className="h-full bg-status-danger"
style={{ width: `${pct(n.failed, maxEntered)}%` }}
title={t("lanes.legendFailed")}
/>
<div
className="h-full bg-fg-muted/60"
style={{ width: `${pct(n.detected, maxEntered)}%` }}
title={t("lanes.legendDetected")}
/>
</div>
<span className="w-28 shrink-0 text-right tabular-nums text-fg-muted">
{n.entered === 0 ? (
// Reached but never declared still beats "never reached" — it
// is the difference between "you skip this stage" and "you do
// it without saying so".
n.detected > 0 ? (
t("lanes.detectedOnly", { count: n.detected })
) : (
t("lanes.never")
)
) : (
<>
<span className="text-fg-secondary">{n.passed}</span>/{n.entered}
<span className="ml-1">({Math.round(rate)}%)</span>
</>
)}
</span>
</div>
);
})}
</div>
</div>
);
}
function LegendSwatch({ className, label }: { className: string; label: string }) {
return (
<span className="flex items-center gap-1">
<span className={`h-2 w-2 rounded-sm ${className}`} />
{label}
</span>
);
}