feat(dashboard): lazy routes, Needs-you strip, URL state, exact numbers

- Workspace, SettingsHub, SessionDetail and the Workflow sub-tab are lazy
  (main bundle 2,166 kB -> ~920 kB), wrapped in RouteErrorBoundary: a stale
  chunk after a self-update reloads once (vite:preloadError too), any other
  error shows a Reload panel instead of a blank page.
- Live refresh: lib/throttle.ts + hooks/useLiveRefresh.ts replace trailing
  debounces that froze under a steady event stream; refresh skips hidden
  tabs and refetches after a WebSocket reconnect. Monitor loads only while
  shown, with one batched GET /api/agents?session_ids= instead of N+1; a
  failing endpoint is an inline error, not a blank Dashboard.
- NeedsYouStrip on Monitor and above Analytics. Tabs, filters, page, sort
  and search live in the URL (Dashboard, Sessions, Activity, SessionDetail).
- Sessions: tool / workspace / date-range filters, CSV/JSON export
  (formula-neutralised), focusable row links, Open in workspace (the dead
  /run links are gone), stale responses dropped, error + Retry.
- Fixes: "NaNs" durations, 30/7-day charts skipping idle days, weekday
  labels west of UTC (now averages), cache hit rate denominator, billable
  tokens vs cache reads, exact tool-call counts, "To" date = end of day,
  invalid ?from= no longer throws, recent list no longer shrinks to 15,
  stable message keys, transcript switch races.
- Light-theme-unreadable bg-black code panels and dark-only DAG fills use
  tokens; status configs and chart colours use theme tokens; Health tab
  and other literals translated.
- Server (additive): GET /api/agents?session_ids=, GET /api/sessions
  awaiting=1&from=&to=, stats tool_call_count/distinct_tool_count, event
  rows carry session/agent labels.
This commit is contained in:
2026-10-05 13:22:04 +07:00
parent 8fb703c6db
commit 03f1673fce
63 changed files with 3879 additions and 1662 deletions
+65 -37
View File
@@ -76,6 +76,7 @@ import { useTranslation } from "react-i18next";
import { Workflow, RefreshCw, Download, AlertCircle, Info } from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import type { WorkflowData, WSMessage } from "../lib/types";
import { WorkflowStats } from "../components/workflows/WorkflowStats";
@@ -90,7 +91,7 @@ import { SkillUsagePanel } from "../components/workflows/SkillUsagePanel";
type StatusFilter = "all" | "active" | "completed";
export function Workflows() {
export function Workflows({ embedded = false }: { embedded?: boolean } = {}) {
const { t } = useTranslation("workflows");
const [data, setData] = useState<WorkflowData | null>(null);
const [loading, setLoading] = useState(true);
@@ -100,36 +101,49 @@ export function Workflows() {
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [hasPipelines, setHasPipelines] = useState(false);
const drillInRef = useRef<HTMLDivElement>(null);
// A filter switch can overtake a slow poll; only the newest response lands.
const fetchSeq = useRef(0);
const fetchData = useCallback(async () => {
const seq = ++fetchSeq.current;
try {
setError(null);
const result = await api.workflows.get(statusFilter);
if (seq !== fetchSeq.current) return;
setError(null);
setData(result);
setLastUpdated(new Date());
} catch (err) {
setError(err instanceof Error ? err.message : t("failedLoad"));
if (seq === fetchSeq.current) setError(err instanceof Error ? err.message : t("failedLoad"));
} finally {
setLoading(false);
if (seq === fetchSeq.current) setLoading(false);
}
}, [statusFilter]);
const selectSession = useCallback((id: string | null) => {
setSelectedSessionId(id);
// The drill-in sits far below the scatter; bring it into view.
if (id)
requestAnimationFrame(() =>
drillInRef.current?.scrollIntoView?.({ behavior: "smooth", block: "start" })
);
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
// Auto-refresh on WebSocket events
useEffect(() => {
let debounceTimer: ReturnType<typeof setTimeout>;
const handler = (_msg: WSMessage) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(fetchData, 3000);
};
const unsub = eventBus.subscribe(handler);
return () => {
unsub();
clearTimeout(debounceTimer);
};
}, [fetchData]);
// Auto-refresh on the WebSocket messages that change workflow data, throttled
// so a steady stream refreshes every few seconds instead of never.
useLiveRefresh(fetchData, {
throttleMs: 5000,
match: (msg: WSMessage) =>
msg.type === "session_created" ||
msg.type === "session_updated" ||
msg.type === "agent_created" ||
msg.type === "agent_updated" ||
msg.type === "workflow_upserted",
});
const handleRefresh = () => {
setLoading(true);
@@ -151,6 +165,7 @@ export function Workflows() {
return (
<div className="space-y-6">
<PageHeader
embedded={embedded}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
@@ -173,6 +188,7 @@ export function Workflows() {
return (
<div className="space-y-6">
<PageHeader
embedded={embedded}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
@@ -196,6 +212,7 @@ export function Workflows() {
<div className="space-y-6">
{/* Page Header */}
<PageHeader
embedded={embedded}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
@@ -207,9 +224,13 @@ export function Workflows() {
<WorkflowStats stats={data.stats} />
{/* Section 1: this dashboard's own pipelines */}
<Section number={1} title={t("lanes.title")} subtitle={t("lanes.subtitle")} infoKey="lanes">
<LanePipelinePanel />
</Section>
{/* Pipelines are dormant by default: the section stays mounted (so it can
report a template appearing) but hidden while none is loaded. */}
<div className={hasPipelines ? undefined : "hidden"}>
<Section number={1} title={t("lanes.title")} subtitle={t("lanes.subtitle")} infoKey="lanes">
<LanePipelinePanel onAvailability={setHasPipelines} />
</Section>
</div>
{/* Section 2: what drives those pipelines */}
<Section
@@ -247,7 +268,7 @@ export function Workflows() {
/>
{selectedNode && (
<div className="mt-3 flex items-center gap-2">
<span className="text-xs text-fg-muted">{t("filteredBy")}</span>
<span className="text-xs text-fg-muted">{t("selectedNode")}</span>
<span className="badge bg-accent/15 text-accent border border-accent/20 text-xs">
{selectedNode}
</span>
@@ -255,7 +276,7 @@ export function Workflows() {
onClick={() => setSelectedNode(null)}
className="text-xs text-fg-muted hover:text-fg-secondary underline"
>
{t("clearFilter")}
{t("clearSelection")}
</button>
</div>
)}
@@ -279,7 +300,7 @@ export function Workflows() {
subtitle={t("complexity.subtitle")}
infoKey="complexity"
>
<SessionComplexityScatter data={data.complexity} onSessionClick={setSelectedSessionId} />
<SessionComplexityScatter data={data.complexity} onSessionClick={selectSession} />
</Section>
<Section
@@ -293,18 +314,20 @@ export function Workflows() {
</div>
{/* Section 7: Session Drill-In */}
<Section
number={7}
title={t("drillIn.title")}
subtitle={t("drillIn.subtitle")}
infoKey="drillIn"
>
<SessionDrillIn
sessionId={selectedSessionId}
onClose={() => setSelectedSessionId(null)}
onSelectSession={(id) => setSelectedSessionId(id)}
/>
</Section>
<div ref={drillInRef} className="scroll-mt-4">
<Section
number={7}
title={t("drillIn.title")}
subtitle={t("drillIn.subtitle")}
infoKey="drillIn"
>
<SessionDrillIn
sessionId={selectedSessionId}
onClose={() => setSelectedSessionId(null)}
onSelectSession={(id) => setSelectedSessionId(id)}
/>
</Section>
</div>
</div>
);
}
@@ -467,12 +490,15 @@ function ChartInfoPopover({ infoKey, title }: { infoKey: string; title: string }
// ── Page Header ──
function PageHeader({
embedded,
statusFilter,
onStatusFilterChange,
onRefresh,
onExport,
lastUpdated,
}: {
/** Inside Dashboard → Analytics → Workflow: the tab bar already names it. */
embedded?: boolean;
statusFilter: StatusFilter;
onStatusFilterChange: (f: StatusFilter) => void;
onRefresh: () => void;
@@ -488,8 +514,10 @@ function PageHeader({
];
return (
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div
className={`flex flex-col sm:flex-row items-start sm:items-center gap-4 ${embedded ? "sm:justify-end" : "justify-between"}`}
>
<div className={embedded ? "hidden" : "flex items-center gap-3"}>
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
<Workflow className="w-4.5 h-4.5 text-accent" />
</div>