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 && ( | |
| People wear their own avatars wherever g1t knows them only by name, and a workspace is never shown as a person. The activity feeds, recent changes, home's most active faces, issue and pull request authors, assignees, comments and the audit log drew a person from their username alone, so everyone got their letter avatar instead of the picture they uploaded. An avatar given no picture now draws the letter at once and lays the person's own picture over it once it loads, from /-/avatar/user/<username> (routes/avatar-user.ts): a redirect to the picture on the usercontent origin, or not found, read from identity's people only, so a workspace's icon can never stand in for someone. Kept five minutes, public like the profile. Agents, the product tour's people, "someone" and workspaces' square avatars are never looked up (image null), so an account with the same name never lends them its face. | 290 | <div className="flex w-full max-w-5 flex-col-reverse gap-0.5 px-px" style={{ height: `${(totals[i]! / top) * 100}%` }}> |
| 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. | 291 | {series |
| 292 | .filter((s) => (s.values[i] ?? 0) > 0) | |
| 293 | .map((s, k, all) => ( | |
| 294 | <div | |
| 295 | key={s.key} | |
| People wear their own avatars wherever g1t knows them only by name, and a workspace is never shown as a person. The activity feeds, recent changes, home's most active faces, issue and pull request authors, assignees, comments and the audit log drew a person from their username alone, so everyone got their letter avatar instead of the picture they uploaded. An avatar given no picture now draws the letter at once and lays the person's own picture over it once it loads, from /-/avatar/user/<username> (routes/avatar-user.ts): a redirect to the picture on the usercontent origin, or not found, read from identity's people only, so a workspace's icon can never stand in for someone. Kept five minutes, public like the profile. Agents, the product tour's people, "someone" and workspaces' square avatars are never looked up (image null), so an account with the same name never lends them its face. | 296 | className={cn("w-full min-h-0.5", k === all.length - 1 && "rounded-t-[3px]")} |
| 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. | 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 | } |