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:
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user