feat: add monitoring trends
This commit is contained in:
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();
|
||||
})();
|
||||
Reference in new issue
Block a user