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