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
+100 -90
View File
@@ -65,9 +65,11 @@ import {
Bot,
} from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useTranslation } from "react-i18next";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { fmt, formatDuration } from "../lib/format";
import { fmt, formatDuration, parseDate } from "../lib/format";
import { effectiveSessionStatus } from "../lib/types";
import { styleForTool } from "./conversation/toolStyle";
import type { Agent, Session, SessionStats } from "../lib/types";
@@ -76,8 +78,8 @@ interface SessionOverviewProps {
agents: Agent[];
}
/** Debounce window for stats refresh - coalesces bursts of hook events into one fetch. */
const REFRESH_DEBOUNCE_MS = 600;
/** Throttle window for stats refresh - coalesces bursts of hook events into one fetch. */
const REFRESH_THROTTLE_MS = 1000;
/** Compact tile used in the top stat row. */
function StatTile({
@@ -93,24 +95,25 @@ function StatTile({
icon: React.ReactNode;
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
}) {
// Theme tokens only: status colours for states, syntax hues for categories.
const palette = {
default: "border-surface-3 bg-surface-2 text-fg-secondary",
violet: "border-violet-500/20 bg-violet-500/5 text-violet-800 dark:text-violet-200",
violet: "border-syntax-keyword/20 bg-syntax-keyword/5 text-syntax-keyword",
emerald: "border-status-success/20 bg-status-success/5 text-status-success",
amber: "border-status-warning/20 bg-status-warning/5 text-status-warning",
rose: "border-rose-500/20 bg-rose-500/5 text-rose-800 dark:text-rose-200",
cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-800 dark:text-cyan-200",
blue: "border-blue-600/20 bg-blue-600/5 text-blue-700 dark:text-blue-300",
rose: "border-status-danger/20 bg-status-danger/5 text-status-danger",
cyan: "border-syntax-property/20 bg-syntax-property/5 text-syntax-property",
blue: "border-syntax-builtin/20 bg-syntax-builtin/5 text-syntax-builtin",
}[tone];
const iconTone = {
default: "text-fg-muted",
violet: "text-violet-700 dark:text-violet-400",
violet: "text-syntax-keyword",
emerald: "text-status-success",
amber: "text-status-warning",
rose: "text-rose-700 dark:text-rose-400",
cyan: "text-cyan-700 dark:text-cyan-400",
blue: "text-blue-500",
rose: "text-status-danger",
cyan: "text-syntax-property",
blue: "text-syntax-builtin",
}[tone];
return (
@@ -158,8 +161,8 @@ function ToolUsageRow({ toolName, count, max }: { toolName: string; count: numbe
}
export function SessionOverview({ session, agents }: SessionOverviewProps) {
const { t } = useTranslation("sessions");
const [stats, setStats] = useState<SessionStats | null>(null);
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchingRef = useRef(false);
// Tick clock every 30s so a still-active session's "duration" tile stays current.
@@ -170,8 +173,14 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
return () => window.clearInterval(id);
}, [session.status]);
// A refresh asked for while one is in flight is not dropped: it runs once
// when the current one settles (that is the throttle's trailing call).
const pendingRef = useRef(false);
const fetchStats = async () => {
if (fetchingRef.current) return;
if (fetchingRef.current) {
pendingRef.current = true;
return;
}
fetchingRef.current = true;
try {
const result = await api.sessions.stats(session.id);
@@ -180,6 +189,10 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
// Non-fatal - overview just won't update this round.
} finally {
fetchingRef.current = false;
if (pendingRef.current) {
pendingRef.current = false;
void fetchStats();
}
}
};
@@ -189,47 +202,29 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.id]);
// Live refresh on websocket events (debounced).
useEffect(() => {
const unsubscribe = eventBus.subscribe((msg) => {
if (isRemoteDataRefreshMessage(msg)) {
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = setTimeout(() => {
refreshTimerRef.current = null;
fetchStats();
}, REFRESH_DEBOUNCE_MS);
return;
}
const isRelevant =
msg.type === "new_event" ||
msg.type === "agent_created" ||
msg.type === "agent_updated" ||
msg.type === "session_updated";
if (!isRelevant) return;
// Live refresh on this session's websocket events, throttled (a steady
// stream still refreshes) and again after a reconnect.
useLiveRefresh(() => void fetchStats(), {
throttleMs: REFRESH_THROTTLE_MS,
match: (msg) => {
if (isRemoteDataRefreshMessage(msg)) return true;
if (
msg.type !== "new_event" &&
msg.type !== "agent_created" &&
msg.type !== "agent_updated" &&
msg.type !== "session_updated"
)
return false;
const data = msg.data as { session_id?: string; id?: string };
// Match either by session_id (events) or by id (session_updated)
const matchesSession = data.session_id === session.id || data.id === session.id;
if (!matchesSession) return;
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = setTimeout(() => {
refreshTimerRef.current = null;
fetchStats();
}, REFRESH_DEBOUNCE_MS);
});
return () => {
unsubscribe();
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.id]);
return data.session_id === session.id || data.id === session.id;
},
});
// Tool calls = sum of all tool counts (PreToolUse+PostToolUse events have a tool_name).
// We approximate "tool calls" as half of that (each call produces a Pre + Post event).
const toolCallCount = useMemo(() => {
if (!stats) return 0;
const total = stats.tools_used.reduce((s, t) => s + t.count, 0);
return Math.round(total / 2);
}, [stats]);
// Exact totals from the server (`tools_used` is only the top 15). An older
// server lacks them: fall back to the visible list's count.
const toolCallCount = stats?.tool_call_count ?? 0;
const distinctTools = stats?.distinct_tool_count ?? stats?.tools_used.length ?? 0;
const maxToolCount = useMemo(() => {
if (!stats) return 0;
@@ -249,11 +244,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
// Avg event rate (events / minute)
const eventRate = useMemo(() => {
if (!stats || !session.started_at) return null;
const start = new Date(session.started_at).getTime();
const start = parseDate(session.started_at).getTime();
const end = session.ended_at
? new Date(session.ended_at).getTime()
? parseDate(session.ended_at).getTime()
: stats.last_event_at
? new Date(stats.last_event_at).getTime()
? parseDate(stats.last_event_at).getTime()
: now;
const minutes = Math.max(1, (end - start) / 60_000);
return stats.total_events / minutes;
@@ -287,11 +282,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
</span>
<Bot className="w-3.5 h-3.5 text-status-success flex-shrink-0" />
<span className="text-xs text-status-success font-medium flex-shrink-0">
{activeAgent.name || "Agent"}
{activeAgent.name || t("overview.agent")}
</span>
{activeAgent.current_tool && (
<span className="text-[11px] text-fg-secondary font-mono inline-flex items-center gap-1">
<span className="text-fg-muted">running</span>
<span className="text-fg-muted">{t("overview.running")}</span>
<span className="text-status-success">{activeAgent.current_tool}</span>
</span>
)}
@@ -309,45 +304,51 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
{/* Stat tiles */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
<StatTile
label="Events"
label={t("overview.events")}
value={stats.total_events.toLocaleString()}
hint={
eventRate !== null && eventRate > 0
? `${eventRate < 1 ? eventRate.toFixed(2) : Math.round(eventRate)}/min`
? t("overview.perMin", {
rate: eventRate < 1 ? eventRate.toFixed(2) : Math.round(eventRate),
})
: undefined
}
icon={<Activity className="w-3 h-3" />}
/>
<StatTile
label="Tool calls"
label={t("overview.toolCalls")}
value={toolCallCount.toLocaleString()}
hint={stats.tools_used.length > 0 ? `${stats.tools_used.length} unique` : undefined}
hint={distinctTools > 0 ? t("overview.unique", { count: distinctTools }) : undefined}
icon={<Wrench className="w-3 h-3" />}
tone="violet"
/>
<StatTile
label="Subagents"
label={t("overview.subagents")}
value={stats.agents.subagent.toLocaleString()}
hint={stats.agents.main > 0 ? `+${stats.agents.main} main` : undefined}
hint={
stats.agents.main > 0 ? t("overview.plusMain", { count: stats.agents.main }) : undefined
}
icon={<GitBranch className="w-3 h-3" />}
tone="cyan"
/>
<StatTile
label="Compactions"
label={t("overview.compactions")}
value={stats.agents.compaction.toLocaleString()}
icon={<Layers className="w-3 h-3" />}
tone="blue"
/>
<StatTile
label="Errors"
label={t("overview.errors")}
value={stats.error_count.toLocaleString()}
icon={<AlertTriangle className="w-3 h-3" />}
tone={stats.error_count > 0 ? "rose" : "default"}
/>
<StatTile
label="Duration"
label={t("overview.duration")}
value={durationLabel}
hint={session.status === "active" ? "running" : "completed"}
hint={t(`common:status.${effectiveSessionStatus(session)}`, {
defaultValue: effectiveSessionStatus(session),
})}
icon={<Clock className="w-3 h-3" />}
tone={session.status === "active" ? "emerald" : "default"}
/>
@@ -359,15 +360,17 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
<div className="lg:col-span-2 rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
<Wrench className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
Top tools
<Wrench className="w-3.5 h-3.5 text-syntax-keyword" />
{t("overview.topTools")}
</h3>
<span className="text-[10px] text-fg-muted font-mono">
{stats.tools_used.length} total
{t("overview.distinctTotal", { count: distinctTools })}
</span>
</div>
{stats.tools_used.length === 0 ? (
<div className="text-center py-6 text-xs text-fg-muted">No tool calls yet.</div>
<div className="text-center py-6 text-xs text-fg-muted">
{t("overview.noToolCalls")}
</div>
) : (
<div className="space-y-1.5">
{stats.tools_used.slice(0, 8).map((t) => (
@@ -406,7 +409,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
if (stats.agents.compaction > 0) {
rows.push({
key: "__compaction__",
label: "Context Compaction",
label: t("overview.contextCompaction"),
count: stats.agents.compaction,
isCompaction: true,
});
@@ -418,20 +421,24 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
<>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
<GitBranch className="w-3.5 h-3.5 text-cyan-700 dark:text-cyan-400" />
Subagents
<GitBranch className="w-3.5 h-3.5 text-syntax-property" />
{t("overview.subagents")}
</h3>
<span className="text-[10px] text-fg-muted font-mono">{totalRuns} runs</span>
<span className="text-[10px] text-fg-muted font-mono">
{t("overview.runs", { count: totalRuns })}
</span>
</div>
{rows.length === 0 ? (
<div className="text-center py-6 text-xs text-fg-muted">
No subagents in this session.
{t("overview.noSubagents")}
</div>
) : (
<div className="space-y-1.5">
{rows.slice(0, 8).map((r) => {
const pct = max > 0 ? Math.max(4, Math.round((r.count / max) * 100)) : 0;
const barClass = r.isCompaction ? "bg-status-warning/60" : "bg-cyan-500/60";
const barClass = r.isCompaction
? "bg-status-warning/60"
: "bg-syntax-property/60";
return (
<div key={r.key} className="flex items-center gap-2">
<span
@@ -468,9 +475,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
<div className="flex items-center justify-between mb-2.5">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
<Coins className="w-3.5 h-3.5 text-status-warning" />
Token flow
{t("overview.tokenFlow")}
</h3>
<span className="text-[10px] text-fg-muted font-mono">{fmt(totalTokens)} total</span>
<span className="text-[10px] text-fg-muted font-mono">
{t("overview.total", { value: fmt(totalTokens) })}
</span>
</div>
<TokenFlowBar tokens={tokens} total={totalTokens} />
</div>
@@ -481,7 +490,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-3 flex items-center gap-1.5">
<Activity className="w-3.5 h-3.5 text-fg-secondary" />
Event mix
{t("overview.eventMix")}
</h3>
<div className="flex flex-wrap gap-1.5">
{stats.events_by_type.slice(0, 12).map((e) => (
@@ -502,34 +511,35 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
}
function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total: number }) {
const { t } = useTranslation();
const segments = [
{
key: "cache_read",
label: "Cache read",
label: t("common:token.cacheRead"),
value: tokens.cache_read_tokens,
cls: "bg-sky-500",
text: "text-sky-700 dark:text-sky-300",
cls: "bg-syntax-builtin",
text: "text-syntax-builtin",
},
{
key: "cache_write",
label: "Cache write",
label: t("common:token.cacheWrite"),
value: tokens.cache_write_tokens,
cls: "bg-violet-500",
text: "text-violet-700 dark:text-violet-300",
cls: "bg-syntax-keyword",
text: "text-syntax-keyword",
},
{
key: "input",
label: "Input",
label: t("common:token.input"),
value: tokens.input_tokens,
cls: "bg-status-success",
text: "text-status-success",
},
{
key: "output",
label: "Output",
label: t("common:token.output"),
value: tokens.output_tokens,
cls: "bg-orange-500",
text: "text-orange-700 dark:text-orange-300",
cls: "bg-syntax-number",
text: "text-syntax-number",
},
];