Menu
popagent
publicLatest change 0ea30ecfc0b99682425d7170ee77bf5bcf8ace06 - Add range, filter, and paging controls to the Observability page by AkurAI Build
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Copy, Loader2, RefreshCw } from "lucide-react";
import type {
ObservabilityLog,
ObservabilityLogListResponse,
ObservabilityOverviewResponse,
ObservabilityTraceListResponse,
ObservabilityTraceResponse,
ObservabilityTraceSpan,
ObservabilityTraceSummary,
} from "../api-types";
import { apiFetch } from "./api";
import { toast } from "./toast";
const PER_PAGE = 25;
export const OBSERVABILITY_RANGES = [
{ id: "1h", label: "1h", ms: 3_600_000 },
{ id: "24h", label: "24h", ms: 86_400_000 },
{ id: "7d", label: "7d", ms: 7 * 86_400_000 },
{ id: "30d", label: "30d", ms: 30 * 86_400_000 },
] as const;
export type ObservabilityRangeId = typeof OBSERVABILITY_RANGES[number]["id"];
export type TraceStatusFilter = "all" | "error" | "running" | "success";
export function observabilityRangeQuery(range: ObservabilityRangeId, now = new Date()): string {
const span = OBSERVABILITY_RANGES.find((option) => option.id === range) ?? OBSERVABILITY_RANGES[1];
return `from=${new Date(now.getTime() - span.ms).toISOString()}&to=${now.toISOString()}`;
}
export function formatDuration(ms: number | null): string {
if (ms === null) return "running";
if (ms < 1000) return `${Math.round(ms)} ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
}
export function relativeTime(iso: string, now = new Date()): string {
const seconds = Math.max(0, Math.round((now.getTime() - new Date(iso).getTime()) / 1000));
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86_400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86_400)}d ago`;
}
export type TraceGroup = {
traceId: string;
name: string;
entityName: string | null;
status: "success" | "error" | "running";
startedAt: string;
durationMs: number | null;
rootCount: number;
};
/** One row per trace: Mastra records several root spans under the same trace ID. */
export function groupTraces(traces: ObservabilityTraceSummary[]): TraceGroup[] {
const groups = new Map<string, TraceGroup>();
for (const trace of traces) {
const existing = groups.get(trace.traceId);
if (!existing) {
groups.set(trace.traceId, {
traceId: trace.traceId,
name: trace.entityName ?? trace.name,
entityName: trace.entityName,
status: trace.status,
startedAt: trace.startedAt,
durationMs: trace.durationMs,
rootCount: 1,
});
continue;
}
existing.rootCount += 1;
if (trace.status === "error") existing.status = "error";
else if (trace.status === "running" && existing.status !== "error") existing.status = "running";
if (trace.startedAt < existing.startedAt) existing.startedAt = trace.startedAt;
if (trace.durationMs !== null) existing.durationMs = Math.max(existing.durationMs ?? 0, trace.durationMs);
}
return [...groups.values()];
}
export function filterTraces(groups: TraceGroup[], status: TraceStatusFilter, query: string): TraceGroup[] {
const search = query.trim().toLowerCase();
return groups.filter((group) => (status === "all" || group.status === status)
&& (!search || `${group.name} ${group.traceId}`.toLowerCase().includes(search)));
}
export function filterLogs(logs: ObservabilityLog[], levels: string[], query: string): ObservabilityLog[] {
const search = query.trim().toLowerCase();
return logs.filter((log) => (!levels.length || levels.includes(log.level))
&& (!search || `${log.message} ${log.traceId ?? ""} ${log.entityName ?? ""}`.toLowerCase().includes(search)));
}
export type SpanBar = { span: ObservabilityTraceSpan; depth: number; offset: number; width: number };
/** Lay spans out as a waterfall relative to the whole trace window. */
export function spanTimeline(spans: ObservabilityTraceSpan[]): SpanBar[] {
if (!spans.length) return [];
const starts = spans.map((span) => new Date(span.startedAt).getTime());
const ends = spans.map((span, index) => span.endedAt ? new Date(span.endedAt).getTime() : starts[index]!);
const base = Math.min(...starts);
const total = Math.max(...ends) - base || 1;
const byId = new Map(spans.map((span) => [span.spanId, span]));
const depthOf = (span: ObservabilityTraceSpan): number => {
let depth = 0;
let parent = span.parentSpanId ? byId.get(span.parentSpanId) : undefined;
while (parent && depth < 4) {
depth += 1;
parent = parent.parentSpanId ? byId.get(parent.parentSpanId) : undefined;
}
return depth;
};
return spans
.map((span, index) => ({
span,
depth: depthOf(span),
offset: ((starts[index]! - base) / total) * 100,
width: Math.max(1.5, ((ends[index]! - starts[index]!) / total) * 100),
}))
.sort((a, b) => a.offset - b.offset);
}
export function ObservabilitySummary({ overview }: { overview: ObservabilityOverviewResponse }) {
const items: { label: string; value: string; tone?: string }[] = [
{ label: "Runs", value: overview.runs.toLocaleString() },
{ label: "Errors", value: `${overview.errors.toLocaleString()} · ${(overview.errorRate * 100).toFixed(1)}%`, tone: overview.errors ? "text-red-400" : undefined },
{ label: "P95 latency", value: overview.p95LatencyMs === null ? "—" : formatDuration(overview.p95LatencyMs) },
{ label: "Input tokens", value: overview.inputTokens.toLocaleString() },
{ label: "Output tokens", value: overview.outputTokens.toLocaleString() },
{ label: "Process RSS", value: `${(overview.processRssBytes / 1_048_576).toFixed(1)} MiB` },
{ label: "Heap used", value: `${(overview.processHeapUsedBytes / 1_048_576).toFixed(1)} MiB` },
];
return <section aria-label="Observability summary" className="obs-kpis">
{items.map((item) => <article key={item.label} className="obs-kpi">
<span className="obs-kpi-label">{item.label}</span>
<span className={`obs-kpi-value ${item.tone ?? ""}`}>{item.value}</span>
</article>)}
</section>;
}
function StatusIcon({ status }: { status: TraceGroup["status"] }) {
if (status === "error") return <AlertTriangle className="size-4 shrink-0 text-red-400" />;
if (status === "running") return <Loader2 className="size-4 shrink-0 animate-spin text-ink-dim" />;
return <CheckCircle2 className="size-4 shrink-0 text-emerald-400" />;
}
export function ObservabilityPage() {
const [range, setRange] = useState<ObservabilityRangeId>("24h");
const [autoRefresh, setAutoRefresh] = useState(false);
const [overview, setOverview] = useState<ObservabilityOverviewResponse>();
const [traces, setTraces] = useState<ObservabilityTraceListResponse>();
const [logs, setLogs] = useState<ObservabilityLogListResponse>();
const [selected, setSelected] = useState<ObservabilityTraceResponse>();
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState<"traces" | "logs">();
const [updatedAt, setUpdatedAt] = useState<string>();
const [error, setError] = useState("");
const [traceStatus, setTraceStatus] = useState<TraceStatusFilter>("all");
const [traceQuery, setTraceQuery] = useState("");
const [logLevels, setLogLevels] = useState<string[]>([]);
const [logQuery, setLogQuery] = useState("");
const [pane, setPane] = useState<"traces" | "logs">("traces");
const windowRef = useRef(observabilityRangeQuery("24h"));
const load = useCallback(async () => {
setLoading(true);
setError("");
windowRef.current = observabilityRangeQuery(range);
try {
const [overviewResponse, tracesResponse, logsResponse] = await Promise.all([
apiFetch(`/api/observability/overview?${windowRef.current}`),
apiFetch(`/api/observability/traces?perPage=${PER_PAGE}&${windowRef.current}`),
apiFetch(`/api/observability/logs?perPage=${PER_PAGE}&${windowRef.current}`),
]);
if (!overviewResponse.ok || !tracesResponse.ok || !logsResponse.ok) {
setError("Observability data is unavailable.");
} else {
setOverview(await overviewResponse.json() as ObservabilityOverviewResponse);
setTraces(await tracesResponse.json() as ObservabilityTraceListResponse);
setLogs(await logsResponse.json() as ObservabilityLogListResponse);
setUpdatedAt(new Date().toISOString());
}
} catch {
setError("Observability data is unavailable.");
} finally {
setLoading(false);
}
}, [range]);
useEffect(() => { void load(); }, [load]);
useEffect(() => {
if (!autoRefresh) return;
const timer = setInterval(() => void load(), 15_000);
return () => clearInterval(timer);
}, [autoRefresh, load]);
const loadMore = async (kind: "traces" | "logs") => {
const current = kind === "traces" ? traces : logs;
if (!current?.hasMore) return;
setLoadingMore(kind);
try {
const response = await apiFetch(`/api/observability/${kind}?perPage=${PER_PAGE}&page=${current.page + 1}&${windowRef.current}`);
if (!response.ok) throw new Error("unavailable");
if (kind === "traces" && traces) {
const next = await response.json() as ObservabilityTraceListResponse;
setTraces({ ...next, traces: [...traces.traces, ...next.traces] });
} else if (kind === "logs" && logs) {
const next = await response.json() as ObservabilityLogListResponse;
setLogs({ ...next, logs: [...logs.logs, ...next.logs] });
}
} catch {
toast("Could not load more observability records.");
} finally {
setLoadingMore(undefined);
}
};
const openTrace = async (traceId: string) => {
if (selected?.traceId === traceId) return setSelected(undefined);
try {
const response = await apiFetch(`/api/observability/traces/${traceId}`);
if (response.ok) setSelected(await response.json() as ObservabilityTraceResponse);
else toast("That trace is no longer available.");
} catch {
toast("That trace is no longer available.");
}
};
const copyTraceId = (traceId: string) => {
void navigator.clipboard?.writeText(traceId).then(() => toast("Trace ID copied."), () => toast("Could not copy the trace ID."));
};
const groups = useMemo(() => filterTraces(groupTraces(traces?.traces ?? []), traceStatus, traceQuery), [traces, traceStatus, traceQuery]);
const visibleLogs = useMemo(() => filterLogs(logs?.logs ?? [], logLevels, logQuery), [logs, logLevels, logQuery]);
const timeline = useMemo(() => spanTimeline(selected?.spans ?? []), [selected]);
const rangeLabel = OBSERVABILITY_RANGES.find((option) => option.id === range)?.label ?? "24h";
const chipClass = (active: boolean) => `min-h-8 rounded-full border px-2.5 text-xs ${active ? "border-accent/50 bg-accent-soft text-ink" : "border-line text-ink-muted hover:text-ink"}`;
return <div className="w-full">
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div>
<h1 className="text-xl font-semibold">Observability</h1>
<p className="text-sm text-ink-dim">Runs, latency, tokens, and correlated warnings over the last {rangeLabel}.{updatedAt ? ` Updated ${relativeTime(updatedAt)}.` : ""}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<div role="group" aria-label="Time range" className="flex rounded border border-line">
{OBSERVABILITY_RANGES.map((option) => <button
key={option.id}
type="button"
aria-pressed={range === option.id}
onClick={() => { setRange(option.id); setSelected(undefined); }}
className={`min-h-9 px-3 text-sm ${range === option.id ? "bg-accent-soft font-medium text-ink" : "text-ink-muted hover:text-ink"}`}
>{option.label}</button>)}
</div>
<label className="flex min-h-9 items-center gap-2 text-sm text-ink-muted">
<input type="checkbox" checked={autoRefresh} onChange={(event) => setAutoRefresh(event.currentTarget.checked)} className="size-4 accent-accent" />Auto
</label>
<button type="button" onClick={() => void load()} disabled={loading} className="liquid-control flex min-h-9 items-center gap-2 rounded px-3 text-sm"><RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />Refresh</button>
</div>
</div>
{error ? <div role="alert" className="mt-4 rounded border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-300">{error}</div> : null}
{loading && !overview ? <p className="mt-4 text-sm text-ink-dim">Loading observability…</p> : null}
{overview ? <div className="mt-4"><ObservabilitySummary overview={overview} /></div> : null}
<div role="group" aria-label="Observability pane" className="mt-4 flex gap-1 md:hidden">
<button type="button" aria-pressed={pane === "traces"} onClick={() => setPane("traces")} className={chipClass(pane === "traces")}>Traces</button>
<button type="button" aria-pressed={pane === "logs"} onClick={() => setPane("logs")} className={chipClass(pane === "logs")}>Warnings and errors</button>
</div>
<div className="mt-4 grid items-start gap-4 xl:grid-cols-[1.45fr_1fr]">
<section id="observability-traces" className={`rounded border border-line bg-panel ${pane === "traces" ? "" : "hidden md:block"}`}>
<header className="flex flex-wrap items-center gap-2 border-b border-line p-2">
<input
type="search"
aria-label="Filter traces"
placeholder="Filter traces"
value={traceQuery}
onChange={(event) => setTraceQuery(event.currentTarget.value)}
className="min-h-9 min-w-0 basis-full rounded sm:flex-1 sm:basis-40 border border-line bg-bg px-2.5 text-sm text-ink outline-none focus:border-accent/50"
/>
{(["all", "error", "running", "success"] as TraceStatusFilter[]).map((option) => <button
key={option}
type="button"
aria-pressed={traceStatus === option}
onClick={() => setTraceStatus(option)}
className={chipClass(traceStatus === option)}
>{option === "all" ? "All" : option === "error" ? "Errors" : option === "running" ? "Running" : "Success"}</button>)}
</header>
<div className="divide-y divide-line">
{groups.length ? groups.map((group) => {
const open = selected?.traceId === group.traceId;
return <article key={group.traceId}>
<div className="flex items-center gap-2 pr-2">
<button type="button" aria-expanded={open} onClick={() => void openTrace(group.traceId)} className="flex min-h-11 min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left hover:bg-panel-2">
{open ? <ChevronDown className="size-4 shrink-0 text-ink-dim" /> : <ChevronRight className="size-4 shrink-0 text-ink-dim" />}
<StatusIcon status={group.status} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{group.name}</span>
<span className="block truncate font-mono text-[10px] text-ink-dim">{group.traceId}</span>
</span>
<span className="shrink-0 text-right text-xs text-ink-dim">
<span className="block tabular-nums">{formatDuration(group.durationMs)}</span>
<span className="block">{relativeTime(group.startedAt)} · {group.rootCount} span{group.rootCount === 1 ? "" : "s"}</span>
</span>
</button>
<button type="button" aria-label={`Copy trace ID ${group.traceId}`} onClick={() => copyTraceId(group.traceId)} className="flex size-9 shrink-0 items-center justify-center rounded text-ink-dim hover:bg-panel-2 hover:text-ink"><Copy className="size-3.5" /></button>
</div>
{open ? <ol className="space-y-1 border-t border-line bg-bg/40 px-2 py-2">
{timeline.map((bar) => <li key={bar.span.spanId} className="text-xs" style={{ paddingLeft: `${bar.depth * 12}px` }}>
<div className="flex items-center gap-2">
<span title={bar.span.spanType} className="w-28 shrink-0 truncate text-[10px] uppercase tracking-wide text-ink-dim">{bar.span.spanType}</span>
<span className="min-w-0 flex-1 truncate">{bar.span.entityName ?? bar.span.name}</span>
<span className="shrink-0 tabular-nums text-ink-dim">{formatDuration(bar.span.durationMs)}</span>
</div>
<div className="obs-track"><span className={`obs-bar ${bar.span.status === "error" ? "is-error" : ""}`} style={{ marginLeft: `${bar.offset}%`, width: `${bar.width}%` }} /></div>
{bar.span.error ? <pre className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap break-words rounded border border-red-500/20 bg-red-500/5 p-2 text-red-300">{typeof bar.span.error === "string" ? bar.span.error : JSON.stringify(bar.span.error, null, 2)}</pre> : null}
</li>)}
</ol> : null}
</article>;
}) : <p className="p-3 text-sm text-ink-dim">{traces?.traces.length ? "No trace matches these filters." : "No traces in this period."}</p>}
</div>
{traces?.hasMore ? <div className="border-t border-line p-2"><button type="button" disabled={loadingMore === "traces"} onClick={() => void loadMore("traces")} className="min-h-9 w-full rounded border border-line text-sm text-ink-muted hover:text-ink">{loadingMore === "traces" ? "Loading…" : `Load more (${traces.total.toLocaleString()} total)`}</button></div> : null}
</section>
<section className={`rounded border border-line bg-panel ${pane === "logs" ? "" : "hidden md:block"}`}>
<header className="flex flex-wrap items-center gap-2 border-b border-line p-2">
<input
type="search"
aria-label="Filter warnings and errors"
placeholder="Filter messages"
value={logQuery}
onChange={(event) => setLogQuery(event.currentTarget.value)}
className="min-h-9 min-w-0 basis-full rounded sm:flex-1 sm:basis-40 border border-line bg-bg px-2.5 text-sm text-ink outline-none focus:border-accent/50"
/>
{["warn", "error", "fatal"].map((level) => <button
key={level}
type="button"
aria-pressed={logLevels.includes(level)}
onClick={() => setLogLevels(logLevels.includes(level) ? logLevels.filter((item) => item !== level) : [...logLevels, level])}
className={chipClass(logLevels.includes(level))}
>{level}</button>)}
</header>
<div className="divide-y divide-line">
{visibleLogs.length ? visibleLogs.map((log) => <article key={log.id ?? `${log.timestamp}-${log.message}`} className="px-3 py-2">
<div className="flex items-center gap-2 text-[10px] uppercase tracking-wide">
<span className={log.level === "warn" ? "text-amber-400" : "text-red-400"}>{log.level}</span>
<time dateTime={log.timestamp} className="text-ink-dim" title={new Date(log.timestamp).toLocaleString()}>{relativeTime(log.timestamp)}</time>
{log.entityName ? <span className="truncate text-ink-dim">{log.entityName}</span> : null}
</div>
<p className="mt-0.5 break-words text-sm">{log.message}</p>
{log.traceId ? <button
type="button"
onClick={() => { setPane("traces"); setTraceStatus("all"); setTraceQuery(log.traceId!); if (selected?.traceId !== log.traceId) void openTrace(log.traceId!); document.getElementById("observability-traces")?.scrollIntoView({ block: "start" }); }}
className="mt-1 block max-w-full truncate font-mono text-[10px] text-accent hover:underline"
>Show trace {log.traceId}</button> : null}
</article>) : <p className="p-3 text-sm text-ink-dim">{logs?.logs.length ? "No message matches these filters." : "No warnings or errors in this period."}</p>}
</div>
{logs?.hasMore ? <div className="border-t border-line p-2"><button type="button" disabled={loadingMore === "logs"} onClick={() => void loadMore("logs")} className="min-h-9 w-full rounded border border-line text-sm text-ink-muted hover:text-ink">{loadingMore === "logs" ? "Loading…" : `Load more (${logs.total.toLocaleString()} total)`}</button></div> : null}
</section>
</div>
</div>;
}