feat: add monitoring trends

This commit is contained in:
lussierj committed 2026-08-19 20:00:52 +00:00
1 parent 19fc29c72c
commit 4d360fac42
18 files changed
+1683

No files matched your search

@@ -20,6 +20,7 @@
locationSummary: document.getElementById("minerDetailLocationSummary"),
returnLink: document.getElementById("minerDetailReturn"),
mapLink: document.getElementById("minerDetailMap"),
trendsLink: document.getElementById("minerDetailTrends"),
repairsLink: document.getElementById("minerDetailRepairs"),
identity: document.getElementById("minerIdentityDetails"),
tags: document.getElementById("minerTags"),
@@ -380,6 +381,9 @@
el.lifecycle.textContent = "Lifecycle: " + words(data.miner.lifecycle_status);
renderLocation(data.location, data.miner.id, data.capabilities.can_view_map);
renderRepair(data.repair, data.capabilities);
el.trendsLink.href = "/monitoring/analytics?" + new URLSearchParams({
scope: "miner", scope_id: data.miner.id, range: "24h"
}).toString();
if (data.capabilities.can_open_repair_center) {
const params = new URLSearchParams({ scope: "all", q: data.miner.id });
el.repairsLink.href = "/operations/repairs?" + params.toString();
@@ -0,0 +1,54 @@
.trend-controls form {
display: grid;
grid-template-columns: minmax(140px, 0.7fr) minmax(160px, 1fr)
minmax(160px, 1fr) auto auto;
gap: 12px;
align-items: end;
}
.trend-controls form > label,
.trend-ranges {
display: grid;
gap: 5px;
margin: 0;
}
.trend-ranges {
grid-auto-flow: column;
justify-content: start;
border: 0;
padding: 0;
}
.trend-ranges legend { margin-bottom: 5px; font-weight: 700; }
.trend-ranges label { display: flex; gap: 6px; align-items: center; white-space: nowrap; }
.trend-summary { display: flex; flex-wrap: wrap; gap: 8px 24px; margin: 0 0 14px; }
.trend-summary p { margin: 0; }
.trend-charts { display: grid; gap: 16px; }
.trend-panel { min-width: 0; }
.trend-chart { width: 100%; min-height: 220px; overflow: hidden; }
.trend-chart svg { display: block; width: 100%; height: auto; min-height: 220px; }
.trend-grid { stroke: #cbd5e1; stroke-width: 1; }
.trend-line { fill: none; stroke: #c65312; stroke-width: 2.5; stroke-linejoin: round; }
.trend-point { fill: #ffffff; stroke: #0f172a; stroke-width: 2; }
.trend-point:focus { outline: none; stroke: #2563eb; stroke-width: 5; }
.trend-axis-label { fill: #475569; font-size: 11px; }
.trend-disclosure { color: var(--muted); font-size: 13px; }
.trend-data-table { margin-top: 12px; }
.trend-data-table summary { cursor: pointer; font-weight: 700; }
@media (max-width: 900px) {
.trend-controls form { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.trend-ranges { grid-column: 1 / -1; }
}
@media (max-width: 560px) {
.trend-controls form { grid-template-columns: 1fr; }
.trend-ranges { grid-column: auto; grid-auto-flow: row; }
.trend-chart { min-height: 180px; }
.trend-chart svg { min-height: 180px; }
}
@media (prefers-reduced-motion: reduce) {
.trend-line, .trend-point { transition: none; animation: none; }
}
@@ -0,0 +1,344 @@
(function () {
"use strict";
if (document.body.dataset.view !== "analytics") return;
const canViewLocations = document.body.dataset.canViewLocations === "true";
const allowedScopes = new Set(["fleet", "site", "room", "miner"]);
const allowedRanges = new Set(["6h", "24h", "7d"]);
const params = new URLSearchParams(window.location.search);
const initialScope = allowedScopes.has(params.get("scope"))
? params.get("scope") : "fleet";
const state = {
scope: initialScope,
scopeId: params.get("scope_id") || "",
minerId: initialScope === "miner" ? (params.get("scope_id") || "") : "",
range: allowedRanges.has(params.get("range")) ? params.get("range") : "24h",
available: { sites: [], rooms: [] },
controller: null
};
const el = {
form: document.getElementById("trendControls"),
scope: document.getElementById("trendScope"),
site: document.getElementById("trendSite"),
siteLabel: document.getElementById("trendSiteLabel"),
room: document.getElementById("trendRoom"),
roomLabel: document.getElementById("trendRoomLabel"),
loading: document.getElementById("trendLoading"),
error: document.getElementById("trendError"),
errorText: document.getElementById("trendErrorText"),
retry: document.getElementById("trendRetry"),
content: document.getElementById("trendContent"),
freshness: document.getElementById("trendFreshness"),
scopeNote: document.getElementById("trendScopeNote"),
scopeLabel: document.getElementById("trendScopeLabel"),
rangeSummary: document.getElementById("trendRangeSummary"),
timezone: document.getElementById("trendTimezone"),
charts: document.getElementById("trendCharts")
};
function create(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function svgNode(tag, attributes) {
const node = document.createElementNS("http://www.w3.org/2000/svg", tag);
Object.entries(attributes || {}).forEach(function (entry) {
node.setAttribute(entry[0], String(entry[1]));
});
return node;
}
function dateTime(value) {
if (!value) return "Unavailable";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "Unavailable" : date.toLocaleString();
}
function compactTime(value) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "Unavailable" : date.toLocaleString([], {
month: "short", day: "numeric", hour: "2-digit", minute: "2-digit"
});
}
function number(value, unit) {
if (value === null || value === undefined || !Number.isFinite(Number(value))) {
return "Unavailable";
}
return Number(value).toLocaleString([], { maximumFractionDigits: 2 }) + " " + unit;
}
async function api(url, signal) {
const response = await fetch(url, {
headers: { Accept: "application/json" }, signal: signal
});
const payload = await response.json().catch(function () { return {}; });
if (!response.ok) {
const detail = payload.detail;
throw new Error(typeof detail === "string" ? detail : "Request failed (" + response.status + ")");
}
if (!payload || !payload.data || !payload.meta) {
throw new Error("The trends response was incomplete.");
}
return payload;
}
function replaceOptions(select, options, selected, label) {
select.replaceChildren();
if (!options.length) {
const empty = create("option", "", "No " + label + " available");
empty.value = "";
select.appendChild(empty);
return;
}
options.forEach(function (item) {
const option = create("option", "", item.name);
option.value = item.id;
option.selected = String(item.id) === String(selected);
select.appendChild(option);
});
}
function ensureMinerScopeOption() {
if (state.scope !== "miner" || el.scope.querySelector('option[value="miner"]')) return;
const option = create("option", "", "Miner");
option.value = "miner";
el.scope.appendChild(option);
}
function renderScopeControls() {
ensureMinerScopeOption();
el.scope.value = state.scope;
const selectedSite = state.scope === "site" ? state.scopeId : el.site.value;
let rooms = state.available.rooms || [];
if (state.scope === "room") {
const selectedRoom = rooms.find(function (room) {
return String(room.id) === String(state.scopeId);
});
if (selectedRoom) replaceOptions(el.site, state.available.sites, selectedRoom.site_id, "sites");
} else {
replaceOptions(el.site, state.available.sites, selectedSite, "sites");
}
if (el.site.value) {
rooms = rooms.filter(function (room) {
return String(room.site_id) === String(el.site.value);
});
}
replaceOptions(el.room, rooms, state.scope === "room" ? state.scopeId : "", "rooms");
el.siteLabel.hidden = !canViewLocations || !["site", "room"].includes(state.scope);
el.roomLabel.hidden = !canViewLocations || state.scope !== "room";
const checked = el.form.querySelector('input[name="trendRange"][value="' + state.range + '"]');
if (checked) checked.checked = true;
}
function pointStatus(point) {
if (point.observation_count === 0) return "No observations";
if (point.metric_sample_count === 0) return "Metric unavailable";
return point.incomplete ? "Recorded; current bucket incomplete" : "Recorded";
}
function renderTable(series, tableId) {
const details = create("details", "trend-data-table");
details.appendChild(create("summary", "", "View " + series.metric + " data table"));
const wrapper = create("div", "table-scroll");
const table = create("table");
table.id = tableId;
const caption = create("caption", "", series.label + " bucket values and coverage");
const head = create("thead");
const headRow = create("tr");
["Bucket start", "Value", "Status", "Samples", "Contributors", "Observed miners", "Scoped miners"]
.forEach(function (label) { headRow.appendChild(create("th", "", label)); });
head.appendChild(headRow);
const body = create("tbody");
series.points.forEach(function (point) {
const row = create("tr");
[dateTime(point.bucket_start), number(point.value, series.unit), pointStatus(point),
point.metric_sample_count, point.contributing_miner_count,
point.observed_miner_count, point.scoped_miner_count]
.forEach(function (value) { row.appendChild(create("td", "", String(value))); });
body.appendChild(row);
});
table.append(caption, head, body);
wrapper.appendChild(table);
details.appendChild(wrapper);
return details;
}
function renderChart(series, index) {
const panel = create("section", "panel trend-panel");
const headingId = "trendSeriesTitle" + index;
const title = create("h2", "", series.label);
title.id = headingId;
const valid = series.points.filter(function (point) {
return point.value !== null && Number.isFinite(Number(point.value));
});
const rangeCopy = valid.length
? "Observed range " + number(Math.min.apply(null, valid.map(function (p) { return Number(p.value); })), series.unit) +
" to " + number(Math.max.apply(null, valid.map(function (p) { return Number(p.value); })), series.unit)
: "No valid values in this range";
const header = create("div", "panel-head");
const heading = create("div");
heading.append(create("p", "eyebrow", series.metric === "hashrate" ? "TH/s" : "Watts"), title);
header.append(heading, create("p", "freshness", rangeCopy));
panel.appendChild(header);
const chart = create("div", "trend-chart");
if (!valid.length) {
chart.appendChild(create("p", "empty-copy", "No valid " + series.metric + " observations were recorded."));
} else {
const width = 800;
const height = 250;
const left = 72;
const right = 20;
const top = 20;
const bottom = 48;
const plotWidth = width - left - right;
const plotHeight = height - top - bottom;
let minimum = Math.min.apply(null, valid.map(function (point) { return Number(point.value); }));
let maximum = Math.max.apply(null, valid.map(function (point) { return Number(point.value); }));
if (minimum === maximum) {
const padding = Math.max(Math.abs(minimum) * 0.05, 1);
minimum -= padding;
maximum += padding;
}
const svg = svgNode("svg", {
viewBox: "0 0 " + width + " " + height,
role: "img",
"aria-labelledby": headingId + " chartDescription" + index
});
const description = svgNode("desc", { id: "chartDescription" + index });
description.textContent = series.label + " in " + series.unit + ". Gaps represent missing values.";
svg.appendChild(description);
[0, 0.5, 1].forEach(function (ratio) {
const y = top + ratio * plotHeight;
svg.appendChild(svgNode("line", { x1: left, x2: width - right, y1: y, y2: y, class: "trend-grid" }));
const label = svgNode("text", { x: left - 8, y: y + 4, "text-anchor": "end", class: "trend-axis-label" });
label.textContent = number(maximum - ratio * (maximum - minimum), series.unit);
svg.appendChild(label);
});
let pathData = "";
series.points.forEach(function (point, pointIndex) {
const x = left + (series.points.length === 1 ? 0 : pointIndex / (series.points.length - 1)) * plotWidth;
if (point.value === null || !Number.isFinite(Number(point.value))) {
pathData += " M " + x + " " + (top + plotHeight);
return;
}
const y = top + (maximum - Number(point.value)) / (maximum - minimum) * plotHeight;
const previous = pointIndex > 0 ? series.points[pointIndex - 1] : null;
const command = previous && previous.value !== null && Number.isFinite(Number(previous.value)) ? " L " : " M ";
pathData += command + x + " " + y;
const marker = svgNode("circle", {
cx: x, cy: y, r: 3.5, class: "trend-point", tabindex: 0,
"aria-label": compactTime(point.bucket_start) + ", " + number(point.value, series.unit) +
", " + pointStatus(point)
});
const markerTitle = svgNode("title");
markerTitle.textContent = marker.getAttribute("aria-label");
marker.appendChild(markerTitle);
svg.appendChild(marker);
});
const path = svgNode("path", { d: pathData, class: "trend-line" });
svg.insertBefore(path, svg.querySelector("circle"));
const startLabel = svgNode("text", { x: left, y: height - 12, class: "trend-axis-label" });
startLabel.textContent = compactTime(series.points[0].bucket_start);
const endLabel = svgNode("text", { x: width - right, y: height - 12, "text-anchor": "end", class: "trend-axis-label" });
endLabel.textContent = compactTime(series.points[series.points.length - 1].bucket_start);
svg.append(startLabel, endLabel);
chart.appendChild(svg);
}
panel.append(chart, create("p", "trend-disclosure", "Gaps indicate no valid metric value. Recorded zero remains zero; values are not interpolated."),
renderTable(series, "trendTable" + index));
return panel;
}
function render(payload) {
state.available = payload.data.available_scopes || { sites: [], rooms: [] };
renderScopeControls();
el.scopeLabel.textContent = payload.data.scope.label;
el.rangeSummary.textContent = payload.meta.range + " range / " +
Math.round(payload.meta.bucket_seconds / 60) + " minute buckets / raw snapshots";
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone || "browser local time";
el.timezone.textContent = "Times shown in " + zone + ". API timestamps use UTC.";
el.freshness.textContent = payload.meta.latest_observed_at
? "Latest observation in range: " + dateTime(payload.meta.latest_observed_at)
: "No observations in selected range";
el.scopeNote.textContent = payload.meta.attribution === "current_placement_cohort"
? "Site and room trends use miners currently assigned to this location. Historical placement is not tracked."
: payload.meta.attribution === "miner_identity"
? "Trend values follow this miner's durable UUID."
: "Fleet trends use the current miner inventory cohort.";
el.charts.replaceChildren();
payload.data.series.forEach(function (series, index) {
el.charts.appendChild(renderChart(series, index));
});
}
function selectedScopeId() {
if (state.scope === "fleet") return "";
if (state.scope === "site") return el.site.value || state.scopeId;
if (state.scope === "room") return el.room.value || state.scopeId;
return state.minerId || state.scopeId;
}
function requestUrl() {
const query = new URLSearchParams({ scope: state.scope, range: state.range });
const scopeId = selectedScopeId();
if (state.scope !== "fleet") query.set("scope_id", scopeId);
return "/api/monitoring/trends?" + query.toString();
}
async function load() {
const scopeId = selectedScopeId();
if (state.scope !== "fleet" && !scopeId) {
el.error.hidden = false;
el.errorText.textContent = "Choose a valid scope before applying filters.";
return;
}
if (state.controller) state.controller.abort();
state.controller = new AbortController();
el.loading.hidden = false;
el.error.hidden = true;
try {
const payload = await api(requestUrl(), state.controller.signal);
state.scopeId = payload.data.scope.id || "";
if (state.scope === "miner") state.minerId = state.scopeId;
render(payload);
const url = new URL(window.location.href);
url.search = new URL(requestUrl(), window.location.origin).search;
window.history.replaceState({}, "", url.pathname + url.search);
el.content.hidden = false;
} catch (error) {
if (error.name === "AbortError") return;
el.errorText.textContent = error.message;
el.error.hidden = false;
} finally {
el.loading.hidden = true;
}
}
el.scope.addEventListener("change", function () {
state.scope = el.scope.value;
if (state.scope !== "miner") state.scopeId = "";
renderScopeControls();
});
el.site.addEventListener("change", function () {
if (state.scope === "site") state.scopeId = el.site.value;
if (state.scope === "room") state.scopeId = "";
renderScopeControls();
});
el.form.addEventListener("submit", function (event) {
event.preventDefault();
state.scope = el.scope.value;
const selectedRange = el.form.querySelector('input[name="trendRange"]:checked');
state.range = selectedRange ? selectedRange.value : "24h";
state.scopeId = selectedScopeId();
load();
});
el.retry.addEventListener("click", load);
renderScopeControls();
load();
})();