345 lines
16 KiB
JavaScript
345 lines
16 KiB
JavaScript
(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();
|
|
})();
|