Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry. | 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 | */ | |
| 6 | import { Activity, ChevronDown, Radio } from "lucide-react"; | |
| 7 | import { type ReactNode, useId, useState } from "react"; | |
| 8 | import { Form, Link, useNavigate, useNavigation } from "react-router"; | |
| 9 | ||
| 10 | import type { LogSeverity } from "@g1t/contracts"; | |
| 11 | ||
| 12 | import { cn } from "../lib/cn"; | |
| 13 | import { RANGES, type View, axisTime, change as changeOf, niceTicks, statusTone, LEVEL_TONE, viewHref } from "../lib/observability"; | |
| 14 | import { Badge } from "./ui/badge"; | |
| 15 | import { Card } from "./ui/card"; | |
| 16 | import { Hint } from "./ui/hint"; | |
| 17 | import { Input } from "./ui/input"; | |
| 18 | import { SelectField } from "./ui/select"; | |
| 19 | ||
| 20 | // ---- Header and filters ------------------------------------------------------ | |
| 21 | ||
| 22 | export 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 | */ | |
| 41 | export 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. */ | |
| 133 | export 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. */ | |
| 140 | export 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 | ||
| 175 | export type ChartSeries = { key: string; label: string; color: string; values: (number | null)[] }; | |
| 176 | ||
| 177 | const 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 | */ | |
| 184 | export 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 | ||
| 374 | export 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 | ||
| 384 | export 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. */ | |
| 393 | export 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. */ | |
| 402 | export 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. */ | |
| 412 | export 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 | } |