Skip to content
418 linesCodeBlameRaw
1/**
2 * The parts of a project's Observability pages: the bar of filters every
3 * page shares, numbers with how they moved, the time charts, and the
4 * badges for statuses and log levels.
5 */
6import { Activity, ChevronDown, Radio } from "lucide-react";
7import { type ReactNode, useId, useState } from "react";
8import { Form, Link, useNavigate, useNavigation } from "react-router";
9
10import type { LogSeverity } from "@g1t/contracts";
11
12import { cn } from "../lib/cn";
13import { RANGES, type View, axisTime, change as changeOf, niceTicks, statusTone, LEVEL_TONE, viewHref } from "../lib/observability";
14import { Badge } from "./ui/badge";
15import { Card } from "./ui/card";
16import { Hint } from "./ui/hint";
17import { Input } from "./ui/input";
18import { SelectField } from "./ui/select";
19
20// ---- Header and filters ------------------------------------------------------
21
22export function ObservabilityHeader({ title, about, children }: { title: string; about: ReactNode; children?: ReactNode }) {
23 return (
24 <header className="flex flex-wrap items-start justify-between gap-4">
25 <div className="min-w-0">
26 <h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
27 <Activity size={18} className="text-accent" />
28 {title}
29 </h1>
30 <p className="mt-1 max-w-2xl text-sm text-muted">{about}</p>
31 </div>
32 {children}
33 </header>
34 );
35}
36
37/**
38 * The period, environment and service every page narrows by, in the
39 * address, so a view can be shared. Search is a page's own (`search`).
40 */
41export function FilterBar({
42 path,
43 view,
44 environments,
45 services,
46 search,
47 ranges = true,
48 children,
49}: {
50 path: string;
51 view: View;
52 environments: string[];
53 services: string[];
54 search?: { placeholder: string; hint?: string };
55 ranges?: boolean;
56 children?: ReactNode;
57}) {
58 const navigate = useNavigate();
59 const go = (change: Record<string, string | null>) => navigate(viewHref(path, view, change), { preventScrollReset: true });
60 const envs = [...new Set([...(view.environment ? [view.environment] : []), ...environments])];
61 const svcs = [...new Set([...(view.service ? [view.service] : []), ...services])];
62 return (
63 <div className="mt-6 flex flex-wrap items-center gap-2">
64 {search && (
65 <Form method="get" action={path} className="min-w-0 flex-1 basis-64" role="search">
66 {/* What else the view says goes with the search. */}
67 {view.range && <input type="hidden" name="range" value={view.range} />}
68 {view.environment && <input type="hidden" name="env" value={view.environment} />}
69 {view.service && <input type="hidden" name="service" value={view.service} />}
70 {view.deployment && <input type="hidden" name="deployment" value={view.deployment} />}
71 <Input
72 name="q"
73 defaultValue={view.q}
74 placeholder={search.placeholder}
75 aria-label={search.hint ?? "Search"}
76 className="h-8 font-mono text-[0.8125rem]"
77 autoComplete="off"
78 spellCheck={false}
79 />
80 </Form>
81 )}
82 {envs.length > 0 && (
83 <SelectField
84 aria-label="Environment"
85 size="sm"
86 className="w-40"
87 value={view.environment ?? "*"}
88 onValueChange={(value) => go({ env: value === "*" ? null : value })}
89 options={[{ value: "*", label: "All environments" }, ...envs.map((e) => ({ value: e, label: e[0]!.toUpperCase() + e.slice(1) }))]}
90 />
91 )}
92 {svcs.length > 1 || view.service ? (
93 <SelectField
94 aria-label="Service"
95 size="sm"
96 className="w-44"
97 value={view.service ?? "*"}
98 onValueChange={(value) => go({ service: value === "*" ? null : value })}
99 options={[{ value: "*", label: "All services" }, ...svcs.map((s) => ({ value: s, label: s }))]}
100 />
101 ) : null}
102 {view.deployment && (
103 <Link
104 to={viewHref(path, view, { deployment: null })}
105 className="inline-flex h-8 items-center gap-1.5 rounded-md border border-accent/40 bg-accent/10 px-2.5 font-mono text-xs text-accent hover:bg-accent/15"
106 aria-label="Show every deployment"
107 >
108 {view.deployment.slice(0, 12)} <span aria-hidden="true">×</span>
109 </Link>
110 )}
111 {children}
112 {ranges && (
113 <nav className="ml-auto flex rounded-md border border-line p-0.5 text-sm" aria-label="Period">
114 {RANGES.map((r) => (
115 <Link
116 key={r.key}
117 to={viewHref(path, view, { range: r.key })}
118 preventScrollReset
119 aria-current={r.key === view.range ? "true" : undefined}
120 aria-label={`The last ${r.label}`}
121 className={cn("rounded px-2 py-0.5 tabular-nums", r.key === view.range ? "bg-raised text-fg" : "text-muted hover:text-fg")}
122 >
123 {r.key}
124 </Link>
125 ))}
126 </nav>
127 )}
128 </div>
129 );
130}
131
132/** Whether the page is loading the next view: the content dims while it does. */
133export function useLoadingView(): boolean {
134 return useNavigation().state === "loading";
135}
136
137// ---- Numbers ---------------------------------------------------------------------
138
139/** A headline number, with how it moved from the period before. */
140export function StatTile({
141 label,
142 value,
143 now,
144 before,
145 lowerIsBetter = false,
146 detail,
147}: {
148 label: string;
149 value: ReactNode;
150 now?: number | null;
151 before?: number | null;
152 lowerIsBetter?: boolean;
153 detail?: ReactNode;
154}) {
155 const moved = now !== undefined && before !== undefined ? changeOf(now, before, lowerIsBetter) : null;
156 return (
157 <Card className="min-w-0 p-4">
158 <p className="text-xs text-muted">{label}</p>
159 <p className="mt-1 text-2xl font-semibold tracking-tight tabular-nums">{value}</p>
160 <p className="mt-1 h-4 text-xs text-faint">
161 {moved ? (
162 <span className={cn("tabular-nums", moved.good === true && "text-success", moved.good === false && "text-danger")}>
163 {moved.text} <span className="text-faint">vs the period before</span>
164 </span>
165 ) : (
166 detail
167 )}
168 </p>
169 </Card>
170 );
171}
172
173// ---- Time charts -----------------------------------------------------------------
174
175export type ChartSeries = { key: string; label: string; color: string; values: (number | null)[] };
176
177const CHART_HEIGHT = 160;
178
179/**
180 * A measure over time on one axis: stacked bars (counts) or lines
181 * (latency). Hovering or focusing a step shows each series' value there;
182 * the legend names every series, and the table under it has the numbers.
183 */
184export function TimeChart({
185 title,
186 kind,
187 times,
188 stepS,
189 series,
190 format,
191 empty = "Nothing in this period.",
192 action,
193}: {
194 title: string;
195 kind: "bars" | "lines";
196 times: number[];
197 stepS: number;
198 series: ChartSeries[];
199 format: (n: number) => string;
200 empty?: string;
201 action?: ReactNode;
202}) {
203 const id = useId();
204 const [at, setAt] = useState<number | null>(null);
205 const totals = times.map((_, i) => series.reduce((n, s) => n + (s.values[i] ?? 0), 0));
206 const peaks = kind === "bars" ? totals : times.map((_, i) => Math.max(0, ...series.map((s) => s.values[i] ?? 0)));
207 const max = Math.max(0, ...peaks);
208 const scale = niceTicks(max);
209 const top = scale[scale.length - 1] || 1;
210 const spanMs = (times.length * stepS * 1000) || 1;
211 const labelEvery = Math.max(1, Math.ceil(times.length / 6));
212 const anything = series.some((s) => s.values.some((v) => v !== null && v > 0));
213 const W = 1000;
214 const x = (i: number) => ((i + 0.5) / Math.max(1, times.length)) * W;
215 const y = (v: number) => CHART_HEIGHT - (v / top) * CHART_HEIGHT;
216
217 return (
218 <Card asChild className="min-w-0 p-4">
219 <section aria-labelledby={`${id}-title`}>
220 <div className="flex flex-wrap items-center justify-between gap-2">
221 <h2 id={`${id}-title`} className="text-sm font-medium">
222 {title}
223 </h2>
224 {action}
225 </div>
226 {series.length > 1 && (
227 <ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted" aria-label="Legend">
228 {series.map((s) => (
229 <li key={s.key} className="flex items-center gap-1.5">
230 <span className={cn(kind === "bars" ? "size-2.5 rounded-sm" : "h-0.5 w-3 rounded-full")} style={{ background: s.color }} />
231 {s.label}
232 </li>
233 ))}
234 </ul>
235 )}
236 {!anything ? (
237 <p className="mt-3 grid place-items-center rounded-md border border-dashed border-line text-sm text-faint" style={{ height: CHART_HEIGHT }}>
238 {empty}
239 </p>
240 ) : (
241 <div className="mt-3 flex gap-2">
242 <div className="relative w-12 shrink-0 text-right text-[0.6875rem] text-faint tabular-nums" style={{ height: CHART_HEIGHT }} aria-hidden="true">
243 {scale.map((t) => (
244 <span key={t} className="absolute right-0 -translate-y-1/2" style={{ top: `${100 - (t / top) * 100}%` }}>
245 {format(t)}
246 </span>
247 ))}
248 </div>
249 <div className="relative min-w-0 flex-1" style={{ height: CHART_HEIGHT }} onMouseLeave={() => setAt(null)}>
250 {scale.map((t) => (
251 <div key={t} className="absolute inset-x-0 border-t border-line/70" style={{ top: `${100 - (t / top) * 100}%` }} aria-hidden="true" />
252 ))}
253 {kind === "lines" && (
254 <svg className="absolute inset-0 h-full w-full overflow-visible" viewBox={`0 0 ${W} ${CHART_HEIGHT}`} preserveAspectRatio="none" aria-hidden="true">
255 {series.map((s) => {
256 // Steps with no requests break the line rather than drop it to zero.
257 const runs: string[] = [];
258 let run: string[] = [];
259 s.values.forEach((v, i) => {
260 if (v === null) {
261 if (run.length) runs.push(run.join(" "));
262 run = [];
263 } else run.push(`${x(i)},${y(v)}`);
264 });
265 if (run.length) runs.push(run.join(" "));
266 return runs.map((points, i) =>
267 points.includes(" ") ? (
268 <polyline key={`${s.key}-${i}`} points={points} fill="none" stroke={s.color} strokeWidth={2} strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
269 ) : (
270 <circle key={`${s.key}-${i}`} cx={points.split(",")[0]} cy={points.split(",")[1]} r={3} fill={s.color} vectorEffect="non-scaling-stroke" />
271 ),
272 );
273 })}
274 </svg>
275 )}
276 <div className="absolute inset-0 flex items-end" role="list" aria-label={`${title} by time`}>
277 {times.map((t, i) => (
278 <div
279 key={t}
280 role="listitem"
281 tabIndex={0}
282 aria-label={`${axisTime(t, spanMs)}: ${series.map((s) => `${s.label} ${s.values[i] === null ? "none" : format(s.values[i]!)}`).join(", ")}`}
283 onMouseEnter={() => setAt(i)}
284 onFocus={() => setAt(i)}
285 onBlur={() => setAt(null)}
286 className="relative flex h-full min-w-0 flex-1 flex-col items-center justify-end outline-none focus-visible:bg-raised/60"
287 >
288 {at === i && <div className="absolute inset-y-0 left-1/2 w-px bg-line-strong" aria-hidden="true" />}
289 {kind === "bars" && totals[i]! > 0 && (
290 <div className="flex w-full max-w-5 flex-col-reverse gap-[2px] px-[1px]" style={{ height: `${(totals[i]! / top) * 100}%` }}>
291 {series
292 .filter((s) => (s.values[i] ?? 0) > 0)
293 .map((s, k, all) => (
294 <div
295 key={s.key}
296 className={cn("w-full min-h-[2px]", k === all.length - 1 && "rounded-t-[3px]")}
297 style={{ flexGrow: s.values[i] ?? 0, flexBasis: 0, background: s.color }}
298 />
299 ))}
300 </div>
301 )}
302 </div>
303 ))}
304 </div>
305 {at !== null && (
306 <div
307 className="pointer-events-none absolute top-0 z-10 min-w-36 rounded-md border border-line bg-bg px-2.5 py-2 text-xs shadow-lg"
308 style={at > times.length / 2 ? { right: `${100 - ((at + 0.5) / times.length) * 100}%`, marginRight: 8 } : { left: `${((at + 0.5) / times.length) * 100}%`, marginLeft: 8 }}
309 role="status"
310 >
311 <p className="font-medium tabular-nums">{axisTime(times[at]!, spanMs)} UTC</p>
312 <ul className="mt-1 space-y-0.5">
313 {series.map((s) => (
314 <li key={s.key} className="flex items-center justify-between gap-3">
315 <span className="flex items-center gap-1.5 text-muted">
316 <span className="size-2 rounded-sm" style={{ background: s.color }} />
317 {s.label}
318 </span>
319 <span className="tabular-nums">{s.values[at] === null ? "—" : format(s.values[at]!)}</span>
320 </li>
321 ))}
322 </ul>
323 </div>
324 )}
325 </div>
326 </div>
327 )}
328 {anything && (
329 <div className="mt-1 flex justify-between pl-14 text-[0.6875rem] text-faint tabular-nums" aria-hidden="true">
330 {times.map((t, i) => (i % labelEvery === 0 ? <span key={t}>{axisTime(t, spanMs)}</span> : null))}
331 </div>
332 )}
333 {anything && (
334 <details className="group mt-2 text-xs">
335 <summary className="flex cursor-pointer list-none items-center gap-1 text-faint hover:text-muted">
336 <ChevronDown size={12} className="transition-transform group-open:rotate-180" />
337 Table
338 </summary>
339 <div className="mt-2 max-h-56 overflow-auto rounded-md border border-line">
340 <table className="w-full tabular-nums">
341 <thead className="sticky top-0 bg-raised text-left text-muted">
342 <tr>
343 <th className="px-2 py-1 font-medium">Time (UTC)</th>
344 {series.map((s) => (
345 <th key={s.key} className="px-2 py-1 text-right font-medium">
346 {s.label}
347 </th>
348 ))}
349 </tr>
350 </thead>
351 <tbody>
352 {times.map((t, i) => (
353 <tr key={t} className="border-t border-line">
354 <td className="px-2 py-1">{new Date(t / 1000).toISOString().slice(0, 16).replace("T", " ")}</td>
355 {series.map((s) => (
356 <td key={s.key} className="px-2 py-1 text-right">
357 {s.values[i] === null ? "—" : format(s.values[i]!)}
358 </td>
359 ))}
360 </tr>
361 ))}
362 </tbody>
363 </table>
364 </div>
365 </details>
366 )}
367 </section>
368 </Card>
369 );
370}
371
372// ---- Badges ----------------------------------------------------------------------
373
374export function StatusBadge({ status, outcome }: { status: number | null; outcome?: string | null }) {
375 const failed = outcome && outcome !== "ok" && outcome !== "canceled" && outcome !== "responseStreamDisconnected";
376 const badge = (
377 <Badge tone={statusTone(status, outcome)} className="font-mono tabular-nums">
378 {status ?? (failed ? "ERR" : "—")}
379 </Badge>
380 );
381 return failed ? <Hint label={`Outcome: ${outcome}`}>{badge}</Hint> : badge;
382}
383
384export function LevelBadge({ level }: { level: LogSeverity }) {
385 return (
386 <Badge tone={LEVEL_TONE[level]} className="w-12 justify-center font-mono uppercase">
387 {level}
388 </Badge>
389 );
390}
391
392/** Where a record came from: an app on g1t.page, or OpenTelemetry. */
393export function SourceBadge({ source }: { source: "cloudflare" | "otlp" }) {
394 return (
395 <Hint label={source === "cloudflare" ? "From the app on g1t.page" : "Sent with OpenTelemetry"}>
396 <Badge tone="neutral">{source === "cloudflare" ? "g1t.page" : "OTLP"}</Badge>
397 </Hint>
398 );
399}
400
401/** The live dot: a page following what arrives as it arrives. */
402export function LiveDot({ on }: { on: boolean }) {
403 return (
404 <span className={cn("inline-flex items-center gap-1.5 text-xs", on ? "text-success" : "text-faint")}>
405 <Radio size={13} className={cn(on && "animate-pulse")} />
406 {on ? "Live" : "Paused"}
407 </span>
408 );
409}
410
411/** A bar of how long something took, against the longest beside it. */
412export function DurationBar({ ms, max, failed }: { ms: number; max: number; failed?: boolean }) {
413 return (
414 <span className="relative block h-1.5 w-full overflow-hidden rounded-full bg-line" aria-hidden="true">
415 <span className={cn("absolute inset-y-0 left-0 rounded-full", failed ? "bg-danger" : "bg-accent")} style={{ width: `${Math.max(2, Math.min(100, (ms / (max || 1)) * 100))}%` }} />
416 </span>
417 );
418}