Skip to content
115 linesCodeBlameRaw
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 */
7import type { CheckHistory, CheckSample } from "@g1t/contracts";
8
9import { median } from "./incidents.ts";
10
11/** Checks further apart than this are drawn as a gap, not a line across it. */
12const GAP_MS = 3 * 60_000;
13
14export 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". */
33export 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. */
38function niceMax(ms: number): number {
39 const step = ms <= 1000 ? 250 : 500;
40 return Math.max(step, Math.ceil(ms / step) * step);
41}
42
43export 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
79export 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
92export 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". */
108export 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}