| 1 | /** |
| 2 | * An incident's parts, check by check, as the status worker keeps them |
| 3 | * (7 days, `AdminIncidentDetail.checks`): the layout of the latency chart |
| 4 | * on the incident page, and the words over it. Drawn on the server as SVG; |
| 5 | * nothing here runs in the browser. |
| 6 | */ |
| 7 | import type { CheckHistory, CheckSample } from "@g1t/contracts"; |
| 8 | |
| 9 | import { median } from "./incidents.ts"; |
| 10 | |
| 11 | /** Checks further apart than this are drawn as a gap, not a line across it. */ |
| 12 | const GAP_MS = 3 * 60_000; |
| 13 | |
| 14 | export type LatencyPlot = { |
| 15 | width: number; |
| 16 | height: number; |
| 17 | plot: { x: number; y: number; width: number; height: number }; |
| 18 | /** The top of the scale, in milliseconds. */ |
| 19 | max: number; |
| 20 | /** The answered checks as one path; a check that failed, or a gap, breaks it. */ |
| 21 | line: string; |
| 22 | /** Where the slow line is; null when the part's limit is not known. */ |
| 23 | slowY: number | null; |
| 24 | /** Slow checks, as dots on the line. */ |
| 25 | slow: { x: number; y: number }[]; |
| 26 | /** Checks that failed, as marks along the top. */ |
| 27 | down: { x: number }[]; |
| 28 | /** Gridlines, with their label. */ |
| 29 | ticks: { y: number; label: string }[]; |
| 30 | }; |
| 31 | |
| 32 | /** "800 ms", "1.5 s". */ |
| 33 | export function msWords(ms: number): string { |
| 34 | return ms >= 1000 ? `${Number((ms / 1000).toFixed(1))} s` : `${Math.round(ms)} ms`; |
| 35 | } |
| 36 | |
| 37 | /** A round top for the scale: the next 250 ms under a second, then the next half second. */ |
| 38 | function niceMax(ms: number): number { |
| 39 | const step = ms <= 1000 ? 250 : 500; |
| 40 | return Math.max(step, Math.ceil(ms / step) * step); |
| 41 | } |
| 42 | |
| 43 | export function latencyPlot(history: CheckHistory, { width = 640, height = 120 } = {}): LatencyPlot { |
| 44 | const plot = { x: 44, y: 8, width: width - 52, height: height - 26 }; |
| 45 | const answered = history.samples.filter((s) => s.outcome !== "down" && s.ms != null); |
| 46 | const top = Math.max(history.slow_ms != null ? history.slow_ms * 1.5 : 0, ...answered.map((s) => s.ms!), 1); |
| 47 | const max = niceMax(top); |
| 48 | const from = Date.parse(history.from); |
| 49 | const span = Math.max(1, Date.parse(history.to) - from); |
| 50 | const xOf = (at: string) => plot.x + (Math.min(Math.max(Date.parse(at) - from, 0), span) / span) * plot.width; |
| 51 | const yOf = (ms: number) => plot.y + plot.height - (Math.min(ms, max) / max) * plot.height; |
| 52 | const round = (n: number) => Math.round(n * 10) / 10; |
| 53 | |
| 54 | let line = ""; |
| 55 | let last: CheckSample | null = null; |
| 56 | for (const s of history.samples) { |
| 57 | if (s.outcome === "down" || s.ms == null) { |
| 58 | last = null; |
| 59 | continue; |
| 60 | } |
| 61 | const joined = last != null && Date.parse(s.at) - Date.parse(last.at) <= GAP_MS; |
| 62 | line += `${joined ? "L" : "M"}${round(xOf(s.at))} ${round(yOf(s.ms))}`; |
| 63 | last = s; |
| 64 | } |
| 65 | const ticks = [0, max / 2, max].map((v) => ({ y: round(yOf(v)), label: v === 0 ? "0" : msWords(v) })); |
| 66 | return { |
| 67 | width, |
| 68 | height, |
| 69 | plot, |
| 70 | max, |
| 71 | line, |
| 72 | slowY: history.slow_ms != null ? round(yOf(history.slow_ms)) : null, |
| 73 | slow: history.samples.filter((s) => s.outcome === "degraded" && s.ms != null).map((s) => ({ x: round(xOf(s.at)), y: round(yOf(s.ms!)) })), |
| 74 | down: history.samples.filter((s) => s.outcome === "down").map((s) => ({ x: round(xOf(s.at)) })), |
| 75 | ticks, |
| 76 | }; |
| 77 | } |
| 78 | |
| 79 | export type LatencySummary = { |
| 80 | checks: number; |
| 81 | slow: number; |
| 82 | down: number; |
| 83 | /** Of the checks that answered. */ |
| 84 | median_ms: number | null; |
| 85 | slowest_ms: number | null; |
| 86 | /** Slow at first and asked again at once. */ |
| 87 | asked_again: number; |
| 88 | /** Where the checks ran from, most first. */ |
| 89 | colos: string[]; |
| 90 | }; |
| 91 | |
| 92 | export function latencySummary(samples: CheckSample[]): LatencySummary { |
| 93 | const answered = samples.filter((s) => s.outcome !== "down" && s.ms != null).map((s) => s.ms!); |
| 94 | const colos = new Map<string, number>(); |
| 95 | for (const s of samples) if (s.colo) colos.set(s.colo, (colos.get(s.colo) ?? 0) + 1); |
| 96 | return { |
| 97 | checks: samples.length, |
| 98 | slow: samples.filter((s) => s.outcome === "degraded").length, |
| 99 | down: samples.filter((s) => s.outcome === "down").length, |
| 100 | median_ms: median(answered), |
| 101 | slowest_ms: answered.length ? Math.max(...answered) : null, |
| 102 | asked_again: samples.filter((s) => s.first_ms != null).length, |
| 103 | colos: [...colos].sort((a, b) => b[1] - a[1]).map(([c]) => c), |
| 104 | }; |
| 105 | } |
| 106 | |
| 107 | /** The summary in a line: "62 checks · 14 slow · 2 not answering · median 640 ms · slowest 2.4 s · from IAD". */ |
| 108 | export function summaryWords(s: LatencySummary): string { |
| 109 | const parts = [`${s.checks} check${s.checks === 1 ? "" : "s"}`, `${s.slow} slow`, `${s.down} not answering`]; |
| 110 | if (s.median_ms != null) parts.push(`median ${msWords(s.median_ms)}`); |
| 111 | if (s.slowest_ms != null) parts.push(`slowest ${msWords(s.slowest_ms)}`); |
| 112 | if (s.asked_again) parts.push(`${s.asked_again} asked again`); |
| 113 | if (s.colos.length) parts.push(`from ${s.colos.join(", ")}`); |
| 114 | return parts.join(" · "); |
| 115 | } |