(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(); })();