AkurAI Build
Menu

popagent

public

Latest 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>;
}