2026-09-15 19:30:08 +09:00
|
|
|
|
import { decode } from "/vendor/msgpack/index.mjs";
|
|
|
|
|
|
|
|
|
|
|
|
const target = document.querySelector("#target");
|
|
|
|
|
|
const day = document.querySelector("#day");
|
|
|
|
|
|
const detail = document.querySelector("#detail");
|
|
|
|
|
|
const observed = document.querySelector("#observed");
|
|
|
|
|
|
|
|
|
|
|
|
const percent = (value) =>
|
|
|
|
|
|
value === null ? "Unknown" : `${(value * 100).toFixed(2)}% healthy`;
|
|
|
|
|
|
|
|
|
|
|
|
function state(value) {
|
|
|
|
|
|
switch (value) {
|
|
|
|
|
|
case null:
|
|
|
|
|
|
return "unknown";
|
|
|
|
|
|
case 1:
|
|
|
|
|
|
return "good";
|
|
|
|
|
|
case 0:
|
|
|
|
|
|
return "down";
|
|
|
|
|
|
default:
|
|
|
|
|
|
return "mixed";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function load() {
|
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
|
document.querySelector("script[data-api]").dataset.api,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return response.ok ? decode(await response.arrayBuffer()) : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderRow(record, recordIndex, dates, select) {
|
|
|
|
|
|
const row = document.createElement("article");
|
|
|
|
|
|
row.className = "health-row";
|
|
|
|
|
|
|
|
|
|
|
|
const title = document.createElement("h3");
|
|
|
|
|
|
title.textContent = record.name;
|
|
|
|
|
|
|
|
|
|
|
|
const bars = document.createElement("div");
|
|
|
|
|
|
bars.className = "bars";
|
|
|
|
|
|
bars.setAttribute("role", "group");
|
|
|
|
|
|
bars.setAttribute("aria-label", `${record.name}, daily history`);
|
|
|
|
|
|
|
|
|
|
|
|
for (const [dayIndex, value] of record.values.entries()) {
|
|
|
|
|
|
const button = document.createElement("button");
|
|
|
|
|
|
button.type = "button";
|
|
|
|
|
|
button.className = `bar ${state(value)}`;
|
|
|
|
|
|
button.title = `${dates[dayIndex]}: ${percent(value)}`;
|
|
|
|
|
|
button.setAttribute("aria-label", `${record.name} · ${button.title}`);
|
|
|
|
|
|
button.setAttribute("aria-pressed", "false");
|
|
|
|
|
|
button.dataset.target = String(recordIndex);
|
|
|
|
|
|
button.dataset.day = String(dayIndex);
|
|
|
|
|
|
|
|
|
|
|
|
button.addEventListener("click", () => {
|
|
|
|
|
|
target.value = String(recordIndex);
|
|
|
|
|
|
day.value = String(dayIndex);
|
|
|
|
|
|
select();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
bars.append(button);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
row.append(title, bars);
|
|
|
|
|
|
return row;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderGroup(group, groupIndex, records, dates, select) {
|
2026-09-16 18:44:13 +09:00
|
|
|
|
const [header, names, values] = group;
|
2026-09-15 19:30:08 +09:00
|
|
|
|
const section = document.createElement("section");
|
|
|
|
|
|
section.className = "health-group";
|
|
|
|
|
|
|
|
|
|
|
|
const heading = document.createElement("h2");
|
|
|
|
|
|
heading.id = `group-${groupIndex}`;
|
|
|
|
|
|
heading.textContent = `${String(groupIndex + 1).padStart(2, "0")} ${header}`;
|
|
|
|
|
|
section.setAttribute("aria-labelledby", heading.id);
|
|
|
|
|
|
section.append(heading);
|
|
|
|
|
|
|
2026-09-16 18:44:13 +09:00
|
|
|
|
for (const [seriesIndex, name] of names.entries()) {
|
2026-09-15 19:30:08 +09:00
|
|
|
|
const record = { name, values: values[seriesIndex] };
|
|
|
|
|
|
const recordIndex = records.length;
|
|
|
|
|
|
|
|
|
|
|
|
records.push(record);
|
|
|
|
|
|
target.add(new Option(`${header} · ${name}`, String(recordIndex)));
|
|
|
|
|
|
section.append(renderRow(record, recordIndex, dates, select));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return section;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function render([start, groups]) {
|
|
|
|
|
|
const dates = Array.from({ length: 30 }, (_, index) =>
|
|
|
|
|
|
new Date((start + index * 86400) * 1000).toISOString().slice(0, 10),
|
|
|
|
|
|
);
|
|
|
|
|
|
observed.textContent = `${dates[0]} – ${dates.at(-1)} · UTC`;
|
|
|
|
|
|
|
|
|
|
|
|
const records = [];
|
|
|
|
|
|
const select = () => {
|
|
|
|
|
|
const record = records[Number(target.value)];
|
|
|
|
|
|
const dayIndex = Number(day.value);
|
|
|
|
|
|
detail.textContent = `${record.name} · ${dates[dayIndex]}: ${percent(record.values[dayIndex])}.`;
|
|
|
|
|
|
|
|
|
|
|
|
for (const bar of document.querySelectorAll(".bar")) {
|
|
|
|
|
|
const selected =
|
|
|
|
|
|
bar.dataset.target === target.value && bar.dataset.day === day.value;
|
|
|
|
|
|
bar.setAttribute("aria-pressed", String(selected));
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
for (const [index, date] of dates.entries()) {
|
|
|
|
|
|
day.add(new Option(date, String(index)));
|
|
|
|
|
|
}
|
|
|
|
|
|
day.value = "29";
|
|
|
|
|
|
|
|
|
|
|
|
groups.sort((left, right) => left[1].length - right[1].length);
|
|
|
|
|
|
const container = document.querySelector("#groups");
|
|
|
|
|
|
|
|
|
|
|
|
for (const [groupIndex, group] of groups.entries()) {
|
|
|
|
|
|
container.append(renderGroup(group, groupIndex, records, dates, select));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (records.length) {
|
|
|
|
|
|
document.querySelector(".controls").hidden = false;
|
|
|
|
|
|
target.addEventListener("change", select);
|
|
|
|
|
|
day.addEventListener("change", select);
|
|
|
|
|
|
select();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
observed.textContent = "No data";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let snapshot;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
snapshot = await load();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
snapshot = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (snapshot === null) {
|
|
|
|
|
|
observed.textContent = "No data";
|
|
|
|
|
|
} else {
|
|
|
|
|
|
render(snapshot);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
document.documentElement.dataset.ready = "true";
|