Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

445 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
255 className="absolute inset-0 h-full w-full overflow-visible"
256 viewBox={`0 0 ${W} ${CHART_HEIGHT}`}
257 preserveAspectRatio="none"
258 aria-hidden="true"
259 >
260 {series.map((s) => {
261 // Steps with no requests break the line rather than drop it to zero.
262 const runs: string[] = [];
263 let run: string[] = [];
264 s.values.forEach((v, i) => {
265 if (v === null) {
266 if (run.length) runs.push(run.join(" "));
267 run = [];
268 } else run.push(`${x(i)},${y(v)}`);
269 });
270 if (run.length) runs.push(run.join(" "));
271 return runs.map((points, i) =>
272 points.includes(" ") ? (
273 <polyline
274 key={`${s.key}-${i}`}
275 points={points}
276 fill="none"
277 stroke={s.color}
278 strokeWidth={2}
279 strokeLinejoin="round"
280 vectorEffect="non-scaling-stroke"
281 />
282 ) : (
283 <circle
284 key={`${s.key}-${i}`}
285 cx={points.split(",")[0]}
286 cy={points.split(",")[1]}
287 r={3}
288 fill={s.color}
289 vectorEffect="non-scaling-stroke"
290 />
291 ),
292 );
293 })}
294 </svg>
295 )}
296 <div className="absolute inset-0 flex items-end" role="list" aria-label={`${title} by time`}>
297 {times.map((t, i) => (
298 <div
299 key={t}
300 role="listitem"
301 tabIndex={0}
302 aria-label={`${axisTime(t, spanMs)}: ${series.map((s) => `${s.label} ${s.values[i] === null ? "none" : format(s.values[i]!)}`).join(", ")}`}
303 onMouseEnter={() => setAt(i)}
304 onFocus={() => setAt(i)}
305 onBlur={() => setAt(null)}
306 className="relative flex h-full min-w-0 flex-1 flex-col items-center justify-end outline-none focus-visible:bg-raised/60"
307 >
308 {at === i && <div className="absolute inset-y-0 left-1/2 w-px bg-line-strong" aria-hidden="true" />}
309 {kind === "bars" && totals[i]! > 0 && (
310 <div className="flex w-full max-w-5 flex-col-reverse gap-0.5 px-px" style={{ height: `${(totals[i]! / top) * 100}%` }}>
311 {series
312 .filter((s) => (s.values[i] ?? 0) > 0)
313 .map((s, k, all) => (
314 <div
315 key={s.key}
316 className={cn("w-full min-h-0.5", k === all.length - 1 && "rounded-t-[3px]")}
317 style={{ flexGrow: s.values[i] ?? 0, flexBasis: 0, background: s.color }}
318 />
319 ))}
320 </div>
321 )}
322 </div>
323 ))}
324 </div>
325 {at !== null && (
326 <div
327 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"
328 style={
329 at > times.length / 2
330 ? { right: `${100 - ((at + 0.5) / times.length) * 100}%`, marginRight: 8 }
331 : { left: `${((at + 0.5) / times.length) * 100}%`, marginLeft: 8 }
332 }
333 role="status"
334 >
335 <p className="font-medium tabular-nums">{axisTime(times[at]!, spanMs)} UTC</p>
336 <ul className="mt-1 space-y-0.5">
337 {series.map((s) => (
338 <li key={s.key} className="flex items-center justify-between gap-3">
339 <span className="flex items-center gap-1.5 text-muted">
340 <span className="size-2 rounded-sm" style={{ background: s.color }} />
341 {s.label}
342 </span>
343 <span className="tabular-nums">{s.values[at] === null ? "—" : format(s.values[at]!)}</span>
344 </li>
345 ))}
346 </ul>
347 </div>
348 )}
349 </div>
350 </div>
351 )}
352 {anything && (
353 <div className="mt-1 flex justify-between pl-14 text-[0.6875rem] text-faint tabular-nums" aria-hidden="true">
354 {times.map((t, i) => (i % labelEvery === 0 ? <span key={t}>{axisTime(t, spanMs)}</span> : null))}
355 </div>
356 )}
357 {anything && (
358 <details className="group mt-2 text-xs">
359 <summary className="flex cursor-pointer list-none items-center gap-1 text-faint hover:text-muted">
360 <ChevronDown size={12} className="transition-transform group-open:rotate-180" />
361 Table
362 </summary>
363 <div className="mt-2 max-h-56 overflow-auto rounded-md border border-line">
364 <table className="w-full tabular-nums">
365 <thead className="sticky top-0 bg-raised text-left text-muted">
366 <tr>
367 <th className="px-2 py-1 font-medium">Time (UTC)</th>
368 {series.map((s) => (
369 <th key={s.key} className="px-2 py-1 text-right font-medium">
370 {s.label}
371 </th>
372 ))}
373 </tr>
374 </thead>
375 <tbody>
376 {times.map((t, i) => (
377 <tr key={t} className="border-t border-line">
378 <td className="px-2 py-1">{new Date(t / 1000).toISOString().slice(0, 16).replace("T", " ")}</td>
379 {series.map((s) => (
380 <td key={s.key} className="px-2 py-1 text-right">
381 {s.values[i] === null ? "—" : format(s.values[i]!)}
382 </td>
383 ))}
384 </tr>
385 ))}
386 </tbody>
387 </table>
388 </div>
389 </details>
390 )}
391 </section>
392 </Card>
393 );
394}
395
396// ---- Badges ----------------------------------------------------------------------
397
398export function StatusBadge({ status, outcome }: { status: number | null; outcome?: string | null }) {
399 const failed = outcome && outcome !== "ok" && outcome !== "canceled" && outcome !== "responseStreamDisconnected";
400 const badge = (
401 <Badge tone={statusTone(status, outcome)} className="font-mono tabular-nums">
402 {status ?? (failed ? "ERR" : "—")}
403 </Badge>
404 );
405 return failed ? <Hint label={`Outcome: ${outcome}`}>{badge}</Hint> : badge;
406}
407
408export function LevelBadge({ level }: { level: LogSeverity }) {
409 return (
410 <Badge tone={LEVEL_TONE[level]} className="w-12 justify-center font-mono uppercase">
411 {level}
412 </Badge>
413 );
414}
415
416/** Where a record came from: an app on g1t.page, or OpenTelemetry. */
417export function SourceBadge({ source }: { source: "cloudflare" | "otlp" }) {
418 return (
419 <Hint label={source === "cloudflare" ? "From the app on g1t.page" : "Sent with OpenTelemetry"}>
420 <Badge tone="neutral">{source === "cloudflare" ? "g1t.page" : "OTLP"}</Badge>
421 </Hint>
422 );
423}
424
425/** The live dot: a page following what arrives as it arrives. */
426export function LiveDot({ on }: { on: boolean }) {
427 return (
428 <span className={cn("inline-flex items-center gap-1.5 text-xs", on ? "text-success" : "text-faint")}>
429 <Radio size={13} className={cn(on && "animate-pulse")} />
430 {on ? "Live" : "Paused"}
431 </span>
432 );
433}
434
435/** A bar of how long something took, against the longest beside it. */
436export function DurationBar({ ms, max, failed }: { ms: number; max: number; failed?: boolean }) {
437 return (
438 <span className="relative block h-1.5 w-full overflow-hidden rounded-full bg-line" aria-hidden="true">
439 <span
440 className={cn("absolute inset-y-0 left-0 rounded-full", failed ? "bg-danger" : "bg-accent")}
441 style={{ width: `${Math.max(2, Math.min(100, (ms / (max || 1)) * 100))}%` }}
442 />
443 </span>
444 );
445}