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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user