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