feat(ui): fold the Workflows page into Dashboard → Analytics → Workflow

The workflow analytics now render under the Analytics tab's Workflow
sub-tab; ?tab= and ?sub= deep-link the Dashboard, /workflows redirects there,
and the sidebar drops its Workflows item.
This commit is contained in:
2026-10-01 22:05:55 +07:00
parent b71024f244
commit 744e9c6cda
14 changed files with 121 additions and 98 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+9 -9
View File
@@ -75,11 +75,10 @@ graph TB
Layout[Layout Component]
subgraph "Pages"
Home[Dashboard<br/>Monitor / Analytics / Health tabs]
Home[Dashboard<br/>Monitor / Analytics (incl. Workflow) / Health tabs]
Sessions[Sessions]
Detail[SessionDetail]
Feed[ActivityFeed]
Workflows[Workflows]
Settings[Settings]
end
@@ -106,7 +105,7 @@ graph TB
end
Router --> Layout
Layout --> Home & Sessions & Detail & Feed & Workflows & Settings
Layout --> Home & Sessions & Detail & Feed & Settings
Home & Detail --> AgentCard & StatCard & StatusBadge
Feed --> EventDetail
API --> REST
@@ -198,14 +197,14 @@ client/
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
│ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
│ │ └── workflows/ # Workflows page panels: own-pipeline stats + D3 analytics (11 files)
│ │ └── workflows/ # Dashboard → Analytics → Workflow panels: own-pipeline stats + D3 analytics (11 files)
│ │
│ ├── pages/ # Route pages
│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs
│ │ ├── Sessions.tsx # Filterable table; shows each session's real name (synced live from the transcript), falls back to the short ID
│ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers)
│ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates
│ │ ├── Workflows.tsx
│ │ ├── Workflows.tsx # Workflow analytics, rendered in Dashboard → Analytics → Workflow
│ │ ├── Settings.tsx
│ │ ├── AiToolConfig.tsx # Settings → <tool>: the generic tab of every installed, enabled catalog tool (path, Update link, sessions/cost/lanes, default model, vendor pricing, catalog-declared config files; Antigravity adds its hooks card)
│ │ └── NotFound.tsx
@@ -252,11 +251,12 @@ graph TB
Sessions[Sessions<br/>filterable table]
Detail[SessionDetail<br/>agent hierarchy + timeline]
Feed[ActivityFeed<br/>streaming event log]
Workflows[Workflows<br/>Lane pipelines + D3 analytics]
Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Router, Pricing · Git: credentials, worktrees<br/>Projects: one page per repo]
App --> Layout
Layout --> Dashboard & Sessions & Detail & Feed & Workflows & Settings
Layout --> Dashboard & Sessions & Detail & Feed & Settings
Dashboard --> Workflows
Dashboard --> StatCard[StatCard × 6]
Dashboard --> AgentCard[AgentCard × N]
@@ -469,7 +469,7 @@ graph TB
Sessions["/sessions (tabs: sessions | events)"]
Detail["/sessions/:id"]
Feed["/activity (redirect)"]
Workflows["/workflows"]
Workflows["/workflows (redirect → /?tab=analytics&sub=workflow)"]
CcConfig["/cc-config (redirect)"]
Lanes["/lanes (redirect)"]
Run["/run (redirect)"]
@@ -500,7 +500,7 @@ function App() {
<Route path="sessions/:id" element={<SessionDetail />} />
{/* legacy path - the activity feed is now a tab on /sessions */}
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
<Route path="workflows" element={<Workflows />} />
<Route path="workflows" element={<Navigate to="/?tab=analytics&sub=workflow" replace />} />
{/* legacy path - Claude config now lives in a Settings tab */}
<Route path="cc-config" element={<Navigate to="/settings/agents/claude-code" replace />} />
{/* legacy paths - lanes and runs are one page now */}
+4 -3
View File
@@ -41,7 +41,6 @@
* - `./pages/Dashboard`
* - `./pages/History`
* - `./pages/SessionDetail`
* - `./pages/Workflows`
* - `./pages/SettingsHub`
* - `./pages/Workspace`
*
@@ -64,7 +63,6 @@ import { GlobalTooltip } from "./components/Tooltip";
import { Dashboard } from "./pages/Dashboard";
import { History } from "./pages/History";
import { SessionDetail } from "./pages/SessionDetail";
import { Workflows } from "./pages/Workflows";
import { SettingsHub } from "./pages/SettingsHub";
import { Workspace } from "./pages/Workspace";
import { NotFound } from "./pages/NotFound";
@@ -99,7 +97,10 @@ export default function App() {
<Route path="sessions" element={<History />} />
<Route path="sessions/:id" element={<SessionDetail />} />
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
<Route path="workflows" element={<Workflows />} />
<Route
path="workflows"
element={<Navigate to="/?tab=analytics&sub=workflow" replace />}
/>
<Route path="lanes" element={<Navigate to="/workspace" replace />} />
<Route
path="cc-config"
-2
View File
@@ -24,8 +24,6 @@ export function DocumentTitle() {
title = `${t("sessions")} · ${sessionId.slice(0, 8)}`;
} else if (path.startsWith("/sessions")) {
title = t("sessions");
} else if (path.startsWith("/workflows")) {
title = t("workflows");
} else if (path.startsWith("/workspace")) {
title = t("workspace");
} else if (path.startsWith("/settings")) {
-2
View File
@@ -55,7 +55,6 @@ import {
LayoutDashboard,
FolderOpen,
BarChart3,
Workflow,
Play,
Settings,
Wifi,
@@ -89,7 +88,6 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
const NAV_KEYS = [
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
{ to: "/workflows", icon: Workflow, key: "nav:workflows" },
{ to: "/workspace", icon: Play, key: "nav:workspace" },
{ to: "/settings", icon: Settings, key: "nav:settings" },
] as const;
@@ -33,7 +33,6 @@ describe("DocumentTitle", () => {
it("titles the other top-level routes", () => {
expect(titleFor("/")).toMatch(/^Dashboard · /);
expect(titleFor("/sessions")).toMatch(/^Sessions · /);
expect(titleFor("/workflows")).toMatch(/^Workflows · /);
expect(titleFor("/settings")).toMatch(/^Settings · /);
});
});
-1
View File
@@ -64,7 +64,6 @@
"upToDate": "Up to date",
"updateAvailable": "Update available",
"website": "Website",
"workflows": "Workflows",
"workspace": "Workspace",
"wsEndpoint": "WebSocket",
"releaseAvailable": "{{version}} available",
-1
View File
@@ -64,7 +64,6 @@
"upToDate": "Đã là mới nhất",
"updateAvailable": "Có bản cập nhật",
"website": "Website",
"workflows": "Quy trình",
"workspace": "Không gian làm việc",
"wsEndpoint": "WebSocket",
"releaseAvailable": "Có {{version}}",
+88 -71
View File
@@ -63,7 +63,8 @@ import {
useRef,
type ReactNode,
} from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Workflows } from "./Workflows";
import { useTranslation } from "react-i18next";
import {
LayoutDashboard,
@@ -732,9 +733,14 @@ function AnalyticsTab() {
const [costData, setCostData] = useState<CostResult | null>(null);
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
const [loading, setLoading] = useState(true);
// `?sub=` deep-links a sub-tab (the old /workflows route lands on `workflow`).
const [params] = useSearchParams();
const [analyticsSubTab, setAnalyticsSubTab] = useState<
"tokens" | "cost" | "workflow" | "productivity"
>("cost");
>(() => {
const sub = params.get("sub");
return sub === "tokens" || sub === "workflow" || sub === "productivity" ? sub : "cost";
});
// Global data scope; a change re-runs `load` (api injects the `sources` param).
const [scope] = useDataScope();
@@ -1340,81 +1346,89 @@ function AnalyticsTab() {
)}
{analyticsSubTab === "workflow" && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Agent type distribution */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">
{t("subagentTypes")}
</h3>
{(data?.agent_types ?? []).length === 0 ? (
<p className="text-sm text-fg-muted">{t("noSubagentData")}</p>
) : (
<div className="space-y-3">
{(data?.agent_types ?? []).slice(0, 10).map(({ subagent_type, count }) => (
<BarRow
key={subagent_type}
label={subagent_type}
count={count}
max={maxAgentTypeCount}
color="bg-violet-400"
/>
))}
</div>
)}
</div>
{/* Agent status donut */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("agentStatus")}</h3>
<DonutChart segments={agentStatusSegments} />
<div className="mt-4 pt-4 border-t border-border space-y-1.5">
<div className="flex justify-between text-xs text-fg-muted">
<span>{t("totalAgentsLabel")}</span>
<Tip raw={(data?.overview.total_agents ?? 0).toLocaleString()}>
<span className="text-fg-secondary font-mono">
{fmt(data?.overview.total_agents ?? 0)}
</span>
</Tip>
</div>
{agentStatusSegments.map((s) => (
<div
key={s.label}
className="flex items-center justify-between text-xs text-fg-muted"
>
<span className="flex items-center gap-1.5">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: s.color }}
<div className="space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Agent type distribution */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">
{t("subagentTypes")}
</h3>
{(data?.agent_types ?? []).length === 0 ? (
<p className="text-sm text-fg-muted">{t("noSubagentData")}</p>
) : (
<div className="space-y-3">
{(data?.agent_types ?? []).slice(0, 10).map(({ subagent_type, count }) => (
<BarRow
key={subagent_type}
label={subagent_type}
count={count}
max={maxAgentTypeCount}
color="bg-violet-400"
/>
{s.label}
</span>
<Tip raw={s.value.toLocaleString()}>
<span className="text-fg-secondary font-mono">{fmt(s.value)}</span>
))}
</div>
)}
</div>
{/* Agent status donut */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">
{t("agentStatus")}
</h3>
<DonutChart segments={agentStatusSegments} />
<div className="mt-4 pt-4 border-t border-border space-y-1.5">
<div className="flex justify-between text-xs text-fg-muted">
<span>{t("totalAgentsLabel")}</span>
<Tip raw={(data?.overview.total_agents ?? 0).toLocaleString()}>
<span className="text-fg-secondary font-mono">
{fmt(data?.overview.total_agents ?? 0)}
</span>
</Tip>
</div>
))}
</div>
</div>
{/* Event type breakdown */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("eventTypes")}</h3>
{(data?.event_types ?? []).length === 0 ? (
<p className="text-sm text-fg-muted">{t("noEventData")}</p>
) : (
<div className="space-y-3">
{(data?.event_types ?? []).map(({ event_type, count }) => (
<BarRow
key={event_type}
label={event_type}
count={count}
max={maxEventTypeCount}
color={EVENT_TYPE_COLORS[event_type] ?? "bg-surface-4"}
/>
{agentStatusSegments.map((s) => (
<div
key={s.label}
className="flex items-center justify-between text-xs text-fg-muted"
>
<span className="flex items-center gap-1.5">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: s.color }}
/>
{s.label}
</span>
<Tip raw={s.value.toLocaleString()}>
<span className="text-fg-secondary font-mono">{fmt(s.value)}</span>
</Tip>
</div>
))}
</div>
)}
</div>
{/* Event type breakdown */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">
{t("eventTypes")}
</h3>
{(data?.event_types ?? []).length === 0 ? (
<p className="text-sm text-fg-muted">{t("noEventData")}</p>
) : (
<div className="space-y-3">
{(data?.event_types ?? []).map(({ event_type, count }) => (
<BarRow
key={event_type}
label={event_type}
count={count}
max={maxEventTypeCount}
color={EVENT_TYPE_COLORS[event_type] ?? "bg-surface-4"}
/>
))}
</div>
)}
</div>
</div>
{/* The former Workflows page, folded into this sub-tab. */}
<Workflows />
</div>
)}
@@ -2346,7 +2360,10 @@ export function Dashboard() {
const { t } = useTranslation("dashboard");
// Persistent Tab State
const [tabParams] = useSearchParams();
const [activeTab, setActiveTab] = useState<"monitor" | "analytics" | "health">(() => {
const tab = tabParams.get("tab");
if (tab === "monitor" || tab === "analytics" || tab === "health") return tab;
return (
(localStorage.getItem("dashboard_tab") as "monitor" | "analytics" | "health") || "analytics"
);
+3 -2
View File
@@ -1,6 +1,7 @@
/**
* @file Workflows.tsx
* @description Workflows page. Opens on this dashboard's OWN pipelines — per-node
* @description The workflow analytics, shown inside Dashboard → Analytics → Workflow (the
* old `/workflows` route redirects there). Opens on this dashboard's OWN pipelines — per-node
* pass rates across every lane, and which skills/plugins actually get used — then
* the Claude Code orchestration analytics: headline stats, Workflow-tool runs, the
* spawning DAG, error propagation, session complexity, compaction impact and the
@@ -494,7 +495,7 @@ function PageHeader({
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
<h2 className="text-lg font-semibold text-fg-primary">{t("title")}</h2>
{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" />
@@ -9390,11 +9390,11 @@ exports[`screen snapshots > Workflows 1`] = `
<div
class="flex items-center gap-2"
>
<h1
<h2
class="text-lg font-semibold text-fg-primary"
>
Workflows
</h1>
</h2>
<span
class="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"
>
@@ -559,6 +559,17 @@ describe("screen snapshots", () => {
it("Dashboard", async () => {
await snapshot(<Dashboard />, "/");
});
it("Dashboard deep link opens the Workflow sub-tab with the workflow analytics", async () => {
localStorage.removeItem("dashboard_tab");
const { container } = render(
<MemoryRouter initialEntries={["/?tab=analytics&sub=workflow"]}>
<Dashboard />
</MemoryRouter>
);
await settle();
const headings = [...container.querySelectorAll("h2")].map((h) => h.textContent);
expect(headings).toContain(i18n.t("workflows:title"));
});
it("Sessions", async () => {
await snapshot(<Sessions />, "/sessions");
});
+1 -1
View File
@@ -2120,7 +2120,7 @@ DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `agenthub lanes shell` can attach the same session via a real local terminal, staying in sync with the browser view.
Every history row carries `lane_id` (the workspace whose run it belongs to) and `tool` (the CLI that was invoked: `"claude"` or `"codex"`). `GET /api/run/history?laneId=<n>` returns only that workspace's runs. Spawned Claude processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the Dashboard's Analytics tab, and the Workflows page automatically. Codex runs do not emit these hooks; they are recorded only in run history and `dashboard_runs`.
Every history row carries `lane_id` (the workspace whose run it belongs to) and `tool` (the CLI that was invoked: `"claude"` or `"codex"`). `GET /api/run/history?laneId=<n>` returns only that workspace's runs. Spawned Claude processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, and the Dashboard's Analytics tab (Workflow sub-tab included) automatically. Codex runs do not emit these hooks; they are recorded only in run history and `dashboard_runs`.
---
+2 -2
View File
@@ -815,7 +815,7 @@ under `agenthub feature activate`: the backwards declaration prunes the records
ahead of it, so the previous pass leaves nothing behind to count, while a
feature switch archives them first. Neither ever counted *passes* — one workspace row
contributes at most one record per node, however many times it ran — so this
narrows the history, it does not skew a rate. The Workflows page renders this as one bar per node (see
narrows the history, it does not skew a rate. Dashboard → Analytics → Workflow renders this as one bar per node (see
`client/src/components/workflows/LanePipelinePanel.tsx`).
**A new run is opened by a DECLARATION, never by a detection alone.** `runs` is
@@ -1042,7 +1042,7 @@ Create a JSON file in your custom pipeline directory (e.g., `~/.agenthub/pipelin
- **label** (string, optional): display name in the UI; defaults to `id` if omitted
- **icon** (string, optional): unicode emoji or single character shown before the stage name
- **gate** (boolean, optional): `true` if this stage blocks unless explicitly passed; defaults to `false`
- **command** (string, optional): the one command that drives THIS stage — a slash command (`"/my-training-skill"`) or a shell one (`"agenthub lanes hook ci-gate"`). Where `skill` above names the driver for the whole pipeline, this narrows it to the step, so a reader of the pipeline map sees what moves each node instead of inferring it from the node's `detect` regexes. Omit it (or leave it empty) for a node with no single driver; use `"agenthub stage <id>"` for a node whose only "work" is the declaration itself (a terminal `done`, a gate another agent declares). Surfaced in the node's tooltip on a workspace card and as copyable text per node on the Workflows page, and run by `agenthub lanes node <id>` (below); **never executed by the dashboard**, same contract as `skill`.
- **command** (string, optional): the one command that drives THIS stage — a slash command (`"/my-training-skill"`) or a shell one (`"agenthub lanes hook ci-gate"`). Where `skill` above names the driver for the whole pipeline, this narrows it to the step, so a reader of the pipeline map sees what moves each node instead of inferring it from the node's `detect` regexes. Omit it (or leave it empty) for a node with no single driver; use `"agenthub stage <id>"` for a node whose only "work" is the declaration itself (a terminal `done`, a gate another agent declares). Surfaced in the node's tooltip on a workspace card and as copyable text per node in Dashboard → Analytics → Workflow, and run by `agenthub lanes node <id>` (below); **never executed by the dashboard**, same contract as `skill`.
- **aliases** (array, optional): alternative names for the stage (e.g., `["training", "fit"]` lets you call `agenthub stage training`). An alias may also name a **sub-state** — *why* the workspace sits on a node — without adding a node to the map. Two constraints: no alias may collide with another node's id or alias (`phaseIdx` takes the FIRST match, so a duplicate silently resolves a declaration onto the wrong node), and all aliases of one node share one record slot (`stageRecords` resolves each declared key onto its node, and the canonical id's record wins), so a sub-state that needs its own `--evidence` has to be a real node.
- **detect** (array, optional): stage-detection rules for this node — see "Stage detection → Where the rules live".