orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1import Chart from "chart.js/auto";
2import {
3 applyDefaults,
4 fontStack,
5 ink,
6 palette,
7 series,
8 status,
9 tooltipStyle,
10} from "./chart_theme.js";
11
12const accent = {
13 green: status.good,
14 greenBright: series[0],
15 greenFill: "rgba(87, 179, 120, 0.35)",
16 amber: status.warn,
17 amberFill: "rgba(216, 168, 62, 0.35)",
18 terracotta: status.bad,
19 terracottaFill: "rgba(220, 106, 75, 0.35)",
20 slate: status.info,
21 slateFill: "rgba(99, 169, 201, 0.3)",
22 grid: ink.grid,
23 ticks: ink.ticks,
24};
25
26// Fills for the ranked bars, in the validated order, with the neutral last for
27// anything folded into "other".
28const backgroundColors = [
29 accent.greenFill,
30 accent.amberFill,
31 accent.terracottaFill,
32 accent.slateFill,
33 "rgba(125, 116, 105, 0.35)",
34];
35
36const borderColors = palette;
37
38applyDefaults(Chart);
39
40const tickFont = { size: 11, family: fontStack };
41const legendLabel = { boxWidth: 10, boxHeight: 10, font: tickFont, color: ink.legend };
42
43document.addEventListener("DOMContentLoaded", function () {
44 const canvas = document.getElementById("chart-response-times");
45 if (!canvas) return;
46 const data = JSON.parse(
47 document.getElementById("chart-status-response-times-data").innerHTML
48 );
49 const ctx = canvas.getContext("2d");
50
51 const series = [
52 { key: "total", label: "Total", color: accent.green, width: 2, tension: 0.25 },
53 { key: "dns", label: "DNS", color: accent.terracotta, width: 1.5, tension: 0.2 },
54 { key: "tcp", label: "TCP", color: accent.amber, width: 1.5, tension: 0.2 },
55 { key: "tls", label: "TLS", color: accent.slate, width: 1.5, tension: 0.2 },
56 { key: "ttfb", label: "TTFB", color: "#7d7469", width: 1.5, tension: 0.2 },
57 ];
58
59 const chart = new Chart(ctx, {
60 type: "line",
61 data: {
62 labels: data.map((d) => {
63 const date = new Date(d.label);
64 return `${date.getHours() % 12 || 12}:${date.getMinutes() < 10 ? "0" : ""}${date.getMinutes()} ${date.getHours() >= 12 ? "PM" : "AM"}`;
65 }),
66 datasets: series.map((s) => ({
67 label: s.label,
68 // Older rows have null phase timings, and chart.js draws a null as a
69 // gap in the line, which is what we want here.
70 data: data.map((d) => (d[s.key] == null ? null : d[s.key])),
71 borderColor: s.color,
72 backgroundColor: s.color,
73 borderWidth: s.width,
74 pointRadius: 0,
75 pointHoverRadius: 4,
76 pointHoverBackgroundColor: s.color,
77 tension: s.tension,
78 fill: false,
79 spanGaps: false,
80 })),
81 },
82 options: {
83 responsive: true,
84 maintainAspectRatio: false,
85 animation: { duration: 0 },
86 interaction: { mode: "index", intersect: false },
87 plugins: {
88 tooltip: {
89 ...tooltipStyle,
90 mode: "index",
91 intersect: false,
92 titleFont: tickFont,
93 bodyFont: tickFont,
94 callbacks: {
95 label: (item) =>
96 ` ${item.dataset.label}: ${item.parsed.y == null ? "–" : item.parsed.y + " ms"}`,
97 },
98 },
99 legend: { position: "top", labels: legendLabel },
100 },
101 scales: {
102 x: {
103 grid: { color: accent.grid },
104 border: { display: false },
105 ticks: { autoSkip: true, maxRotation: 25, font: tickFont, color: accent.ticks },
106 },
107 y: {
108 grid: { color: accent.grid },
109 border: { display: false },
110 ticks: {
111 beginAtZero: true,
112 font: tickFont,
113 color: accent.ticks,
114 callback: (value) => `${value} ms`,
115 },
116 },
117 },
118 },
119 });
120 chart.canvas.parentNode.style.width = "100%";
121 chart.canvas.parentNode.style.height = "300px";
122});
123
124function buildDoughnut(canvasId, dataId) {
125 const canvas = document.getElementById(canvasId);
126 if (!canvas) return;
127 const data = JSON.parse(document.getElementById(dataId).innerHTML);
128 const ctx = canvas.getContext("2d");
129
130 // Keyed by name, so 200 and Uptime are always green and a failure is always
131 // terracotta whatever order the server sent the rows in.
132 const paint = (label) => {
133 const name = String(label || "").toLowerCase();
134 if (name === "uptime" || name === "200") return [accent.greenFill, accent.green];
135 if (name === "downtime") return [accent.terracottaFill, accent.terracotta];
136 return null;
137 };
138
139 const bg = data.map((d, i) => {
140 const p = paint(d.label);
141 return p ? p[0] : backgroundColors[i % backgroundColors.length];
142 });
143 const bd = data.map((d, i) => {
144 const p = paint(d.label);
145 return p ? p[1] : borderColors[i % borderColors.length];
146 });
147
148 new Chart(ctx, {
149 type: "doughnut",
150 data: {
151 labels: data.map((d) => String(d.label)),
152 datasets: [
153 {
154 data: data.map((d) => d.count),
155 backgroundColor: bg,
156 borderColor: bd,
157 borderWidth: 1.5,
158 },
159 ],
160 },
161 options: {
162 responsive: true,
163 aspectRatio: 2,
164 animation: { animateRotate: false },
165 cutout: "62%",
166 // Bottom rather than right: these two panels are a third of a row wide,
167 // and a right hand legend leaves so little room that Chart.js truncates
168 // "Downtime" to "Downt".
169 plugins: {
170 legend: { position: "bottom", labels: legendLabel },
171 tooltip: {
172 ...tooltipStyle,
173 },
174 },
175 },
176 });
177}
178
179document.addEventListener("DOMContentLoaded", () => buildDoughnut("chart-status-codes", "chart-status-codes-data"));
180document.addEventListener("DOMContentLoaded", () => buildDoughnut("chart-uptime", "chart-uptime-data"));