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
@@ -0,0 +1,25 @@
/**
* @file exportData.test.ts
* @description Unit tests for the CSV export helper: quoting and formula neutralizing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { toCsv } from "../exportData";
describe("toCsv", () => {
it("writes a header row and quotes cells that need it", () => {
const csv = toCsv(
[
{ id: "a", name: 'say "hi", ok', n: 3 },
{ id: "b", name: null, n: 0 },
],
["id", "name", "n"]
);
expect(csv).toBe('id,name,n\r\na,"say ""hi"", ok",3\r\nb,,0\r\n');
});
it("neutralizes spreadsheet formulas", () => {
expect(toCsv([{ v: "=HYPERLINK(1)" }], ["v"])).toBe("v\r\n'=HYPERLINK(1)\r\n");
});
});
+21
View File
@@ -17,6 +17,7 @@ import {
formatTime,
getCurrentLocale,
formatModelName,
isoOrUndefined,
} from "../format";
describe("formatMs", () => {
@@ -256,3 +257,23 @@ describe("formatModelName", () => {
expect(formatModelName("o1-preview")).toBe("O1 Preview");
});
});
describe("NaN guards", () => {
it("formatDuration accepts timestamps that already end in Z", () => {
expect(formatDuration("2026-03-05T10:00:00Z", "2026-03-05T10:00:05Z")).toBe("5s");
});
it("formatMs renders '-' for NaN", () => {
expect(formatMs(NaN)).toBe("-");
});
it("timeAgo renders '-' for an unparseable timestamp", () => {
expect(timeAgo("not a date")).toBe("-");
});
});
describe("isoOrUndefined", () => {
it("returns undefined for an empty or unparseable value instead of throwing", () => {
expect(isoOrUndefined("")).toBeUndefined();
expect(isoOrUndefined("x")).toBeUndefined();
expect(isoOrUndefined("2026-01-02T03:04:05Z")).toBe("2026-01-02T03:04:05.000Z");
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* @file throttle.test.ts
* @description Unit tests for the leading+trailing throttle used by live-refresh pages.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { throttle } from "../throttle";
afterEach(() => vi.useRealTimers());
describe("throttle", () => {
it("runs at once, then at most once per window with a trailing call", () => {
vi.useFakeTimers();
const fn = vi.fn();
const t = throttle(fn, 1000);
t();
expect(fn).toHaveBeenCalledTimes(1);
// A steady stream never freezes: it fires every window, not after it stops.
for (let i = 0; i < 25; i++) {
vi.advanceTimersByTime(100);
t();
}
expect(fn.mock.calls.length).toBeGreaterThanOrEqual(3);
vi.advanceTimersByTime(1000);
const n = fn.mock.calls.length;
vi.advanceTimersByTime(5000);
expect(fn).toHaveBeenCalledTimes(n);
});
it("cancel drops a pending trailing call", () => {
vi.useFakeTimers();
const fn = vi.fn();
const t = throttle(fn, 1000);
t();
t();
t.cancel();
vi.advanceTimersByTime(2000);
expect(fn).toHaveBeenCalledTimes(1);
});
});
+39
View File
@@ -0,0 +1,39 @@
/**
* @file exportData.ts
* @description Client-side "Download CSV / JSON" for list pages (Sessions,
* Activity, a session's events): `toCsv` turns rows into RFC 4180 CSV (and
* neutralizes spreadsheet formulas), `downloadText` saves a string as a file.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** One CSV cell: quoted when needed; a leading =, +, - or @ is prefixed with `'`
* so a spreadsheet never runs a value from a session as a formula. */
function cell(value: unknown): string {
if (value == null) return "";
let s = typeof value === "object" ? JSON.stringify(value) : String(value);
if (typeof value === "string" && /^[=+\-@\t\r]/.test(s)) s = `'${s}`;
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
export function toCsv<T extends object>(
rows: readonly T[],
columns: readonly (keyof T & string)[]
) {
const lines = [columns.join(",")];
for (const row of rows) lines.push(columns.map((c) => cell(row[c])).join(","));
return lines.join("\r\n") + "\r\n";
}
export function downloadText(filename: string, text: string, mime: string): void {
const url = URL.createObjectURL(new Blob([text], { type: mime }));
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
/** `name-YYYY-MM-DD.ext` */
export function exportName(name: string, ext: "csv" | "json"): string {
return `${name}-${new Date().toISOString().slice(0, 10)}.${ext}`;
}
+13 -1
View File
@@ -163,7 +163,7 @@ import i18n from "../i18n";
* parseDate("2026-04-18T08:49:13Z") // parsed as-is
* parseDate("2026-04-18T08:49:13-04:00") // parsed as-is (explicit offset)
*/
function parseDate(iso: string): Date {
export function parseDate(iso: string): Date {
// Already has timezone info (Z or +/- offset) - parse directly
// (`/[+-]\d{2}:\d{2}$/` catches trailing `+04:00`-style offsets).
if (/[Zz]$/.test(iso) || /[+-]\d{2}:\d{2}$/.test(iso)) {
@@ -297,6 +297,16 @@ export function formatDuration(start: string, end: string): string {
return formatMs(ms);
}
/**
* A URL/user date value as an ISO string for an API param, or `undefined` when
* it is empty or unparseable (`new Date("x").toISOString()` throws).
*/
export function isoOrUndefined(value: string | null | undefined): string | undefined {
if (!value) return undefined;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
}
/**
* Formats a millisecond duration as the coarsest two-unit representation:
* "Nh Nm" once >= 1 hour, "Nm Ns" once >= 1 minute, else "Ns".
@@ -311,6 +321,7 @@ export function formatDuration(start: string, end: string): string {
* formatMs(-10) // "0s"
*/
export function formatMs(ms: number): string {
if (Number.isNaN(ms)) return "-"; // unparseable input upstream
if (ms < 0) return "0s"; // clamp negative spans (clock skew) to zero
const totalSec = Math.floor(ms / 1000);
const hours = Math.floor(totalSec / 3600); // whole hours
@@ -334,6 +345,7 @@ export function formatMs(ms: number): string {
*/
export function timeAgo(iso: string): string {
const ms = Date.now() - parseDate(iso).getTime();
if (Number.isNaN(ms)) return "-";
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return i18n.t("common:time.justNow");
const minutes = Math.floor(seconds / 60);
+34
View File
@@ -0,0 +1,34 @@
/**
* @file throttle.ts
* @description Leading + trailing throttle for live-refresh callbacks. Unlike a
* trailing debounce, a steady stream of WebSocket events can never postpone the
* refresh forever: it fires at once, then at most once per `ms`, and once more
* after the burst so the last change is never lost.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export interface Throttled {
(): void;
cancel(): void;
}
export function throttle(fn: () => void, ms: number): Throttled {
let last = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
const run = () => {
timer = undefined;
last = Date.now();
fn();
};
const t = (() => {
if (timer) return;
const wait = last + ms - Date.now();
if (wait <= 0) run();
else timer = setTimeout(run, wait);
}) as Throttled;
t.cancel = () => {
clearTimeout(timer);
timer = undefined;
};
return t;
}
+39 -29
View File
@@ -933,6 +933,12 @@ export interface DashboardEvent {
/** ISO timestamp the event was recorded (ingest time, not hook-reported time).
* Maps to `events.created_at`. Drives all time-bucketed charts. */
created_at: string;
/** Labels joined by `GET /api/events` only (absent on WebSocket pushes). */
session_name?: string | null;
session_cwd?: string | null;
session_lane_id?: number | null;
agent_name?: string | null;
agent_subagent_type?: string | null;
}
// ───── Overview & analytics ─────
@@ -1860,6 +1866,10 @@ export interface SessionStats {
events_by_type: Array<{ event_type: string; count: number }>;
/** Tool invocation counts within this session, most-used first. */
tools_used: Array<{ tool_name: string; count: number }>;
/** Exact tool-call total (`tools_used` is a top-15 list); absent on older servers. */
tool_call_count?: number;
/** Number of distinct tools used; absent on older servers. */
distinct_tool_count?: number;
/** Count of events representing an error (APIError, error-summary Stop) in
* this session. Drives the "N errors" badge on the detail page. */
error_count: number;
@@ -2899,31 +2909,31 @@ export const STATUS_CONFIG: Record<
// Green: the agent is actively running a tool / doing work.
working: {
labelKey: "common:status.working",
color: "text-emerald-700 dark:text-emerald-400",
bg: "bg-emerald-500/10 border-emerald-500/20",
dot: "bg-emerald-400",
color: "text-status-success",
bg: "bg-status-success/10 border-status-success/25",
dot: "bg-status-success",
},
// Yellow: idle-between-turns, or (via the AWAITING_STATUS overlay) blocked
// on user input - attention may be required.
waiting: {
labelKey: "common:status.waiting",
color: "text-yellow-700 dark:text-yellow-400",
bg: "bg-yellow-500/10 border-yellow-500/20",
dot: "bg-yellow-400",
color: "text-status-warning",
bg: "bg-status-warning/10 border-status-warning/25",
dot: "bg-status-warning",
},
// Violet: the agent finished cleanly.
// Accent: the agent finished cleanly.
completed: {
labelKey: "common:status.completed",
color: "text-violet-700 dark:text-violet-400",
bg: "bg-violet-500/10 border-violet-500/20",
dot: "bg-violet-400",
color: "text-accent",
bg: "bg-accent/10 border-accent/25",
dot: "bg-accent",
},
// Red: the agent ended in an error state.
error: {
labelKey: "common:status.error",
color: "text-red-700 dark:text-red-400",
bg: "bg-red-500/10 border-red-500/20",
dot: "bg-red-400",
color: "text-status-danger",
bg: "bg-status-danger/10 border-status-danger/25",
dot: "bg-status-danger",
},
};
@@ -3107,30 +3117,30 @@ export const SESSION_STATUS_CONFIG: Record<
// Green: the session is live and running.
active: {
labelKey: "common:status.active",
color: "text-emerald-700 dark:text-emerald-400",
bg: "bg-emerald-500/10 border-emerald-500/20",
dot: "bg-emerald-400",
color: "text-status-success",
bg: "bg-status-success/10 border-status-success/25",
dot: "bg-status-success",
},
// Yellow: blocked on user input via the AWAITING_STATUS overlay.
waiting: {
labelKey: "common:status.waiting",
color: "text-yellow-700 dark:text-yellow-400",
bg: "bg-yellow-500/10 border-yellow-500/20",
dot: "bg-yellow-400",
color: "text-status-warning",
bg: "bg-status-warning/10 border-status-warning/25",
dot: "bg-status-warning",
},
// Violet: the session ended cleanly (SessionEnd).
// Accent: the session ended cleanly (SessionEnd).
completed: {
labelKey: "common:status.completed",
color: "text-violet-700 dark:text-violet-400",
bg: "bg-violet-500/10 border-violet-500/20",
dot: "bg-violet-400",
color: "text-accent",
bg: "bg-accent/10 border-accent/25",
dot: "bg-accent",
},
// Red: the session ended in an error state.
error: {
labelKey: "common:status.error",
color: "text-red-700 dark:text-red-400",
bg: "bg-red-500/10 border-red-500/20",
dot: "bg-red-400",
color: "text-status-danger",
bg: "bg-status-danger/10 border-status-danger/25",
dot: "bg-status-danger",
},
// Slate: stale session swept as "abandoned" (never cleanly ended); has no
// equivalent in STATUS_CONFIG since agents don't get abandoned.
@@ -3138,8 +3148,8 @@ export const SESSION_STATUS_CONFIG: Record<
// Muted slate distinguishes "given up / faded out" from yellow Waiting
// (attention required).
labelKey: "common:status.abandoned",
color: "text-slate-600 dark:text-slate-400",
bg: "bg-slate-500/10 border-slate-500/20",
dot: "bg-slate-400",
color: "text-fg-secondary",
bg: "bg-surface-3 border-border",
dot: "bg-fg-muted",
},
};