Single-binary self-hosted uptime monitoring and status pages on Rust axum: HTTP probes, Lighthouse audits, SEO crawler, and PDF reports.
axumdockerrustself-hostedsqlitestatus-pageuptime-monitoringvite
1import Chart from "chart.js/auto";
2
3const accent = {
4 green: "#6b9e78",
5 greenBright: "#7db88c",
6 greenFill: "rgba(107, 158, 120, 0.35)",
7 amber: "#c9a84c",
8 amberFill: "rgba(201, 168, 76, 0.35)",
9 terracotta: "#c47055",
10 terracottaFill: "rgba(196, 112, 85, 0.35)",
11 slate: "#7eaab8",
12 slateFill: "rgba(126, 170, 184, 0.3)",
13 grid: "rgba(107, 158, 120, 0.08)",
14 ticks: "#847c72",
15};
16
17const backgroundColors = [
18 accent.greenFill,
19 accent.terracottaFill,
20 accent.amberFill,
21 accent.slateFill,
22 "rgba(221, 215, 205, 0.18)",
23 "rgba(160, 152, 144, 0.3)",
24 "rgba(107, 158, 120, 0.55)",
25 "rgba(196, 112, 85, 0.55)",
26 "rgba(201, 168, 76, 0.55)",
27 "rgba(126, 170, 184, 0.55)",
28];
29
30const borderColors = [
31 "rgba(107, 158, 120, 0.9)",
32 "rgba(196, 112, 85, 0.9)",
33 "rgba(201, 168, 76, 0.9)",
34 "rgba(126, 170, 184, 0.9)",
35 "rgba(221, 215, 205, 0.5)",
36 "rgba(160, 152, 144, 0.7)",
37 "rgba(107, 158, 120, 1)",
38 "rgba(196, 112, 85, 1)",
39 "rgba(201, 168, 76, 1)",
40 "rgba(126, 170, 184, 1)",
41];
42
43const fontStack = '"Monaspace Argon", Consolas, "Liberation Mono", Monaco, "Courier New", monospace';
44
45Chart.defaults.color = accent.ticks;
46Chart.defaults.borderColor = accent.grid;
47Chart.defaults.font.family = fontStack;
48Chart.defaults.font.size = 11;
49
50const tickFont = { size: 11, family: fontStack };
51const legendLabel = { boxWidth: 10, boxHeight: 10, font: tickFont, color: "#d1d8d4" };
52
53document.addEventListener("DOMContentLoaded", function () {
54 const canvas = document.getElementById("chart-response-times");
55 if (!canvas) return;
56 const data = JSON.parse(
57 document.getElementById("chart-status-response-times-data").innerHTML
58 );
59 const ctx = canvas.getContext("2d");
60
61 // Total stays the loud green line so old-eye muscle memory still works.
62 // The four phase lines are thinner and more muted so they read as
63 // breakdown, not as five equal-weight series.
64 const series = [
65 { key: "total", label: "Total", color: accent.green, width: 2, tension: 0.25 },
66 { key: "dns", label: "DNS", color: accent.terracotta, width: 1.5, tension: 0.2 },
67 { key: "tcp", label: "TCP", color: accent.amber, width: 1.5, tension: 0.2 },
68 { key: "tls", label: "TLS", color: accent.slate, width: 1.5, tension: 0.2 },
69 { key: "ttfb", label: "TTFB", color: "#a09890", width: 1.5, tension: 0.2 },
70 ];
71
72 const chart = new Chart(ctx, {
73 type: "line",
74 data: {
75 labels: data.map((d) => {
76 const date = new Date(d.label);
77 return `${date.getHours() % 12 || 12}:${date.getMinutes() < 10 ? "0" : ""}${date.getMinutes()} ${date.getHours() >= 12 ? "PM" : "AM"}`;
78 }),
79 datasets: series.map((s) => ({
80 label: s.label,
81 // Older rows (pre-migration-0002) have null phase timings — chart.js
82 // treats null as a gap in the line, which is exactly what we want.
83 data: data.map((d) => (d[s.key] == null ? null : d[s.key])),
84 borderColor: s.color,
85 backgroundColor: s.color,
86 borderWidth: s.width,
87 pointRadius: 0,
88 pointHoverRadius: 4,
89 pointHoverBackgroundColor: s.color,
90 tension: s.tension,
91 fill: false,
92 spanGaps: false,
93 })),
94 },
95 options: {
96 responsive: true,
97 maintainAspectRatio: false,
98 animation: { duration: 0 },
99 interaction: { mode: "index", intersect: false },
100 plugins: {
101 tooltip: {
102 mode: "index",
103 intersect: false,
104 backgroundColor: "rgba(9, 8, 6, 0.95)",
105 titleColor: "#ede8e0",
106 bodyColor: "#ddd7cd",
107 borderColor: "rgba(107, 158, 120, 0.2)",
108 borderWidth: 1,
109 padding: 10,
110 titleFont: tickFont,
111 bodyFont: tickFont,
112 callbacks: {
113 label: (item) =>
114 ` ${item.dataset.label}: ${item.parsed.y == null ? "–" : item.parsed.y + " ms"}`,
115 },
116 },
117 legend: { position: "top", labels: legendLabel },
118 },
119 scales: {
120 x: {
121 grid: { color: accent.grid },
122 border: { display: false },
123 ticks: { autoSkip: true, maxRotation: 25, font: tickFont, color: accent.ticks },
124 },
125 y: {
126 grid: { color: accent.grid },
127 border: { display: false },
128 ticks: {
129 beginAtZero: true,
130 font: tickFont,
131 color: accent.ticks,
132 callback: (value) => `${value} ms`,
133 },
134 },
135 },
136 },
137 });
138 chart.canvas.parentNode.style.width = "100%";
139 chart.canvas.parentNode.style.height = "300px";
140});
141
142function buildDoughnut(canvasId, dataId) {
143 const canvas = document.getElementById(canvasId);
144 if (!canvas) return;
145 const data = JSON.parse(document.getElementById(dataId).innerHTML);
146 const ctx = canvas.getContext("2d");
147
148 // Color rule: status 200 / Uptime = moss green, non-200 / Downtime =
149 // terracotta, everything else pulls from the earthy palette in order.
150 const paint = (label) => {
151 const name = String(label || "").toLowerCase();
152 if (name === "uptime" || name === "200") return [accent.greenFill, "rgba(107, 158, 120, 0.95)"];
153 if (name === "downtime") return [accent.terracottaFill, "rgba(196, 112, 85, 0.95)"];
154 return null;
155 };
156
157 const bg = data.map((d, i) => {
158 const p = paint(d.label);
159 return p ? p[0] : backgroundColors[i % backgroundColors.length];
160 });
161 const bd = data.map((d, i) => {
162 const p = paint(d.label);
163 return p ? p[1] : borderColors[i % borderColors.length];
164 });
165
166 new Chart(ctx, {
167 type: "doughnut",
168 data: {
169 labels: data.map((d) => String(d.label)),
170 datasets: [
171 {
172 data: data.map((d) => d.count),
173 backgroundColor: bg,
174 borderColor: bd,
175 borderWidth: 1.5,
176 },
177 ],
178 },
179 options: {
180 responsive: true,
181 aspectRatio: 2,
182 animation: { animateRotate: false },
183 cutout: "62%",
184 plugins: {
185 legend: { position: "right", labels: legendLabel },
186 tooltip: {
187 backgroundColor: "rgba(9, 8, 6, 0.95)",
188 titleColor: "#ede8e0",
189 bodyColor: "#ddd7cd",
190 borderColor: "rgba(107, 158, 120, 0.2)",
191 borderWidth: 1,
192 padding: 10,
193 titleFont: tickFont,
194 bodyFont: tickFont,
195 },
196 },
197 },
198 });
199}
200
201document.addEventListener("DOMContentLoaded", () => buildDoughnut("chart-status-codes", "chart-status-codes-data"));
202document.addEventListener("DOMContentLoaded", () => buildDoughnut("chart-uptime", "chart-uptime-data"));