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:
@@ -56,8 +56,10 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { X, GitFork, Wrench, List, Search, ChevronDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { workspaceHref } from "../SessionRowMenu";
|
||||
import { api } from "../../lib/api";
|
||||
import { formatDateTime, formatMs, formatModelName } from "../../lib/format";
|
||||
import { formatDateTime, formatDuration, formatModelName } from "../../lib/format";
|
||||
import type {
|
||||
SessionDrillIn as SessionDrillInData,
|
||||
DashboardEvent,
|
||||
@@ -157,14 +159,7 @@ function TreeNode({ node, depth }: TreeNodeProps) {
|
||||
const { t } = useTranslation(["workflows", "common"]);
|
||||
const indentPx = depth * 20;
|
||||
const isMain = node.type === "main";
|
||||
const dur = node.ended_at
|
||||
? formatMs(
|
||||
Math.max(
|
||||
0,
|
||||
new Date(node.ended_at + "Z").getTime() - new Date(node.started_at + "Z").getTime()
|
||||
)
|
||||
)
|
||||
: t("common:running");
|
||||
const dur = node.ended_at ? formatDuration(node.started_at, node.ended_at) : t("common:running");
|
||||
const sc = statusColor(node.status);
|
||||
const statusLabel = t(`common:status.${node.status}`, { defaultValue: node.status });
|
||||
|
||||
@@ -451,14 +446,27 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
|
||||
{session.started_at && ` \u00b7 ${safeTimestamp(session.started_at)}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
|
||||
aria-label={t("drillIn.closePanel")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Link
|
||||
to={`/sessions/${encodeURIComponent(session.id)}`}
|
||||
className="btn-ghost text-xs px-2 py-1"
|
||||
>
|
||||
{t("drillIn.openSession")}
|
||||
</Link>
|
||||
{session.lane_id != null && (
|
||||
<Link to={workspaceHref(session)!} className="btn-ghost text-xs px-2 py-1">
|
||||
{t("drillIn.openInWorkspace")}
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
|
||||
aria-label={t("drillIn.closePanel")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<TabBar active={activeTab} onChange={onTabChange} />
|
||||
</div>
|
||||
@@ -478,51 +486,40 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [allSessions, setAllSessions] = useState<Session[]>([]);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [allLoaded, setAllLoaded] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// Typing fires overlapping requests; only the newest may write the list.
|
||||
const seqRef = useRef(0);
|
||||
|
||||
const fetchPage = useCallback((pageOffset: number, replace: boolean) => {
|
||||
// The search runs on the server (`q` matches id, name and folder), one page
|
||||
// at a time — never a download of every session to filter here.
|
||||
const fetchPage = useCallback((q: string, pageOffset: number, replace: boolean) => {
|
||||
const seq = ++seqRef.current;
|
||||
setLoading(true);
|
||||
api.sessions
|
||||
.list({ limit: PAGE_SIZE, offset: pageOffset })
|
||||
.list({ q: q || undefined, limit: PAGE_SIZE, offset: pageOffset })
|
||||
.then(({ sessions: page }) => {
|
||||
if (seq !== seqRef.current) return;
|
||||
setSessions((prev) => (replace ? page : [...prev, ...page]));
|
||||
setHasMore(page.length === PAGE_SIZE);
|
||||
setOffset(pageOffset + page.length);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
.finally(() => {
|
||||
if (seq === seqRef.current) setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load ALL sessions for search (once, lazily)
|
||||
const loadAllSessions = useCallback(() => {
|
||||
if (allLoaded) return;
|
||||
setAllLoaded(true);
|
||||
// Fetch large batch for search
|
||||
api.sessions
|
||||
.list({ limit: 5000, offset: 0 })
|
||||
.then(({ sessions: all }) => setAllSessions(all))
|
||||
.catch(() => {});
|
||||
}, [allLoaded]);
|
||||
|
||||
// Load first page when dropdown opens
|
||||
// (Re)load the first page while open, debounced on the search text.
|
||||
const query = search.trim();
|
||||
useEffect(() => {
|
||||
if (open && sessions.length === 0) {
|
||||
fetchPage(0, true);
|
||||
}
|
||||
}, [open, sessions.length, fetchPage]);
|
||||
|
||||
// When user starts typing, load all sessions for search
|
||||
useEffect(() => {
|
||||
if (search.trim().length > 0) {
|
||||
loadAllSessions();
|
||||
}
|
||||
}, [search, loadAllSessions]);
|
||||
if (!open) return;
|
||||
const timer = setTimeout(() => fetchPage(query, 0, true), query ? 250 : 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [open, query, fetchPage]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
@@ -536,13 +533,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
// When searching, filter across ALL sessions; otherwise show paginated
|
||||
const filtered = search.trim()
|
||||
? (allSessions.length > 0 ? allSessions : sessions).filter((s) => {
|
||||
const q = search.toLowerCase();
|
||||
return (s.name ?? "").toLowerCase().includes(q) || s.id.toLowerCase().includes(q);
|
||||
})
|
||||
: sessions;
|
||||
const filtered = sessions;
|
||||
|
||||
function handleSelect(id: string) {
|
||||
setOpen(false);
|
||||
@@ -551,7 +542,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
}
|
||||
|
||||
function handleLoadMore() {
|
||||
fetchPage(offset, false);
|
||||
fetchPage(query, offset, false);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -560,7 +551,8 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
<div
|
||||
className={[
|
||||
"flex items-center gap-2 px-3 py-2 rounded-lg border bg-surface-3 transition-colors cursor-text",
|
||||
open ? "border-indigo-500/40 ring-1 ring-indigo-500/20" : "border-border",
|
||||
"focus-within:ring-2 focus-within:ring-accent/40",
|
||||
open ? "border-accent/40" : "border-border",
|
||||
].join(" ")}
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
@@ -573,6 +565,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
type="text"
|
||||
value={search}
|
||||
placeholder={t("drillIn.searchPlaceholder")}
|
||||
aria-label={t("drillIn.searchPlaceholder")}
|
||||
className="flex-1 bg-transparent text-xs text-fg-secondary placeholder-fg-muted outline-none min-w-0"
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={(e) => {
|
||||
@@ -648,8 +641,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more - only show when not filtering client-side */}
|
||||
{!search.trim() && hasMore && (
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadMore}
|
||||
|
||||
Reference in New Issue
Block a user