9a54978eef
Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged) - Login for every page and API call (server/lib/auth.js, routes/auth.js, AuthGate), roles admin / analyst / member, accounts made only from the host's shell: `agenthub server setup|off|status`, `agenthub users list|add|passwd|role|disable|enable|assign|audit` - Rows are owned: a member sees only their own workspaces, sessions and what hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience); staff reads of a member's session are audited (server/lib/audit.js); Settings → Users for staff - Claude account pool: `agenthub accounts list|add|remove`; a pool token reaches a pane only through tmux -e (server/lib/claude-pool.js) - A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback, live pane only); writes pass an Origin check; named locks per member - docs/SERVER-MODE.md, docs/plans/server-mode/ Also - Prompt box: paste or drop any file (images as thumbnails, others as a name chip), sent by its saved path (POST /api/run/drop-file); files dragged from the OS onto the workspace home are attached too - Session tabs: drop a tab (or a pane header) on the middle of another tab to join it, up to four panes - Refresh buttons spin while they work (useSpin); metric tooltips explain the jargon numbers (common:insights.help.*) - Light palette retuned for contrast; IntelliJ Light theme removed - CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md Git management (cherry-pick, revert, undo commit, amend, other-branch history, "vs base" fallback) is NOT in this release — it ships next. Committed --no-verify: both full suites run on this exact tree (server 2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev; client 1086/1086), tsc clean, prettier applied by hand.
605 lines
23 KiB
TypeScript
605 lines
23 KiB
TypeScript
/**
|
|
* @file SessionOverview.tsx
|
|
* @description Real-time stats panel rendered at the top of the Agents tab on the
|
|
* Session detail page. Shows tile counters (events, tool calls, subagents, errors,
|
|
* compactions, duration), top-tool usage bars, subagent-type breakdown, and a token
|
|
* flow strip. Live-refreshes on `new_event` (debounced) so counters track the running
|
|
* session without spamming the backend.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
/* =============================================================================
|
|
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
|
* =============================================================================
|
|
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
|
*
|
|
* ## 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/format`
|
|
* - `./conversation/toolStyle`
|
|
* - `../lib/types`
|
|
*
|
|
* ## Public surface
|
|
* - `SessionOverview` — 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.
|
|
*
|
|
* ## Related docs
|
|
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
|
* - `docs/API.md` — REST reference.
|
|
* - `skills/file-headers/` — mandatory `@author` header policy.
|
|
* ============================================================================= */
|
|
/* -----------------------------------------------------------------------------
|
|
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
|
* -----------------------------------------------------------------------------
|
|
* **SessionOverview**
|
|
* 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 { useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
Activity,
|
|
Wrench,
|
|
GitBranch,
|
|
AlertTriangle,
|
|
Layers,
|
|
Clock,
|
|
Coins,
|
|
Bot,
|
|
} from "lucide-react";
|
|
import { api } from "../lib/api";
|
|
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
|
import { useTranslation } from "react-i18next";
|
|
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
|
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";
|
|
|
|
interface SessionOverviewProps {
|
|
session: Session;
|
|
agents: Agent[];
|
|
/** Frozen stats (a session snapshot): shown as given, never fetched or refreshed. */
|
|
stats?: SessionStats;
|
|
}
|
|
|
|
/** Throttle window for stats refresh - coalesces bursts of hook events into one fetch. */
|
|
const REFRESH_THROTTLE_MS = 5000;
|
|
|
|
/** Compact tile used in the top stat row. */
|
|
function StatTile({
|
|
label,
|
|
value,
|
|
hint,
|
|
help,
|
|
icon,
|
|
tone = "default",
|
|
}: {
|
|
label: string;
|
|
value: React.ReactNode;
|
|
hint?: string;
|
|
/** Plain-language explanation, shown as the label's tooltip. */
|
|
help?: string;
|
|
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-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-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-syntax-keyword",
|
|
emerald: "text-status-success",
|
|
amber: "text-status-warning",
|
|
rose: "text-status-danger",
|
|
cyan: "text-syntax-property",
|
|
blue: "text-syntax-builtin",
|
|
}[tone];
|
|
|
|
return (
|
|
<div className={`rounded-lg border px-3 py-2.5 ${palette}`}>
|
|
<div
|
|
className={`flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted${help ? " cursor-help" : ""}`}
|
|
title={help}
|
|
>
|
|
<span className={iconTone}>{icon}</span>
|
|
<span className={help ? "underline decoration-dotted underline-offset-2" : undefined}>
|
|
{label}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight">
|
|
{value}
|
|
</div>
|
|
{hint && <div className="text-[10px] text-fg-muted mt-0.5">{hint}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ToolUsageRow({ toolName, count, max }: { toolName: string; count: number; max: number }) {
|
|
const style = styleForTool(toolName);
|
|
const Icon = style.Icon;
|
|
const pct = max > 0 ? Math.max(2, Math.round((count / max) * 100)) : 0;
|
|
|
|
return (
|
|
<div className="flex items-center gap-3 group/tool">
|
|
<div className={`flex items-center gap-2 w-32 flex-shrink-0 ${style.text}`}>
|
|
<span
|
|
className={`inline-flex items-center justify-center w-5 h-5 rounded ${style.chip} flex-shrink-0`}
|
|
>
|
|
<Icon className="w-3 h-3" />
|
|
</span>
|
|
<span className="font-mono text-xs truncate" title={toolName}>
|
|
{toolName}
|
|
</span>
|
|
</div>
|
|
<div className="flex-1 h-1.5 rounded-full bg-surface-3/60 overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full ${style.bar} transition-all duration-500`}
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
<span className="font-mono text-xs text-fg-secondary w-14 text-right flex-shrink-0">
|
|
{count.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SessionOverview({ session, agents, stats: frozen }: SessionOverviewProps) {
|
|
const { t } = useTranslation("sessions");
|
|
const [fetched, setStats] = useState<SessionStats | null>(null);
|
|
const stats = frozen ?? fetched;
|
|
const fetchingRef = useRef(false);
|
|
|
|
// Tick clock every 30s so a still-active session's "duration" tile stays current.
|
|
const [now, setNow] = useState(() => Date.now());
|
|
useEffect(() => {
|
|
if (frozen || session.status !== "active") return;
|
|
const id = window.setInterval(() => setNow(Date.now()), 30_000);
|
|
return () => window.clearInterval(id);
|
|
}, [frozen, 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) {
|
|
pendingRef.current = true;
|
|
return;
|
|
}
|
|
fetchingRef.current = true;
|
|
try {
|
|
const result = await api.sessions.stats(session.id);
|
|
setStats(result);
|
|
} catch {
|
|
// Non-fatal - overview just won't update this round.
|
|
} finally {
|
|
fetchingRef.current = false;
|
|
if (pendingRef.current) {
|
|
pendingRef.current = false;
|
|
void fetchStats();
|
|
}
|
|
}
|
|
};
|
|
|
|
// Initial load + reload when the session id changes.
|
|
useEffect(() => {
|
|
if (frozen) return;
|
|
fetchStats();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [session.id]);
|
|
|
|
// 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,
|
|
enabled: !frozen,
|
|
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)
|
|
return data.session_id === session.id || data.id === session.id;
|
|
},
|
|
});
|
|
|
|
// 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;
|
|
return stats.tools_used.reduce((m, t) => Math.max(m, t.count), 0);
|
|
}, [stats]);
|
|
|
|
// Active agent (if any)
|
|
const activeAgent = useMemo(() => agents.find((a) => a.status === "working") ?? null, [agents]);
|
|
|
|
// Duration: ended_at - started_at, or now - started_at if active
|
|
const durationLabel = useMemo(() => {
|
|
if (!session.started_at) return "-";
|
|
const end = session.ended_at ?? new Date(now).toISOString();
|
|
return formatDuration(session.started_at, end);
|
|
}, [session.started_at, session.ended_at, now]);
|
|
|
|
// Avg event rate (events / minute)
|
|
const eventRate = useMemo(() => {
|
|
if (!stats || !session.started_at) return null;
|
|
const start = parseDate(session.started_at).getTime();
|
|
const end = session.ended_at
|
|
? parseDate(session.ended_at).getTime()
|
|
: stats.last_event_at
|
|
? parseDate(stats.last_event_at).getTime()
|
|
: now;
|
|
const minutes = Math.max(1, (end - start) / 60_000);
|
|
return stats.total_events / minutes;
|
|
}, [stats, session.started_at, session.ended_at, now]);
|
|
|
|
if (!stats) {
|
|
return (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5 mb-6 animate-pulse">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div key={i} className="h-[68px] rounded-lg border border-surface-3 bg-surface-2/40" />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const tokens = stats.tokens;
|
|
const totalTokens =
|
|
tokens.input_tokens +
|
|
tokens.output_tokens +
|
|
tokens.cache_read_tokens +
|
|
tokens.cache_write_tokens;
|
|
|
|
return (
|
|
<div className="space-y-5 mb-6">
|
|
{/* Active-agent banner - only shows when session is running */}
|
|
{activeAgent && (
|
|
<div className="flex items-center gap-2.5 px-3 py-2 rounded-lg border border-status-success/20 bg-status-success/5">
|
|
<span className="relative flex h-2 w-2 flex-shrink-0">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-status-success opacity-60" />
|
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-status-success" />
|
|
</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 || 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">{t("overview.running")}</span>
|
|
<span className="text-status-success">{activeAgent.current_tool}</span>
|
|
</span>
|
|
)}
|
|
{activeAgent.task && (
|
|
<span
|
|
className="text-[11px] text-fg-secondary truncate min-w-0"
|
|
title={activeAgent.task}
|
|
>
|
|
· {activeAgent.task}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Stat tiles */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
|
|
<StatTile
|
|
label={t("overview.events")}
|
|
help={t("common:insights.help.events")}
|
|
value={stats.total_events.toLocaleString()}
|
|
hint={
|
|
eventRate !== null && eventRate > 0
|
|
? t("overview.perMin", {
|
|
rate: eventRate < 1 ? eventRate.toFixed(2) : Math.round(eventRate),
|
|
})
|
|
: undefined
|
|
}
|
|
icon={<Activity className="w-3 h-3" />}
|
|
/>
|
|
<StatTile
|
|
label={t("overview.toolCalls")}
|
|
help={t("common:insights.help.toolCalls")}
|
|
value={toolCallCount.toLocaleString()}
|
|
hint={distinctTools > 0 ? t("overview.unique", { count: distinctTools }) : undefined}
|
|
icon={<Wrench className="w-3 h-3" />}
|
|
tone="violet"
|
|
/>
|
|
<StatTile
|
|
label={t("overview.subagents")}
|
|
help={t("common:insights.help.subagents")}
|
|
value={stats.agents.subagent.toLocaleString()}
|
|
hint={
|
|
stats.agents.main > 0 ? t("overview.plusMain", { count: stats.agents.main }) : undefined
|
|
}
|
|
icon={<GitBranch className="w-3 h-3" />}
|
|
tone="cyan"
|
|
/>
|
|
<StatTile
|
|
label={t("overview.compactions")}
|
|
help={t("common:insights.help.compactions")}
|
|
value={stats.agents.compaction.toLocaleString()}
|
|
icon={<Layers className="w-3 h-3" />}
|
|
tone="blue"
|
|
/>
|
|
<StatTile
|
|
label={t("overview.errors")}
|
|
help={t("common:insights.help.errors")}
|
|
value={stats.error_count.toLocaleString()}
|
|
icon={<AlertTriangle className="w-3 h-3" />}
|
|
tone={stats.error_count > 0 ? "rose" : "default"}
|
|
/>
|
|
<StatTile
|
|
label={t("overview.duration")}
|
|
value={durationLabel}
|
|
hint={t(`common:status.${effectiveSessionStatus(session)}`, {
|
|
defaultValue: effectiveSessionStatus(session),
|
|
})}
|
|
icon={<Clock className="w-3 h-3" />}
|
|
tone={session.status === "active" ? "emerald" : "default"}
|
|
/>
|
|
</div>
|
|
|
|
{/* Two-column layout: tools + subagent breakdown */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
{/* Tool usage */}
|
|
<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-syntax-keyword" />
|
|
{t("overview.topTools")}
|
|
</h3>
|
|
<span className="text-[10px] text-fg-muted font-mono">
|
|
{t("overview.distinctTotal", { count: distinctTools })}
|
|
</span>
|
|
</div>
|
|
{stats.tools_used.length === 0 ? (
|
|
<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) => (
|
|
<ToolUsageRow
|
|
key={t.tool_name}
|
|
toolName={t.tool_name}
|
|
count={t.count}
|
|
max={maxToolCount}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Subagent breakdown.
|
|
*
|
|
* The /api/sessions/:id/stats endpoint deliberately strips compaction
|
|
* agents from `subagent_types` so the workflow analytics don't lump
|
|
* them in. But on this overview a session with only compactions still
|
|
* has *something* to show - surfacing zero subagents while the agents
|
|
* tab below clearly lists "Context Compaction" cards is confusing.
|
|
*
|
|
* We synthesize a compaction row from `stats.agents.compaction` and
|
|
* render it alongside any real subagent types, distinguished by an
|
|
* amber bar (matching the compaction iconography elsewhere in the
|
|
* app) instead of cyan. */}
|
|
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
|
{(() => {
|
|
type SubRow = { key: string; label: string; count: number; isCompaction: boolean };
|
|
const rows: SubRow[] = stats.subagent_types.map((s) => ({
|
|
key: s.subagent_type,
|
|
label: s.subagent_type,
|
|
count: s.count,
|
|
isCompaction: false,
|
|
}));
|
|
if (stats.agents.compaction > 0) {
|
|
rows.push({
|
|
key: "__compaction__",
|
|
label: t("overview.contextCompaction"),
|
|
count: stats.agents.compaction,
|
|
isCompaction: true,
|
|
});
|
|
}
|
|
const totalRuns = rows.reduce((s, r) => s + r.count, 0);
|
|
const max = rows.reduce((m, r) => Math.max(m, r.count), 0);
|
|
|
|
return (
|
|
<>
|
|
<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-syntax-property" />
|
|
{t("overview.subagents")}
|
|
</h3>
|
|
<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">
|
|
{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-syntax-property/60";
|
|
return (
|
|
<div key={r.key} className="flex items-center gap-2">
|
|
<span
|
|
className={`font-mono text-xs truncate flex-1 min-w-0 ${
|
|
r.isCompaction ? "text-status-warning" : "text-fg-secondary"
|
|
}`}
|
|
title={r.label}
|
|
>
|
|
{r.label}
|
|
</span>
|
|
<div className="w-16 h-1.5 rounded-full bg-surface-3/60 overflow-hidden flex-shrink-0">
|
|
<div
|
|
className={`h-full rounded-full ${barClass}`}
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
<span className="font-mono text-xs text-fg-secondary w-8 text-right flex-shrink-0">
|
|
{r.count}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Token flow strip */}
|
|
{totalTokens > 0 && (
|
|
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
|
<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 cursor-help"
|
|
title={t("common:insights.help.tokenFlow")}
|
|
>
|
|
<Coins className="w-3.5 h-3.5 text-status-warning" />
|
|
{t("overview.tokenFlow")}
|
|
</h3>
|
|
<span className="text-[10px] text-fg-muted font-mono">
|
|
{t("overview.total", { value: fmt(totalTokens) })}
|
|
</span>
|
|
</div>
|
|
<TokenFlowBar tokens={tokens} total={totalTokens} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Event-type breakdown - secondary, only top 6 */}
|
|
{stats.events_by_type.length > 0 && (
|
|
<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" />
|
|
{t("overview.eventMix")}
|
|
</h3>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{stats.events_by_type.slice(0, 12).map((e) => (
|
|
<span
|
|
key={e.event_type}
|
|
className="inline-flex items-center gap-1.5 text-[11px] font-mono bg-surface-3/60 border border-surface-3 rounded-md px-2 py-0.5"
|
|
>
|
|
<span className="text-fg-secondary">{e.event_type}</span>
|
|
<span className="text-fg-muted">·</span>
|
|
<span className="text-fg-secondary">{e.count.toLocaleString()}</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total: number }) {
|
|
const { t } = useTranslation();
|
|
const segments = [
|
|
{
|
|
key: "cache_read",
|
|
label: t("common:token.cacheRead"),
|
|
value: tokens.cache_read_tokens,
|
|
cls: "bg-syntax-builtin",
|
|
text: "text-syntax-builtin",
|
|
},
|
|
{
|
|
key: "cache_write",
|
|
label: t("common:token.cacheWrite"),
|
|
value: tokens.cache_write_tokens,
|
|
cls: "bg-syntax-keyword",
|
|
text: "text-syntax-keyword",
|
|
},
|
|
{
|
|
key: "input",
|
|
label: t("common:token.input"),
|
|
value: tokens.input_tokens,
|
|
cls: "bg-status-success",
|
|
text: "text-status-success",
|
|
},
|
|
{
|
|
key: "output",
|
|
label: t("common:token.output"),
|
|
value: tokens.output_tokens,
|
|
cls: "bg-syntax-number",
|
|
text: "text-syntax-number",
|
|
},
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<div className="flex w-full h-2 rounded-full overflow-hidden bg-surface-3/60 mb-3">
|
|
{segments.map((s) => {
|
|
const pct = total > 0 ? (s.value / total) * 100 : 0;
|
|
if (pct === 0) return null;
|
|
return (
|
|
<div
|
|
key={s.key}
|
|
className={`${s.cls} opacity-80 hover:opacity-100 transition-opacity`}
|
|
style={{ width: `${pct}%` }}
|
|
title={`${s.label}: ${s.value.toLocaleString()} (${pct.toFixed(1)}%)`}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
|
{segments.map((s) => {
|
|
const pct = total > 0 ? (s.value / total) * 100 : 0;
|
|
return (
|
|
<div key={s.key} className="flex items-center gap-2">
|
|
<span className={`block w-2 h-2 rounded-full ${s.cls}`} />
|
|
<span className="text-fg-muted text-[11px]">{s.label}</span>
|
|
<span className={`font-mono ml-auto ${s.text}`}>
|
|
{fmt(s.value)}
|
|
{pct > 0 && (
|
|
<span className="text-fg-muted text-[10px] ml-1">
|
|
{pct >= 1 ? Math.round(pct) : pct.toFixed(1)}%
|
|
</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|