Files
Claude-Code-Monitor/client/src/pages/Workflows.tsx
T
nntrivi2001 7357070fb9 chore: remove unused desktop app, cloud deployment infra, and monitoring stack
Deletes desktop/ (Electron wrapper), deployments/ (Helm/Kustomize/
Terraform/CI for cloud deploy), and monitoring/ (Prometheus + Grafana
stack) along with DESKTOP.md, DEPLOYMENT.md, docker-compose.full.yml,
their npm scripts, and every dangling reference across README,
ARCHITECTURE, INSTALL, SETUP, docs/, and the repeated per-file
MODULE_GUIDE "Observability" boilerplate comment. The GET /api/metrics
endpoint itself is untouched — it's the dashboard's own route, not
part of the removed monitoring stack.
2026-08-11 12:16:54 +07:00

603 lines
22 KiB
TypeScript

/**
* @file Workflows.tsx
* @description Displays comprehensive analytics on agent orchestration patterns, including DAGs of agent spawning, tool usage flows, collaboration networks, and session complexity metrics, with real-time updates and interactive filtering.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
*
* ## Design constraints
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
*
* ## Remote data & SSH
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Internal dependencies
* - `../lib/api`
* - `../lib/eventBus`
* - `../lib/types`
* - `../components/workflows/WorkflowStats`
* - `../components/workflows/OrchestrationDAG`
* - `../components/workflows/ToolExecutionFlow`
* - `../components/workflows/AgentCollaborationNetwork`
* - `../components/workflows/SubagentEffectiveness`
* - `../components/workflows/WorkflowPatterns`
* - `../components/workflows/ModelDelegationFlow`
* - `../components/workflows/ErrorPropagationMap`
* - `../components/workflows/ConcurrencyTimeline`
*
* ## Public surface
* - `Workflows` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
* - Server contract changes require `npm run test:server` and OpenAPI sync.
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `docs/API.md` — REST reference.
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **Workflows**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* ----------------------------------------------------------------------------- */
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
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 type { WorkflowData, WSMessage } from "../lib/types";
import { WorkflowStats } from "../components/workflows/WorkflowStats";
import { OrchestrationDAG } from "../components/workflows/OrchestrationDAG";
import { ToolExecutionFlow } from "../components/workflows/ToolExecutionFlow";
import { AgentCollaborationNetwork } from "../components/workflows/AgentCollaborationNetwork";
import { SubagentEffectiveness } from "../components/workflows/SubagentEffectiveness";
import { WorkflowPatterns } from "../components/workflows/WorkflowPatterns";
import { ModelDelegationFlow } from "../components/workflows/ModelDelegationFlow";
import { ErrorPropagationMap } from "../components/workflows/ErrorPropagationMap";
import { ConcurrencyTimeline } from "../components/workflows/ConcurrencyTimeline";
import { SessionComplexityScatter } from "../components/workflows/SessionComplexityScatter";
import { CompactionImpact } from "../components/workflows/CompactionImpact";
import { SessionDrillIn } from "../components/workflows/SessionDrillIn";
import { WorkflowRunsPanel } from "../components/workflows/WorkflowRunsPanel";
type StatusFilter = "all" | "active" | "completed";
export function Workflows() {
const { t } = useTranslation("workflows");
const [data, setData] = useState<WorkflowData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const fetchData = useCallback(async () => {
try {
setError(null);
const result = await api.workflows.get(statusFilter);
setData(result);
setLastUpdated(new Date());
} catch (err) {
setError(err instanceof Error ? err.message : t("failedLoad"));
} finally {
setLoading(false);
}
}, [statusFilter]);
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]);
const handleRefresh = () => {
setLoading(true);
fetchData();
};
const handleExport = () => {
if (!data) return;
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `workflows-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
};
if (loading && !data) {
return (
<div className="space-y-6">
<PageHeader
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
onExport={handleExport}
lastUpdated={null}
/>
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-6 gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="card h-24 animate-pulse bg-surface-2" />
))}
</div>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="card h-64 animate-pulse bg-surface-2" />
))}
</div>
);
}
if (error && !data) {
return (
<div className="space-y-6">
<PageHeader
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
onExport={handleExport}
lastUpdated={null}
/>
<div className="card flex flex-col items-center justify-center py-16 gap-4">
<AlertCircle className="w-10 h-10 text-status-danger" />
<p className="text-status-danger text-sm">{error}</p>
<button onClick={handleRefresh} className="btn-primary text-sm">
{t("common:retry")}
</button>
</div>
</div>
);
}
if (!data) return null;
return (
<div className="space-y-6">
{/* Page Header */}
<PageHeader
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
onExport={handleExport}
lastUpdated={lastUpdated}
/>
{/* Stats Row */}
<WorkflowStats stats={data.stats} />
{/* Workflow-tool runs (issue #167) - fleets ingested from on-disk journals */}
<div className="card p-4 space-y-3">
<div>
<h2 className="text-sm font-semibold text-fg-secondary flex items-center gap-2">
<Workflow className="w-4 h-4 text-violet-400" />
{t("runs.title")}
</h2>
<p className="text-xs text-fg-muted mt-0.5">{t("runs.subtitle")}</p>
</div>
<WorkflowRunsPanel statusFilter={statusFilter} />
</div>
{/* Section 1: Agent Orchestration DAG */}
<Section
number={1}
title={t("orchestration.title")}
subtitle={t("orchestration.subtitle")}
infoKey="orchestration"
>
<OrchestrationDAG
data={data.orchestration}
onNodeClick={setSelectedNode}
selectedNode={selectedNode}
/>
{selectedNode && (
<div className="mt-3 flex items-center gap-2">
<span className="text-xs text-fg-muted">{t("filteredBy")}</span>
<span className="badge bg-accent/15 text-accent border border-accent/20 text-xs">
{selectedNode}
</span>
<button
onClick={() => setSelectedNode(null)}
className="text-xs text-fg-muted hover:text-fg-secondary underline"
>
{t("clearFilter")}
</button>
</div>
)}
</Section>
{/* Section 2: Tool Execution Flow */}
<Section
number={2}
title={t("toolFlow.title")}
subtitle={t("toolFlow.subtitle")}
infoKey="toolFlow"
>
<ToolExecutionFlow data={data.toolFlow} filterAgentType={selectedNode} />
</Section>
{/* Section 3: Agent Collaboration Network */}
<Section
number={3}
title={t("pipeline.title")}
subtitle={t("pipeline.subtitle")}
infoKey="pipeline"
>
<AgentCollaborationNetwork effectiveness={data.effectiveness} edges={data.cooccurrence} />
</Section>
{/* Section 4 + 5: Two Column */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Section
number={4}
title={t("effectiveness.title")}
subtitle={t("effectiveness.subtitle")}
infoKey="effectiveness"
>
<SubagentEffectiveness data={data.effectiveness} />
</Section>
<Section
number={5}
title={t("patterns.title")}
subtitle={t("patterns.subtitle")}
infoKey="patterns"
>
<WorkflowPatterns data={data.patterns} onPatternClick={() => {}} />
</Section>
</div>
{/* Section 6 + 7: Two Column */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Section
number={6}
title={t("modelDelegation.title")}
subtitle={t("modelDelegation.subtitle")}
infoKey="modelDelegation"
>
<ModelDelegationFlow data={data.modelDelegation} />
</Section>
<Section
number={7}
title={t("errorPropagation.title")}
subtitle={t("errorPropagation.subtitle")}
infoKey="errorPropagation"
>
<ErrorPropagationMap data={data.errorPropagation} />
</Section>
</div>
{/* Section 8: Agent Concurrency Timeline */}
<Section
number={8}
title={t("concurrency.title")}
subtitle={t("concurrency.subtitle")}
infoKey="concurrency"
>
<ConcurrencyTimeline data={data.concurrency} />
</Section>
{/* Section 9 + 10: Two Column */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Section
number={9}
title={t("complexity.title")}
subtitle={t("complexity.subtitle")}
infoKey="complexity"
>
<SessionComplexityScatter data={data.complexity} onSessionClick={setSelectedSessionId} />
</Section>
<Section
number={10}
title={t("compaction.title")}
subtitle={t("compaction.subtitle")}
infoKey="compaction"
>
<CompactionImpact data={data.compaction} />
</Section>
</div>
{/* Section 11: Session Drill-In */}
<Section
number={11}
title={t("drillIn.title")}
subtitle={t("drillIn.subtitle")}
infoKey="drillIn"
>
<SessionDrillIn
sessionId={selectedSessionId}
onClose={() => setSelectedSessionId(null)}
onSelectSession={(id) => setSelectedSessionId(id)}
/>
</Section>
</div>
);
}
// ── Section wrapper ──
function Section({
number,
title,
subtitle,
infoKey,
children,
}: {
number: number;
title: string;
subtitle: string;
/** Key under workflows.chartInfo.* - drives the structured popover content. */
infoKey: string;
children: React.ReactNode;
}) {
return (
// Flex column + a flex-1 card so that, when two Sections share a grid row
// (lg:grid-cols-2), the grid's default row-stretch reaches the card itself —
// otherwise a shorter chart (e.g. the Session Complexity Scatter) leaves its
// card shorter than a taller companion (Compaction Impact) in the same row.
<div className="flex flex-col h-full">
<div className="flex items-center justify-between gap-4 mb-3">
<div className="flex items-center gap-2.5 min-w-0">
<span className="w-5 h-5 rounded-md bg-accent/15 text-accent text-[11px] font-bold flex items-center justify-center flex-shrink-0">
{number}
</span>
<h2 className="text-sm font-semibold text-fg-primary">{title}</h2>
<ChartInfoPopover infoKey={infoKey} title={title} />
</div>
{/* Quick descriptor; the full explanation lives in the ⓘ popover, so we
keep this to a single clamped line (ellipsis + hover title) so a long
translation never wraps and unbalances the header row. */}
<span
className="hidden lg:block flex-shrink-0 max-w-[20rem] xl:max-w-sm truncate text-right text-[11px] text-fg-muted"
title={subtitle}
>
{subtitle}
</span>
</div>
<div className="card p-4 flex-1">{children}</div>
</div>
);
}
/**
* Structured info popover for a Workflows chart section. Hover or focus the
* `i` icon to read three short paragraphs sourced from i18n:
*
* 1. What this shows - what data the chart visualizes
* 2. How to read it - visual encoding (axes, sizes, colors, etc.)
* 3. Why it matters - what insights the user can extract
*
* The popover uses fixed positioning and is clamped to the viewport so it
* never gets clipped by the sidebar or screen edges. Auto-flips above the
* trigger when there's no room below.
*/
function ChartInfoPopover({ infoKey, title }: { infoKey: string; title: string }) {
const { t } = useTranslation("workflows");
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [coords, setCoords] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
const POPOVER_W = 340;
const MARGIN = 12;
useLayoutEffect(() => {
if (!open) return;
const update = () => {
const btn = buttonRef.current;
const pop = popoverRef.current;
if (!btn) return;
const r = btn.getBoundingClientRect();
const popH = pop?.offsetHeight ?? 280;
// Center horizontally over the icon, clamp to viewport.
let left = r.left + r.width / 2 - POPOVER_W / 2;
if (left < MARGIN) left = MARGIN;
if (left + POPOVER_W > window.innerWidth - MARGIN) {
left = window.innerWidth - POPOVER_W - MARGIN;
}
// Default below the icon; flip above if not enough room.
const spaceBelow = window.innerHeight - r.bottom;
const placeAbove = spaceBelow < popH + MARGIN && r.top > popH + MARGIN;
const top = placeAbove ? Math.max(MARGIN, r.top - popH - 8) : r.bottom + 8;
setCoords({ left, top });
};
update();
const raf = requestAnimationFrame(update);
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
return (
<>
<button
ref={buttonRef}
type="button"
aria-label={t("chartInfo.labels.what")}
aria-expanded={open}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
className="flex items-center justify-center rounded-full p-0.5 -m-0.5 text-fg-muted hover:text-fg-secondary transition-colors focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<Info className="w-3.5 h-3.5" />
</button>
{open && (
<div
ref={popoverRef}
role="tooltip"
className="fixed z-50 p-3.5 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
style={{ left: coords.left, top: coords.top, width: POPOVER_W }}
>
<p className="text-xs font-semibold text-fg-primary mb-2.5 pb-2 border-b border-[#2a2a4a]">
{title}
</p>
<p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
{t("chartInfo.labels.what")}
</p>
<p className="text-fg-secondary leading-snug mb-2.5">{t(`chartInfo.${infoKey}.what`)}</p>
<p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
{t("chartInfo.labels.howToRead")}
</p>
<p className="text-fg-secondary leading-snug mb-2.5">
{t(`chartInfo.${infoKey}.howToRead`)}
</p>
<p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
{t("chartInfo.labels.why")}
</p>
<p className="text-fg-secondary leading-snug">{t(`chartInfo.${infoKey}.why`)}</p>
</div>
)}
</>
);
}
// ── Page Header ──
function PageHeader({
statusFilter,
onStatusFilterChange,
onRefresh,
onExport,
lastUpdated,
}: {
statusFilter: StatusFilter;
onStatusFilterChange: (f: StatusFilter) => void;
onRefresh: () => void;
onExport: () => void;
lastUpdated: Date | null;
}) {
const { t } = useTranslation("workflows");
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
const filters: { value: StatusFilter; label: string }[] = [
{ value: "all", label: t("allSessions") },
{ value: "active", label: t("activeOnly") },
{ value: "completed", label: t("completed") },
];
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="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>
<div>
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{t("common:live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{t("common:offline")}
</span>
)}
</div>
<p className="text-xs text-fg-muted">{t("subtitle")}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* Status filter tabs */}
<div className="flex bg-surface-2 rounded-lg p-0.5 border border-border">
{filters.map((f) => (
<button
key={f.value}
onClick={() => onStatusFilterChange(f.value)}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
statusFilter === f.value
? "bg-accent/15 text-accent"
: "text-fg-muted hover:text-fg-secondary"
}`}
>
{f.label}
</button>
))}
</div>
{/* Actions */}
<button
onClick={onRefresh}
className="p-2 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-3 transition-colors"
title={t("refreshData")}
>
<RefreshCw className="w-4 h-4" />
</button>
<button
onClick={onExport}
className="p-2 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-3 transition-colors"
title={t("exportJson")}
>
<Download className="w-4 h-4" />
</button>
{lastUpdated && (
<span className="text-[10px] text-fg-muted ml-1">
{t("common:updated")}
{lastUpdated.toLocaleTimeString()}
</span>
)}
</div>
</div>
);
}