| 1 | /** |
| 2 | * An incident's parts, check by check: a latency chart per part, drawn as |
| 3 | * SVG on the server, with the slow line, slow checks dotted and failures |
| 4 | * marked along the top, and the latest checks as a table under it. No |
| 5 | * script and no inline style (sudo ships no JavaScript); the table is the |
| 6 | * way to read exact figures. |
| 7 | */ |
| 8 | import type { CheckHistory } from "@g1t/contracts"; |
| 9 | |
| 10 | import { When } from "~/components/ui"; |
| 11 | import { latencyPlot, latencySummary, msWords, summaryWords } from "~/lib/latency"; |
| 12 | |
| 13 | /** How many of the latest checks the table lists. */ |
| 14 | const LISTED = 30; |
| 15 | |
| 16 | const OUTCOME = { up: "OK", degraded: "Slow", down: "Not answering" } as const; |
| 17 | |
| 18 | function Chart({ history, name }: { history: CheckHistory; name: string }) { |
| 19 | const p = latencyPlot(history); |
| 20 | return ( |
| 21 | <svg viewBox={`0 0 ${p.width} ${p.height}`} className="block h-auto w-full" role="img" aria-label={`${name}: how long each check took, ${summaryWords(latencySummary(history.samples))}`}> |
| 22 | {p.ticks.map((t) => ( |
| 23 | <g key={t.label}> |
| 24 | <line x1={p.plot.x} x2={p.plot.x + p.plot.width} y1={t.y} y2={t.y} stroke="var(--g1t-line)" strokeWidth="1" strokeDasharray={t.label === "0" ? undefined : "2 4"} /> |
| 25 | <text x={p.plot.x - 6} y={t.y + 3.5} textAnchor="end" fontSize="10" className="fill-faint tabular"> |
| 26 | {t.label} |
| 27 | </text> |
| 28 | </g> |
| 29 | ))} |
| 30 | {p.slowY != null && ( |
| 31 | <g> |
| 32 | <line x1={p.plot.x} x2={p.plot.x + p.plot.width} y1={p.slowY} y2={p.slowY} stroke="var(--g1t-warn)" strokeWidth="1" strokeDasharray="4 3" /> |
| 33 | <text x={p.plot.x + p.plot.width} y={p.slowY - 3} textAnchor="end" fontSize="10" className="fill-warn"> |
| 34 | slow over {msWords(history.slow_ms!)} |
| 35 | </text> |
| 36 | </g> |
| 37 | )} |
| 38 | {p.down.map((d, i) => ( |
| 39 | <rect key={`d${i}`} x={d.x - 1} y={p.plot.y} width="2" height={p.plot.height} className="fill-danger/35" /> |
| 40 | ))} |
| 41 | <path d={p.line} fill="none" stroke="var(--g1t-accent)" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" /> |
| 42 | {p.slow.map((s, i) => ( |
| 43 | <circle key={`s${i}`} cx={s.x} cy={s.y} r="2.5" className="fill-warn" /> |
| 44 | ))} |
| 45 | <text x={p.plot.x} y={p.height - 4} fontSize="10" className="fill-faint"> |
| 46 | {new Date(history.from).toISOString().slice(11, 16)} UTC |
| 47 | </text> |
| 48 | <text x={p.plot.x + p.plot.width} y={p.height - 4} textAnchor="end" fontSize="10" className="fill-faint"> |
| 49 | {new Date(history.to).toISOString().slice(11, 16)} UTC |
| 50 | </text> |
| 51 | </svg> |
| 52 | ); |
| 53 | } |
| 54 | |
| 55 | function Part({ history, name }: { history: CheckHistory; name: string }) { |
| 56 | const summary = latencySummary(history.samples); |
| 57 | const latest = history.samples.slice(-LISTED).reverse(); |
| 58 | return ( |
| 59 | <div className="space-y-2"> |
| 60 | <div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5"> |
| 61 | <h3 className="text-sm font-medium">{name}</h3> |
| 62 | {summary.checks > 0 && <p className="text-xs text-faint">{summaryWords(summary)}</p>} |
| 63 | </div> |
| 64 | {history.samples.length === 0 ? ( |
| 65 | <p className="text-sm text-muted">No checks kept for this span: checks are kept for 7 days.</p> |
| 66 | ) : ( |
| 67 | <> |
| 68 | <Chart history={history} name={name} /> |
| 69 | <details className="group"> |
| 70 | <summary className="cursor-pointer text-xs text-muted select-none hover:text-fg"> |
| 71 | The latest {Math.min(LISTED, history.samples.length)} checks |
| 72 | </summary> |
| 73 | <div className="mt-2 overflow-x-auto"> |
| 74 | <table className="w-full text-left text-xs tabular"> |
| 75 | <thead className="text-faint"> |
| 76 | <tr> |
| 77 | <th className="py-1 pr-3 font-normal">At</th> |
| 78 | <th className="py-1 pr-3 font-normal">Took</th> |
| 79 | <th className="py-1 pr-3 font-normal">Result</th> |
| 80 | <th className="py-1 pr-3 font-normal">From</th> |
| 81 | <th className="py-1 font-normal">First try</th> |
| 82 | </tr> |
| 83 | </thead> |
| 84 | <tbody> |
| 85 | {latest.map((s) => ( |
| 86 | <tr key={s.at} className="border-t border-line"> |
| 87 | <td className="py-1 pr-3 whitespace-nowrap"> |
| 88 | <When at={s.at} time /> |
| 89 | </td> |
| 90 | <td className="py-1 pr-3">{s.ms != null ? msWords(s.ms) : "—"}</td> |
| 91 | <td className={`py-1 pr-3 ${s.outcome === "down" ? "text-danger" : s.outcome === "degraded" ? "text-warn" : "text-muted"}`}>{OUTCOME[s.outcome]}</td> |
| 92 | <td className="py-1 pr-3 font-mono">{s.colo ?? "—"}</td> |
| 93 | <td className="py-1 text-faint">{s.first_ms != null ? `${msWords(s.first_ms)}, asked again` : ""}</td> |
| 94 | </tr> |
| 95 | ))} |
| 96 | </tbody> |
| 97 | </table> |
| 98 | </div> |
| 99 | </details> |
| 100 | </> |
| 101 | )} |
| 102 | </div> |
| 103 | ); |
| 104 | } |
| 105 | |
| 106 | /** Every part's checks around an incident. */ |
| 107 | export function IncidentChecks({ checks, names }: { checks: CheckHistory[]; names: Map<string, string> }) { |
| 108 | return ( |
| 109 | <div className="space-y-5"> |
| 110 | {checks.map((h) => ( |
| 111 | <Part key={h.key} history={h} name={names.get(h.key) ?? h.key} /> |
| 112 | ))} |
| 113 | </div> |
| 114 | ); |
| 115 | } |