"use client"; /* Analytics: the project's observability dashboard. Time-series volume/cost/latency/token graphs, per-source & per-tool/model breakdowns, a latency distribution, the usage table, and quick links - all over a selectable date range. Charts are Recharts, themed from the app's CSS variables (resolved to concrete colors so gradients + dark mode work). */ import { useEffect, useMemo, useRef, useState } from "react"; import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import { Icon } from "../icons"; import { Sparkline, StatusPill, EmptyState } from "../primitives"; import { api, Analytics, ProjectCounts, StatRollup, Workflow } from "@/lib/api"; import { fmtUSD } from "@/lib/data"; /* ---------------- formatters ---------------- */ const fmtLatency = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(ms >= 10000 ? 0 : 1)}s` : `${Math.round(ms)}ms`); const fmtCompact = (n: number) => { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; return `${Math.round(n)}`; }; const fmtInt = (n: number) => Math.round(n).toLocaleString(); // "2026-07-24" -> "Jul 24" const fmtAxisDate = (iso: string) => { const d = new Date(iso + "T00:00:00"); return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); }; const SOURCE_LABEL: Record = { playground: "Playground", api: "API", embed: "Embed", assistant: "Forge Assistant", channel_email: "Email", webhook: "Webhook", schedule: "Schedule", app_event: "App event", "-": "Other", }; const srcLabel = (s: string) => SOURCE_LABEL[s] || s || "Other"; const RANGES = [ { value: "7", label: "7d" }, { value: "14", label: "14d" }, { value: "30", label: "30d" }, { value: "90", label: "90d" }, ]; /* ---------------- theme-aware palette ---------------- Recharts draws into SVG; gradient colors and dark-mode switches need concrete values, not `var(--x)` strings. Resolve the tokens from the document once, and again whenever the app flips data-theme on . */ interface Palette { accent: string; signal: string; ok: string; warn: string; err: string; info: string; purple: string; teal: string; fg0: string; fg1: string; fg2: string; line: string; bg1: string; bg3: string; } function readPalette(): Palette { const cs = getComputedStyle(document.documentElement); const v = (n: string, fb: string) => (cs.getPropertyValue(n).trim() || fb); return { accent: v("--accent", "#4F46E5"), signal: v("--signal", "#4F46E5"), ok: v("--ok", "#16A34A"), warn: v("--warn", "#D97706"), err: v("--err", "#DC2626"), info: v("--info", "#2563EB"), purple: v("--io-json", "#7C3AED"), teal: v("--io-messages", "#0D9488"), fg0: v("--fg-0", "#111"), fg1: v("--fg-1", "#444"), fg2: v("--fg-2", "#888"), line: v("--line", "#E5E5E5"), bg1: v("--bg-1", "#fff"), bg3: v("--bg-3", "#f0f0f0"), }; } function useThemeColors(): Palette { const [pal, setPal] = useState(null); useEffect(() => { setPal(readPalette()); const obs = new MutationObserver(() => setPal(readPalette())); obs.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); return () => obs.disconnect(); }, []); return pal || readPaletteFallback(); } // SSR / first paint before the effect runs: sensible light defaults so charts don't flash. function readPaletteFallback(): Palette { return { accent: "#4F46E5", signal: "#4F46E5", ok: "#16A34A", warn: "#D97706", err: "#DC2626", info: "#2563EB", purple: "#7C3AED", teal: "#0D9488", fg0: "#111", fg1: "#444", fg2: "#888", line: "#E5E5E5", bg1: "#fff", bg3: "#f0f0f0", }; } /* ---------------- small building blocks ---------------- */ function DeltaBadge({ cur, prev, goodUp = true, fmt }: { cur: number; prev: number; goodUp?: boolean; fmt?: (n: number) => string }) { if (prev === 0 && cur === 0) return no change; if (prev === 0) return new; const pct = ((cur - prev) / Math.abs(prev)) * 100; const up = pct > 0; const flat = Math.abs(pct) < 0.05; const good = flat ? null : up === goodUp; const color = flat ? "var(--fg-2)" : good ? "var(--ok)" : "var(--err)"; return ( {!flat && } {flat ? "±0%" : `${up ? "+" : ""}${pct.toFixed(pct >= 100 || pct <= -100 ? 0 : 1)}%`} ); } function KpiTile({ label, value, sub, spark, sparkColor, delta }: { label: string; value: string; sub?: string; spark?: number[]; sparkColor?: string; delta?: React.ReactNode; }) { return (
{label}
{delta}
{value}
{sub &&
{sub}
}
{spark && spark.some((n) => n > 0) && }
); } function ChartCard({ title, sub, right, children, height = 232 }: { title: string; sub?: string; right?: React.ReactNode; children: React.ReactNode; height?: number; }) { return (
{title}
{sub &&
{sub}
}
{right}
{children}
); } // One shared tooltip for every chart: dark card, the point label, then each series. function ChartTooltip({ active, payload, label, pal, labelFmt, valueFmt }: any) { if (!active || !payload?.length) return null; return (
{label != null &&
{labelFmt ? labelFmt(label) : label}
} {payload.map((p: any, i: number) => (
{p.name} {valueFmt ? valueFmt(p.value, p.dataKey) : p.value}
))}
); } const axisProps = (pal: Palette) => ({ stroke: pal.line, tick: { fill: pal.fg2, fontSize: 11 }, tickLine: false, axisLine: { stroke: pal.line } }); /* ================= ANALYTICS SCREEN ================= */ export function AnalyticsScreen({ project, onNav }: { project: any; onNav: (s: string) => void }) { const pal = useThemeColors(); const [days, setDays] = useState("30"); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [counts, setCounts] = useState(null); const [workflows, setWorkflows] = useState([]); const reqId = useRef(0); useEffect(() => { if (!project?.id) return; const id = ++reqId.current; setLoading(true); api.projectAnalytics(project.id, Number(days)) .then((d) => { if (id === reqId.current) { setData(d); setLoading(false); } }) .catch(() => { if (id === reqId.current) { setData(null); setLoading(false); } }); }, [project?.id, days]); useEffect(() => { if (!project?.id) return; api.projectCounts(project.id).then(setCounts).catch(() => setCounts(null)); api.listWorkflows(project.id).then(setWorkflows).catch(() => setWorkflows([])); }, [project?.id]); const ts = data?.timeseries || []; const spark = (key: keyof Analytics["timeseries"][number]) => ts.map((p) => Number(p[key]) || 0); const t = data?.totals || ({} as StatRollup); const pv = data?.prev_totals || ({} as StatRollup); const successRate = (r: StatRollup) => (r.runs ? Math.round((100 - (r.error_rate || 0)) * 10) / 10 : 0); // Merge success+error per day for the stacked volume chart; label sources for the pie. const volume = useMemo(() => ts.map((p) => ({ date: p.date, Success: p.success, Errors: p.errors })), [ts]); const sourcePie = useMemo( () => (data?.by_source || []).filter((s) => s.cost_usd > 0 || s.runs > 0).map((s) => ({ name: srcLabel(s.source), value: Math.round(s.cost_usd * 1e6) / 1e6, runs: s.runs })), [data], ); const pieColors = [pal.accent, pal.teal, pal.warn, pal.purple, pal.info, pal.ok, pal.err]; const hasRuns = (data?.totals?.runs || 0) > 0; const health = [ { label: "Workflows", value: counts?.workflows ?? workflows.length, icon: "workflows", screen: "workflows" }, { label: "Agents", value: counts?.agents ?? "-", icon: "agents", screen: "agents" }, { label: "Tools", value: counts?.tools ?? "-", icon: "tools", screen: "tools" }, { label: "Knowledge", value: counts?.knowledge ?? "-", icon: "knowledge", screen: "knowledge" }, ]; return (
{/* header + range picker */}
{project?.name}
Analytics · {project?.slug}
{RANGES.map((r) => ( ))}
{loading && !data ? (
Loading analytics…
) : !hasRuns ? ( <>
onNav("playground")}>Open Playground} />
) : ( <> {/* KPI row */}
} /> `${n}%`} />} /> } /> } /> } /> `${n}%`} />} />
{/* time-series: volume + cost */}
} /> `$${v < 1 ? v.toFixed(2) : fmtCompact(v)}`} {...axisProps(pal)} /> fmtUSD(v)} />} />
{/* time-series: latency + tokens */}
fmtLatency(v)} {...axisProps(pal)} /> fmtLatency(v)} />} /> fmtCompact(v)} {...axisProps(pal)} /> fmtInt(v)} />} />
{/* breakdowns: cost by source (pie) + tool calls (bar) + latency distribution (bar) */}
{sourcePie.length === 0 ? : ( {sourcePie.map((_, i) => )} fmtUSD(v)} />} /> )} {(data?.tools?.length || 0) === 0 ? : ( (k === "calls" ? fmtInt(v) : v)} />} /> )} `${fmtInt(v)} runs`} />} />
{/* usage-by-source table + models + recent */}
Usage by source
{(data?.by_workflow || []).map((r, i) => ( ))} {(data?.by_workflow?.length || 0) === 0 && }
SourceRunsTokensAvg latencyErrorsCost
{r.label}
{fmtInt(r.runs)} {fmtInt(r.tokens)} {fmtLatency(r.avg_latency_ms)} {r.errors ? {r.errors} : 0} {fmtUSD(r.cost_usd)}
No usage in this window.
Model spend
{(data?.models?.length || 0) === 0 ? (
No model calls recorded.
) : ( {data!.models.map((m, i) => ( ))}
ModelCallsTokensCost
{m.model} {fmtInt(m.calls)} {fmtCompact(m.tokens)} {fmtUSD(m.cost_usd)}
)}
{/* recent activity */}
Recent runs
{(data?.recent || []).map((r, i, arr) => (
{r.workflow}
{fmtInt(r.tokens)} tok {fmtLatency(r.latency_ms)} {fmtUSD(r.cost_usd)} {r.started_at ? r.started_at.slice(11, 16) : ""}
))} {(data?.recent?.length || 0) === 0 &&
No recent runs.
}
)}
); } function NoData({ label = "No data" }: { label?: string }) { return
{label}
; } function PieLegend({ items, colors }: { items: { name: string; value: number }[]; colors: string[] }) { if (!items.length) return null; return (
{items.slice(0, 5).map((s, i) => (
{s.name} {fmtUSD(s.value)}
))}
); } /* Quick links kept from the old Overview so this stays the project landing screen: resource counts (deep-link into each builder) + the workflow list + deployment shortcuts. */ function QuickLinks({ health, workflows, onNav }: { health: { label: string; value: any; icon: string; screen: string }[]; workflows: Workflow[]; onNav: (s: string) => void }) { return ( <>
{health.map((h, i) => ( ))}
Workflows
{workflows.length === 0 ? (
No workflows yet. Open the canvas to build one.
) : (
{workflows.map((w) => ( ))}
)}
Deployment
{[["msg", "Channels", "Email", "channels"], ["connect", "Connect", "Run API · MCP · widget", "connect"], ["playground", "Playground", "Test your workflow", "playground"]].map((d, i) => ( ))}
); }