repos
/ orchard main

orchard

mirror

Every 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

5.5 KB · 181 lines · JavaScript Raw History
  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"));