diff --git a/backend/app/frontend/miner_detail_view.py b/backend/app/frontend/miner_detail_view.py
index 00ae1f9..c06723d 100644
--- a/backend/app/frontend/miner_detail_view.py
+++ b/backend/app/frontend/miner_detail_view.py
@@ -29,6 +29,7 @@ def miner_detail_content() -> str:
Return
Back to Fleet
View on Mine Map
+ View trends
Open filtered Repair Center
diff --git a/backend/app/frontend/monitoring_trends_view.py b/backend/app/frontend/monitoring_trends_view.py
new file mode 100644
index 0000000..47b2f8a
--- /dev/null
+++ b/backend/app/frontend/monitoring_trends_view.py
@@ -0,0 +1,52 @@
+"""Semantic HTML for the Internal Alpha Monitoring Trends page."""
+
+
+def monitoring_trends_content(can_view_locations: bool) -> str:
+ location_options = (
+ ''
+ if can_view_locations
+ else ""
+ )
+ return f"""
+
+ Historical scope
+
Trend controls
+
Not loaded
+
+ Fleet trends use current miner inventory.
+
+
+ Loading trendsAggregating bounded raw telemetry.
+
+
+ Trends unavailableThe request failed.
+
+
+
+"""
diff --git a/backend/app/frontend/static/miner-detail.js b/backend/app/frontend/static/miner-detail.js
index 120a14d..f128d8f 100644
--- a/backend/app/frontend/static/miner-detail.js
+++ b/backend/app/frontend/static/miner-detail.js
@@ -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();
diff --git a/backend/app/frontend/static/monitoring-trends.css b/backend/app/frontend/static/monitoring-trends.css
new file mode 100644
index 0000000..0638d08
--- /dev/null
+++ b/backend/app/frontend/static/monitoring-trends.css
@@ -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; }
+}
diff --git a/backend/app/frontend/static/monitoring-trends.js b/backend/app/frontend/static/monitoring-trends.js
new file mode 100644
index 0000000..8bbd04a
--- /dev/null
+++ b/backend/app/frontend/static/monitoring-trends.js
@@ -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();
+})();
diff --git a/backend/app/frontend/views.py b/backend/app/frontend/views.py
index 7dda519..2d6a081 100644
--- a/backend/app/frontend/views.py
+++ b/backend/app/frontend/views.py
@@ -4,11 +4,13 @@ from html import escape
from typing import Callable
from app.frontend.miner_detail_view import miner_detail_content
+from app.frontend.monitoring_trends_view import monitoring_trends_content
NAVIGATION = (
("dashboard", "Dashboard", "/dashboard", "dashboard.view"),
("map", "Mine Map", "/monitoring/map", "locations.view"),
+ ("analytics", "Trends", "/monitoring/analytics", "miners.view"),
("repairs", "Repair Center", "/operations/repairs", "repairs.view"),
("miners", "Miners", "/assets/miners", "miners.view"),
)
@@ -24,6 +26,11 @@ PAGE_DETAILS = {
"Mine Map",
"Inspect where miners and operational conditions are concentrated.",
),
+ "analytics": (
+ "Monitoring / Analytics",
+ "Trends",
+ "Review bounded historical hashrate and power observations.",
+ ),
"repairs": (
"Operations",
"Repair Center",
@@ -450,6 +457,8 @@ def render_app_page(
content = (
miner_detail_content()
if view == "miner_detail"
+ else monitoring_trends_content(has_permission("locations.view"))
+ if view == "analytics"
else _map_content(has_permission("locations.manage"))
if view == "map"
else _miners_content(has_permission("locations.view"))
@@ -467,6 +476,8 @@ def render_app_page(
page_script = (
''
if view == "miner_detail"
+ else ''
+ if view == "analytics"
else ''
if view == "map"
else ''
@@ -477,6 +488,10 @@ def render_app_page(
if view == "dashboard"
else ""
)
+ page_style = (
+ ''
+ if view == "analytics" else ""
+ )
object_attributes = ""
if miner_id is not None:
object_attributes += f' data-miner-id="{escape(miner_id)}"'
@@ -489,6 +504,7 @@ def render_app_page(
{escape(title)} | MinerOps
+ {page_style}
bool:
"/system-health",
"/accounts",
"/monitoring/map",
+ "/monitoring/analytics",
"/operations/repairs",
"/assets/miners",
}
@@ -685,6 +686,8 @@ def required_page_permission(path: str) -> str:
return "repairs.view"
if path in {"/locations", "/monitoring/map"}:
return "locations.view"
+ if path == "/monitoring/analytics":
+ return "miners.view"
if path == "/assets/miners":
return "miners.view"
if path.startswith("/agent/"):
diff --git a/backend/app/main.py b/backend/app/main.py
index e8d2b13..21aa961 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -30,6 +30,8 @@ from app.inventory.router import router as inventory_router
from app.locations.router import router as locations_router
from app.components.router import router as components_router
from app.miner_detail.router import router as miner_detail_router
+from app.monitoring.router import router as monitoring_router
+from app.monitoring.frontend import router as monitoring_frontend_router
from app.repairs.exceptions import (
ControlBoardReplacementActorRequired,
InvalidMacAddress,
@@ -63,6 +65,8 @@ app.include_router(components_router)
app.include_router(locations_router)
app.include_router(dashboard_router)
app.include_router(miner_detail_router)
+app.include_router(monitoring_frontend_router)
+app.include_router(monitoring_router)
install_identity_middleware(app)
diff --git a/backend/app/monitoring/__init__.py b/backend/app/monitoring/__init__.py
new file mode 100644
index 0000000..9e1a6ff
--- /dev/null
+++ b/backend/app/monitoring/__init__.py
@@ -0,0 +1 @@
+"""Historical monitoring read models."""
diff --git a/backend/app/monitoring/dependencies.py b/backend/app/monitoring/dependencies.py
new file mode 100644
index 0000000..124c812
--- /dev/null
+++ b/backend/app/monitoring/dependencies.py
@@ -0,0 +1,8 @@
+"""Dependency construction for the Monitoring domain."""
+
+from app.monitoring.repository import MonitoringRepository
+from app.monitoring.service import MonitoringService
+
+
+def get_monitoring_service() -> MonitoringService:
+ return MonitoringService(MonitoringRepository())
diff --git a/backend/app/monitoring/exceptions.py b/backend/app/monitoring/exceptions.py
new file mode 100644
index 0000000..81d3e32
--- /dev/null
+++ b/backend/app/monitoring/exceptions.py
@@ -0,0 +1,13 @@
+"""Monitoring domain exceptions."""
+
+
+class MonitoringError(Exception):
+ """Base monitoring-domain error."""
+
+
+class MonitoringValidationError(MonitoringError):
+ """The requested trend scope or range is invalid."""
+
+
+class MonitoringScopeNotFound(MonitoringError):
+ """The requested durable scope does not exist."""
diff --git a/backend/app/monitoring/frontend.py b/backend/app/monitoring/frontend.py
new file mode 100644
index 0000000..3aa55ef
--- /dev/null
+++ b/backend/app/monitoring/frontend.py
@@ -0,0 +1,70 @@
+"""Authenticated frontend routes for Monitoring Trends."""
+
+from pathlib import Path
+from typing import Annotated
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.responses import HTMLResponse, Response
+
+from app.frontend.views import render_app_page
+from app.identity import require_permission, user_has_permission
+from app.monitoring.models import (
+ MonitoringTrendQuery,
+ TrendRange,
+ TrendScopeType,
+)
+
+
+router = APIRouter(tags=["frontend"])
+STATIC_ROOT = Path(__file__).parents[1] / "frontend" / "static"
+MonitoringUser = Annotated[dict, Depends(require_permission("miners.view"))]
+
+
+@router.get("/assets/minerops-monitoring-trends.css", include_in_schema=False)
+def monitoring_trends_styles() -> Response:
+ return Response(
+ (STATIC_ROOT / "monitoring-trends.css").read_text(encoding="utf-8"),
+ media_type="text/css",
+ headers={"Cache-Control": "no-store"},
+ )
+
+
+@router.get("/assets/minerops-monitoring-trends.js", include_in_schema=False)
+def monitoring_trends_script() -> Response:
+ return Response(
+ (STATIC_ROOT / "monitoring-trends.js").read_text(encoding="utf-8"),
+ media_type="text/javascript",
+ headers={"Cache-Control": "no-store"},
+ )
+
+
+@router.get(
+ "/monitoring/analytics",
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+def monitoring_analytics_page(
+ current_user: MonitoringUser,
+ scope: Annotated[TrendScopeType, Query()] = "fleet",
+ scope_id: Annotated[UUID | None, Query()] = None,
+ time_range: Annotated[TrendRange, Query(alias="range")] = "24h",
+) -> str:
+ try:
+ query = MonitoringTrendQuery(
+ scope=scope, scope_id=scope_id, range=time_range
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
+ if query.scope in {"site", "room"} and not user_has_permission(
+ current_user, "locations.view"
+ ):
+ raise HTTPException(
+ status_code=403,
+ detail="Location viewing permission is required for this scope",
+ )
+ return render_app_page(
+ "analytics",
+ current_user,
+ lambda permission: user_has_permission(current_user, permission),
+ )
diff --git a/backend/app/monitoring/models.py b/backend/app/monitoring/models.py
new file mode 100644
index 0000000..28179e4
--- /dev/null
+++ b/backend/app/monitoring/models.py
@@ -0,0 +1,98 @@
+"""Typed contracts for bounded historical telemetry trends."""
+
+from datetime import datetime
+from typing import Literal
+from uuid import UUID
+
+from pydantic import BaseModel, ConfigDict, Field, model_validator
+
+
+TrendScopeType = Literal["fleet", "site", "room", "miner"]
+TrendRange = Literal["6h", "24h", "7d"]
+TrendMetric = Literal["hashrate", "power"]
+TrendAttribution = Literal["current_placement_cohort", "miner_identity", "fleet"]
+
+
+class MonitoringTrendQuery(BaseModel):
+ model_config = ConfigDict(populate_by_name=True)
+
+ scope: TrendScopeType = "fleet"
+ scope_id: UUID | None = None
+ time_range: TrendRange = Field(default="24h", alias="range")
+
+ @model_validator(mode="after")
+ def validate_scope_identity(self) -> "MonitoringTrendQuery":
+ if self.scope == "fleet" and self.scope_id is not None:
+ raise ValueError("scope_id is not allowed for fleet scope")
+ if self.scope != "fleet" and self.scope_id is None:
+ raise ValueError("scope_id is required for this scope")
+ return self
+
+
+class MonitoringScope(BaseModel):
+ type: TrendScopeType
+ id: UUID | None = None
+ label: str
+
+
+class MonitoringSiteOption(BaseModel):
+ id: UUID
+ name: str
+
+
+class MonitoringRoomOption(BaseModel):
+ id: UUID
+ site_id: UUID
+ name: str
+
+
+class MonitoringAvailableScopes(BaseModel):
+ fleet: bool = True
+ sites: list[MonitoringSiteOption] = Field(default_factory=list)
+ rooms: list[MonitoringRoomOption] = Field(default_factory=list)
+
+
+class MonitoringTrendPoint(BaseModel):
+ bucket_start: datetime
+ bucket_end: datetime
+ value: float | None = None
+ observation_count: int
+ metric_sample_count: int
+ observed_miner_count: int
+ contributing_miner_count: int
+ scoped_miner_count: int
+ incomplete: bool = False
+
+
+class MonitoringTrendSeries(BaseModel):
+ metric: TrendMetric
+ unit: Literal["TH/s", "W"]
+ label: str
+ points: list[MonitoringTrendPoint]
+
+
+class MonitoringTrendData(BaseModel):
+ scope: MonitoringScope
+ series: list[MonitoringTrendSeries]
+ available_scopes: MonitoringAvailableScopes
+
+
+class MonitoringTrendMeta(BaseModel):
+ generated_at: datetime
+ latest_observed_at: datetime | None = None
+ source: Literal["raw_snapshots"] = "raw_snapshots"
+ attribution: TrendAttribution
+ time_range: TrendRange = Field(alias="range")
+ bucket_seconds: int
+ maximum_points: int = 200
+ completeness: Literal["not_guaranteed"] = "not_guaranteed"
+ current_bucket_incomplete: bool
+ database_query_count: int = Field(ge=1, le=2)
+
+ model_config = ConfigDict(populate_by_name=True)
+
+
+class MonitoringTrendResponse(BaseModel):
+ data: MonitoringTrendData
+ meta: MonitoringTrendMeta
+ error: None = None
diff --git a/backend/app/monitoring/repository.py b/backend/app/monitoring/repository.py
new file mode 100644
index 0000000..999f534
--- /dev/null
+++ b/backend/app/monitoring/repository.py
@@ -0,0 +1,237 @@
+"""PostgreSQL persistence for bounded monitoring trends."""
+
+from collections.abc import Callable, Iterator
+from contextlib import contextmanager
+from datetime import datetime, timedelta
+from typing import Any
+from uuid import UUID
+
+import psycopg
+from psycopg.rows import dict_row
+
+from app.config import settings
+
+
+ConnectionFactory = Callable[[], psycopg.Connection]
+
+
+def default_connection_factory() -> psycopg.Connection:
+ return psycopg.connect(
+ host=settings.postgres_host,
+ port=settings.postgres_port,
+ dbname=settings.postgres_db,
+ user=settings.postgres_user,
+ password=settings.postgres_password,
+ connect_timeout=5,
+ )
+
+
+TREND_SQL = """
+WITH requested AS (
+ SELECT %s::text AS scope_type, %s::uuid AS scope_id,
+ %s::timestamptz AS window_start, %s::timestamptz AS window_end,
+ %s::interval AS bucket_width
+),
+current_locations AS (
+ SELECT miners.id AS miner_id,
+ COALESCE(position.site_id, miners.site_id) AS site_id,
+ COALESCE(position.room_id, miners.room_id) AS room_id
+ FROM miners
+ LEFT JOIN LATERAL (
+ SELECT rooms.site_id, location_positions.room_id
+ FROM location_positions
+ LEFT JOIN rooms ON rooms.id = location_positions.room_id
+ WHERE location_positions.miner_id = miners.id
+ ORDER BY location_positions.id
+ LIMIT 1
+ ) AS position ON true
+),
+scope_meta AS (
+ SELECT requested.scope_type, requested.scope_id,
+ CASE requested.scope_type
+ WHEN 'fleet' THEN true
+ WHEN 'miner' THEN EXISTS (
+ SELECT 1 FROM miners WHERE miners.id = requested.scope_id
+ )
+ WHEN 'site' THEN EXISTS (
+ SELECT 1 FROM sites WHERE sites.id = requested.scope_id
+ )
+ WHEN 'room' THEN EXISTS (
+ SELECT 1 FROM rooms WHERE rooms.id = requested.scope_id
+ )
+ ELSE false
+ END AS scope_exists,
+ CASE requested.scope_type
+ WHEN 'fleet' THEN 'Fleet'
+ WHEN 'miner' THEN (
+ SELECT COALESCE(NULLIF(trim(miners.asset_tag), ''), miners.id::text)
+ FROM miners WHERE miners.id = requested.scope_id
+ )
+ WHEN 'site' THEN (
+ SELECT sites.name FROM sites WHERE sites.id = requested.scope_id
+ )
+ WHEN 'room' THEN (
+ SELECT rooms.name FROM rooms WHERE rooms.id = requested.scope_id
+ )
+ END AS scope_label
+ FROM requested
+),
+scoped_miners AS (
+ SELECT miners.id
+ FROM miners
+ JOIN current_locations ON current_locations.miner_id = miners.id
+ CROSS JOIN requested
+ WHERE requested.scope_type = 'fleet'
+ OR (requested.scope_type = 'miner' AND miners.id = requested.scope_id)
+ OR (requested.scope_type = 'site'
+ AND current_locations.site_id = requested.scope_id)
+ OR (requested.scope_type = 'room'
+ AND current_locations.room_id = requested.scope_id)
+),
+scope_count AS (
+ SELECT count(*)::integer AS scoped_miner_count FROM scoped_miners
+),
+buckets AS (
+ SELECT bucket_start,
+ bucket_start + requested.bucket_width AS bucket_end
+ FROM requested
+ CROSS JOIN LATERAL generate_series(
+ requested.window_start,
+ requested.window_end - requested.bucket_width,
+ requested.bucket_width
+ ) AS bucket_start
+),
+per_miner_bucket AS (
+ SELECT date_bin(
+ requested.bucket_width,
+ snapshots.observed_at,
+ '1970-01-01 00:00:00+00'::timestamptz
+ ) AS bucket_start,
+ snapshots.miner_id,
+ count(*)::integer AS observation_count,
+ count(snapshots.mhs_av)::integer AS hashrate_sample_count,
+ count(snapshots.power_w)::integer AS power_sample_count,
+ avg(snapshots.mhs_av) AS average_mhs_av,
+ avg(snapshots.power_w) AS average_power_w
+ FROM miner_status_snapshots AS snapshots
+ JOIN scoped_miners ON scoped_miners.id = snapshots.miner_id
+ CROSS JOIN requested
+ WHERE snapshots.observed_at >= requested.window_start
+ AND snapshots.observed_at < requested.window_end
+ GROUP BY bucket_start, snapshots.miner_id
+),
+bucket_totals AS (
+ SELECT per_miner_bucket.bucket_start,
+ sum(per_miner_bucket.observation_count)::integer AS observation_count,
+ sum(per_miner_bucket.hashrate_sample_count)::integer
+ AS hashrate_sample_count,
+ sum(per_miner_bucket.power_sample_count)::integer AS power_sample_count,
+ count(*)::integer AS observed_miner_count,
+ count(*) FILTER (
+ WHERE per_miner_bucket.hashrate_sample_count > 0
+ )::integer AS hashrate_contributing_miner_count,
+ count(*) FILTER (
+ WHERE per_miner_bucket.power_sample_count > 0
+ )::integer AS power_contributing_miner_count,
+ CASE WHEN sum(per_miner_bucket.hashrate_sample_count) > 0
+ THEN sum(per_miner_bucket.average_mhs_av) FILTER (
+ WHERE per_miner_bucket.hashrate_sample_count > 0
+ ) / 1000000.0
+ END AS hashrate_ths,
+ CASE WHEN sum(per_miner_bucket.power_sample_count) > 0
+ THEN sum(per_miner_bucket.average_power_w) FILTER (
+ WHERE per_miner_bucket.power_sample_count > 0
+ )
+ END AS power_w
+ FROM per_miner_bucket
+ GROUP BY per_miner_bucket.bucket_start
+),
+latest AS (
+ SELECT max(snapshots.observed_at) AS latest_observed_at
+ FROM miner_status_snapshots AS snapshots
+ JOIN scoped_miners ON scoped_miners.id = snapshots.miner_id
+ CROSS JOIN requested
+ WHERE snapshots.observed_at >= requested.window_start
+ AND snapshots.observed_at < requested.window_end
+)
+SELECT scope_meta.scope_exists, scope_meta.scope_label,
+ buckets.bucket_start, buckets.bucket_end,
+ bucket_totals.hashrate_ths, bucket_totals.power_w,
+ COALESCE(bucket_totals.observation_count, 0) AS observation_count,
+ COALESCE(bucket_totals.hashrate_sample_count, 0) AS hashrate_sample_count,
+ COALESCE(bucket_totals.power_sample_count, 0) AS power_sample_count,
+ COALESCE(bucket_totals.observed_miner_count, 0) AS observed_miner_count,
+ COALESCE(bucket_totals.hashrate_contributing_miner_count, 0)
+ AS hashrate_contributing_miner_count,
+ COALESCE(bucket_totals.power_contributing_miner_count, 0)
+ AS power_contributing_miner_count,
+ scope_count.scoped_miner_count, latest.latest_observed_at
+FROM buckets
+CROSS JOIN scope_meta
+CROSS JOIN scope_count
+CROSS JOIN latest
+LEFT JOIN bucket_totals ON bucket_totals.bucket_start = buckets.bucket_start
+ORDER BY buckets.bucket_start
+"""
+
+
+SCOPE_OPTIONS_SQL = """
+SELECT option_type, id, site_id, name
+FROM (
+ SELECT 'site'::text AS option_type, sites.id,
+ NULL::uuid AS site_id, sites.name
+ FROM sites
+ UNION ALL
+ SELECT 'room'::text AS option_type, rooms.id,
+ rooms.site_id, rooms.name
+ FROM rooms
+) AS options
+ORDER BY option_type DESC, lower(name), id
+"""
+
+
+class MonitoringRepository:
+ """Own the set-based raw-snapshot monitoring read path."""
+
+ def __init__(
+ self,
+ connection_factory: ConnectionFactory | None = None,
+ *,
+ connection: psycopg.Connection | None = None,
+ ):
+ self._connection_factory = connection_factory or default_connection_factory
+ self._connection = connection
+
+ @contextmanager
+ def _connection_scope(self) -> Iterator[psycopg.Connection]:
+ if self._connection is not None:
+ yield self._connection
+ return
+ connection = self._connection_factory()
+ try:
+ yield connection
+ finally:
+ connection.close()
+
+ def read_trends(
+ self,
+ *,
+ scope: str,
+ scope_id: UUID | None,
+ window_start: datetime,
+ window_end: datetime,
+ bucket_width: timedelta,
+ ) -> list[dict[str, Any]]:
+ with self._connection_scope() as connection:
+ with connection.cursor(row_factory=dict_row) as cur:
+ cur.execute(
+ TREND_SQL,
+ (scope, scope_id, window_start, window_end, bucket_width),
+ )
+ return [dict(row) for row in cur.fetchall()]
+
+ def list_location_scope_options(self) -> list[dict[str, Any]]:
+ with self._connection_scope() as connection:
+ with connection.cursor(row_factory=dict_row) as cur:
+ cur.execute(SCOPE_OPTIONS_SQL)
+ return [dict(row) for row in cur.fetchall()]
diff --git a/backend/app/monitoring/router.py b/backend/app/monitoring/router.py
new file mode 100644
index 0000000..0414e6c
--- /dev/null
+++ b/backend/app/monitoring/router.py
@@ -0,0 +1,61 @@
+"""HTTP boundary for bounded historical monitoring trends."""
+
+from typing import Annotated
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
+
+from app.identity import require_permission, user_has_permission
+from app.monitoring.dependencies import get_monitoring_service
+from app.monitoring.exceptions import MonitoringScopeNotFound
+from app.monitoring.models import (
+ MonitoringTrendQuery,
+ MonitoringTrendResponse,
+ TrendRange,
+ TrendScopeType,
+)
+from app.monitoring.service import MonitoringService
+
+
+router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
+MonitoringUser = Annotated[dict, Depends(require_permission("miners.view"))]
+MonitoringServiceDependency = Annotated[
+ MonitoringService,
+ Depends(get_monitoring_service),
+]
+
+
+@router.get("/trends", response_model=MonitoringTrendResponse)
+def get_monitoring_trends(
+ current_user: MonitoringUser,
+ service: MonitoringServiceDependency,
+ response: Response,
+ scope: Annotated[TrendScopeType, Query()] = "fleet",
+ scope_id: Annotated[UUID | None, Query()] = None,
+ time_range: Annotated[TrendRange, Query(alias="range")] = "24h",
+) -> MonitoringTrendResponse:
+ try:
+ query = MonitoringTrendQuery(
+ scope=scope, scope_id=scope_id, range=time_range
+ )
+ except ValueError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
+ ) from exc
+ can_view_locations = user_has_permission(current_user, "locations.view")
+ if query.scope in {"site", "room"} and not can_view_locations:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Location viewing permission is required for this scope",
+ )
+ response.headers["Cache-Control"] = "no-store"
+ try:
+ return service.get_trends(
+ query,
+ include_location_scopes=can_view_locations,
+ )
+ except MonitoringScopeNotFound as exc:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=str(exc),
+ ) from exc
diff --git a/backend/app/monitoring/service.py b/backend/app/monitoring/service.py
new file mode 100644
index 0000000..22e0612
--- /dev/null
+++ b/backend/app/monitoring/service.py
@@ -0,0 +1,173 @@
+"""Business semantics for bounded historical monitoring trends."""
+
+from datetime import datetime, timedelta, timezone
+from uuid import UUID
+
+from app.monitoring.exceptions import MonitoringScopeNotFound
+from app.monitoring.models import (
+ MonitoringAvailableScopes,
+ MonitoringRoomOption,
+ MonitoringScope,
+ MonitoringSiteOption,
+ MonitoringTrendData,
+ MonitoringTrendMeta,
+ MonitoringTrendPoint,
+ MonitoringTrendQuery,
+ MonitoringTrendResponse,
+ MonitoringTrendSeries,
+)
+from app.monitoring.repository import MonitoringRepository
+
+
+RANGE_CONFIGURATION = {
+ "6h": (timedelta(hours=6), timedelta(minutes=5)),
+ "24h": (timedelta(hours=24), timedelta(minutes=15)),
+ "7d": (timedelta(days=7), timedelta(hours=1)),
+}
+MAXIMUM_POINTS = 200
+
+
+class MonitoringService:
+ """Coordinate factual telemetry aggregation without HTTP dependencies."""
+
+ def __init__(self, repository: MonitoringRepository):
+ self.repository = repository
+
+ @staticmethod
+ def _window(
+ query: MonitoringTrendQuery,
+ generated_at: datetime,
+ ) -> tuple[datetime, datetime, timedelta]:
+ duration, bucket_width = RANGE_CONFIGURATION[query.time_range]
+ generated_at = generated_at.astimezone(timezone.utc)
+ bucket_seconds = int(bucket_width.total_seconds())
+ current_bucket_epoch = (
+ int(generated_at.timestamp()) // bucket_seconds * bucket_seconds
+ )
+ current_bucket_start = datetime.fromtimestamp(
+ current_bucket_epoch, tz=timezone.utc
+ )
+ window_end = current_bucket_start + bucket_width
+ return window_end - duration, window_end, bucket_width
+
+ def get_trends(
+ self,
+ query: MonitoringTrendQuery,
+ *,
+ include_location_scopes: bool,
+ generated_at: datetime | None = None,
+ ) -> MonitoringTrendResponse:
+ generated_at = generated_at or datetime.now(timezone.utc)
+ window_start, window_end, bucket_width = self._window(query, generated_at)
+ rows = self.repository.read_trends(
+ scope=query.scope,
+ scope_id=query.scope_id,
+ window_start=window_start,
+ window_end=window_end,
+ bucket_width=bucket_width,
+ )
+ if not rows or not rows[0]["scope_exists"]:
+ raise MonitoringScopeNotFound(f"{query.scope.title()} not found")
+
+ options = MonitoringAvailableScopes()
+ query_count = 1
+ if include_location_scopes:
+ option_rows = self.repository.list_location_scope_options()
+ query_count += 1
+ options = MonitoringAvailableScopes(
+ sites=[
+ MonitoringSiteOption.model_validate(row)
+ for row in option_rows
+ if row["option_type"] == "site"
+ ],
+ rooms=[
+ MonitoringRoomOption.model_validate(row)
+ for row in option_rows
+ if row["option_type"] == "room"
+ ],
+ )
+
+ generated_at = generated_at.astimezone(timezone.utc)
+ hashrate_points = []
+ power_points = []
+ for row in rows:
+ shared = {
+ "bucket_start": row["bucket_start"],
+ "bucket_end": row["bucket_end"],
+ "observation_count": row["observation_count"],
+ "observed_miner_count": row["observed_miner_count"],
+ "scoped_miner_count": row["scoped_miner_count"],
+ "incomplete": row["bucket_end"] > generated_at,
+ }
+ hashrate_points.append(
+ MonitoringTrendPoint(
+ **shared,
+ value=(
+ float(row["hashrate_ths"])
+ if row["hashrate_ths"] is not None
+ else None
+ ),
+ metric_sample_count=row["hashrate_sample_count"],
+ contributing_miner_count=(
+ row["hashrate_contributing_miner_count"]
+ ),
+ )
+ )
+ power_points.append(
+ MonitoringTrendPoint(
+ **shared,
+ value=(
+ float(row["power_w"])
+ if row["power_w"] is not None
+ else None
+ ),
+ metric_sample_count=row["power_sample_count"],
+ contributing_miner_count=(
+ row["power_contributing_miner_count"]
+ ),
+ )
+ )
+
+ if len(rows) > MAXIMUM_POINTS:
+ raise RuntimeError("Monitoring query exceeded the fixed point bound")
+ attribution = (
+ "current_placement_cohort"
+ if query.scope in {"site", "room"}
+ else "miner_identity"
+ if query.scope == "miner"
+ else "fleet"
+ )
+ latest_observed_at = rows[0]["latest_observed_at"]
+ return MonitoringTrendResponse(
+ data=MonitoringTrendData(
+ scope=MonitoringScope(
+ type=query.scope,
+ id=query.scope_id,
+ label=rows[0]["scope_label"],
+ ),
+ series=[
+ MonitoringTrendSeries(
+ metric="hashrate",
+ unit="TH/s",
+ label="Average recorded hashrate",
+ points=hashrate_points,
+ ),
+ MonitoringTrendSeries(
+ metric="power",
+ unit="W",
+ label="Average observed power",
+ points=power_points,
+ ),
+ ],
+ available_scopes=options,
+ ),
+ meta=MonitoringTrendMeta(
+ generated_at=generated_at,
+ latest_observed_at=latest_observed_at,
+ attribution=attribution,
+ range=query.time_range,
+ bucket_seconds=int(bucket_width.total_seconds()),
+ current_bucket_incomplete=window_end > generated_at,
+ database_query_count=query_count,
+ ),
+ )
diff --git a/backend/tests/test_monitoring_trends.py b/backend/tests/test_monitoring_trends.py
new file mode 100644
index 0000000..075cd06
--- /dev/null
+++ b/backend/tests/test_monitoring_trends.py
@@ -0,0 +1,448 @@
+import ast
+import time
+import unittest
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from uuid import uuid4
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+import psycopg
+
+from app.config import settings
+from app.identity import get_current_user, protected_page, required_page_permission
+from app.monitoring.dependencies import get_monitoring_service
+from app.monitoring.frontend import router as monitoring_frontend_router
+from app.monitoring.models import MonitoringTrendQuery
+from app.monitoring.repository import MonitoringRepository, TREND_SQL
+from app.monitoring.router import router as monitoring_router
+from app.monitoring.service import MonitoringService
+
+
+NOW = datetime(2026, 8, 17, 12, 2, tzinfo=timezone.utc)
+APP_ROOT = Path(__file__).parents[1] / "app"
+STATIC_ROOT = APP_ROOT / "frontend" / "static"
+
+
+def trend_rows(count=72, *, scope_exists=True, label="Fleet"):
+ start = datetime(2026, 8, 17, 6, 5, tzinfo=timezone.utc)
+ rows = []
+ for index in range(count):
+ bucket_start = start + timedelta(minutes=5 * index)
+ rows.append({
+ "scope_exists": scope_exists,
+ "scope_label": label,
+ "bucket_start": bucket_start,
+ "bucket_end": bucket_start + timedelta(minutes=5),
+ "hashrate_ths": 0 if index == 0 else None,
+ "power_w": 0 if index == 0 else None,
+ "observation_count": 1 if index < 2 else 0,
+ "hashrate_sample_count": 1 if index == 0 else 0,
+ "power_sample_count": 1 if index == 0 else 0,
+ "observed_miner_count": 1 if index < 2 else 0,
+ "hashrate_contributing_miner_count": 1 if index == 0 else 0,
+ "power_contributing_miner_count": 1 if index == 0 else 0,
+ "scoped_miner_count": 2,
+ "latest_observed_at": start + timedelta(minutes=1),
+ })
+ return rows
+
+
+class FakeMonitoringRepository:
+ def __init__(self, rows=None):
+ self.rows = rows or trend_rows()
+ self.trend_calls = []
+ self.option_calls = 0
+
+ def read_trends(self, **kwargs):
+ self.trend_calls.append(kwargs)
+ return self.rows
+
+ def list_location_scope_options(self):
+ self.option_calls += 1
+ site_id, room_id = uuid4(), uuid4()
+ return [
+ {"option_type": "site", "id": site_id, "site_id": None,
+ "name": ''},
+ {"option_type": "room", "id": room_id, "site_id": site_id,
+ "name": "Room 1"},
+ ]
+
+
+class MonitoringServiceTests(unittest.TestCase):
+ def test_fixed_ranges_emit_expected_bounded_points(self):
+ expected = {"6h": (72, 300), "24h": (96, 900), "7d": (168, 3600)}
+ for range_name, (count, seconds) in expected.items():
+ with self.subTest(range_name=range_name):
+ rows = trend_rows(count)
+ repository = FakeMonitoringRepository(rows)
+ response = MonitoringService(repository).get_trends(
+ MonitoringTrendQuery(scope="fleet", range=range_name),
+ include_location_scopes=False,
+ generated_at=NOW,
+ )
+ self.assertEqual(len(response.data.series), 2)
+ self.assertEqual(len(response.data.series[0].points), count)
+ self.assertLessEqual(count, response.meta.maximum_points)
+ self.assertEqual(response.meta.bucket_seconds, seconds)
+ self.assertEqual(response.meta.database_query_count, 1)
+
+ def test_zero_null_and_empty_bucket_semantics_remain_distinct(self):
+ response = MonitoringService(FakeMonitoringRepository()).get_trends(
+ MonitoringTrendQuery(scope="fleet", range="6h"),
+ include_location_scopes=False,
+ generated_at=NOW,
+ )
+ points = response.data.series[0].points
+ self.assertEqual(points[0].value, 0)
+ self.assertEqual(points[0].metric_sample_count, 1)
+ self.assertIsNone(points[1].value)
+ self.assertEqual(points[1].observation_count, 1)
+ self.assertEqual(points[1].metric_sample_count, 0)
+ self.assertIsNone(points[2].value)
+ self.assertEqual(points[2].observation_count, 0)
+
+ def test_location_options_and_attribution_are_permission_aware(self):
+ repository = FakeMonitoringRepository(trend_rows(label="North Site"))
+ response = MonitoringService(repository).get_trends(
+ MonitoringTrendQuery(scope="site", scope_id=uuid4(), range="6h"),
+ include_location_scopes=True,
+ generated_at=NOW,
+ )
+ self.assertEqual(response.meta.attribution, "current_placement_cohort")
+ self.assertEqual(response.meta.database_query_count, 2)
+ self.assertEqual(repository.option_calls, 1)
+ self.assertEqual(len(response.data.available_scopes.sites), 1)
+ repository = FakeMonitoringRepository()
+ response = MonitoringService(repository).get_trends(
+ MonitoringTrendQuery(scope="fleet", range="6h"),
+ include_location_scopes=False,
+ generated_at=NOW,
+ )
+ self.assertEqual(response.data.available_scopes.sites, [])
+ self.assertEqual(repository.option_calls, 0)
+
+
+class MonitoringRouterTests(unittest.TestCase):
+ def setUp(self):
+ self.repository = FakeMonitoringRepository()
+ self.service = MonitoringService(self.repository)
+ self.app = FastAPI()
+ self.app.include_router(monitoring_router)
+ self.app.dependency_overrides[get_monitoring_service] = lambda: self.service
+ self.client = TestClient(self.app)
+
+ def tearDown(self):
+ self.client.close()
+
+ def authenticate(self, permissions):
+ self.app.dependency_overrides[get_current_user] = lambda: {
+ "id": str(uuid4()), "role": "Custom", "permissions": list(permissions)
+ }
+
+ def test_authentication_permission_and_location_scope_boundaries(self):
+ self.assertEqual(self.client.get("/api/monitoring/trends").status_code, 401)
+ self.authenticate(())
+ self.assertEqual(self.client.get("/api/monitoring/trends").status_code, 403)
+ self.authenticate(("miners.view",))
+ self.assertEqual(self.client.get("/api/monitoring/trends").status_code, 200)
+ response = self.client.get(
+ "/api/monitoring/trends",
+ params={"scope": "site", "scope_id": uuid4(), "range": "6h"},
+ )
+ self.assertEqual(response.status_code, 403)
+ self.assertEqual(self.repository.trend_calls[-1]["scope"], "fleet")
+
+ def test_typed_validation_rejects_unbounded_or_ambiguous_queries(self):
+ self.authenticate(("miners.view", "locations.view"))
+ cases = (
+ {"scope": "fleet", "scope_id": uuid4()},
+ {"scope": "miner"},
+ {"scope": "miner", "scope_id": "bad"},
+ {"scope": "fleet", "range": "30d"},
+ {"scope": "anything"},
+ {"scope": "fleet", "resolution": "1 second"},
+ )
+ for query in cases[:-1]:
+ with self.subTest(query=query):
+ self.assertEqual(
+ self.client.get("/api/monitoring/trends", params=query).status_code,
+ 422,
+ )
+ response = self.client.get("/api/monitoring/trends", params=cases[-1])
+ self.assertEqual(response.status_code, 200)
+ self.assertNotIn("resolution", self.repository.trend_calls[-1])
+
+ def test_typed_envelope_returns_both_series_and_no_store(self):
+ self.authenticate(("miners.view",))
+ response = self.client.get("/api/monitoring/trends?scope=fleet&range=6h")
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ self.repository.trend_calls[-1]["window_end"]
+ - self.repository.trend_calls[-1]["window_start"],
+ timedelta(hours=6),
+ )
+ self.assertEqual(response.headers["cache-control"], "no-store")
+ payload = response.json()
+ self.assertEqual([item["metric"] for item in payload["data"]["series"]],
+ ["hashrate", "power"])
+ self.assertEqual(payload["meta"]["source"], "raw_snapshots")
+ self.assertEqual(payload["meta"]["completeness"], "not_guaranteed")
+ self.assertIsNone(payload["error"])
+
+
+class MonitoringRepositoryIntegrationTests(unittest.TestCase):
+ def setUp(self):
+ self.connection = psycopg.connect(
+ host=settings.postgres_host,
+ port=settings.postgres_port,
+ dbname=settings.postgres_db,
+ user=settings.postgres_user,
+ password=settings.postgres_password,
+ )
+ self.repository = MonitoringRepository(connection=self.connection)
+
+ def tearDown(self):
+ self.connection.rollback()
+ self.connection.close()
+
+ def test_location_scope_options_execute_and_order_deterministically(self):
+ site_alpha, site_beta = uuid4(), uuid4()
+ room_alpha, room_beta = uuid4(), uuid4()
+ suffix = uuid4().hex[:10]
+ with self.connection.cursor() as cur:
+ cur.execute(
+ "INSERT INTO sites (id, name) VALUES (%s, %s), (%s, %s)",
+ (site_alpha, "Scope Alpha " + suffix,
+ site_beta, "scope Beta " + suffix),
+ )
+ cur.execute(
+ "INSERT INTO rooms (id, site_id, name) VALUES "
+ "(%s, %s, %s), (%s, %s, %s)",
+ (room_alpha, site_alpha, "Scope Alpha Room " + suffix,
+ room_beta, site_beta, "scope Beta Room " + suffix),
+ )
+
+ rows = self.repository.list_location_scope_options()
+ site_rows = [row for row in rows if row["option_type"] == "site"]
+ room_rows = [row for row in rows if row["option_type"] == "room"]
+
+ self.assertEqual(
+ [row["option_type"] for row in rows],
+ ["site"] * len(site_rows) + ["room"] * len(room_rows),
+ )
+ ordering = lambda row: (row["name"].lower(), str(row["id"]))
+ self.assertEqual(site_rows, sorted(site_rows, key=ordering))
+ self.assertEqual(room_rows, sorted(room_rows, key=ordering))
+
+ by_id = {row["id"]: row for row in rows}
+ self.assertIsNone(by_id[site_alpha]["site_id"])
+ self.assertIsNone(by_id[site_beta]["site_id"])
+ self.assertEqual(by_id[room_alpha]["site_id"], site_alpha)
+ self.assertEqual(by_id[room_beta]["site_id"], site_beta)
+
+ def test_per_miner_first_math_counts_and_current_location_precedence(self):
+ site_a, site_b, room_a = uuid4(), uuid4(), uuid4()
+ miner_a, miner_b = uuid4(), uuid4()
+ suffix = uuid4().hex[:10]
+ with self.connection.cursor() as cur:
+ cur.execute(
+ "INSERT INTO sites (id, name) VALUES (%s, %s), (%s, %s)",
+ (site_a, "Trend A " + suffix, site_b, "Trend B " + suffix),
+ )
+ cur.execute(
+ "INSERT INTO rooms (id, site_id, name) VALUES (%s, %s, %s)",
+ (room_a, site_a, "Trend Room " + suffix),
+ )
+ cur.execute(
+ "INSERT INTO miners (id, site_id, room_id, asset_tag, lifecycle_status) "
+ "VALUES (%s, %s, NULL, %s, 'active'), "
+ "(%s, %s, %s, %s, 'active')",
+ (miner_a, site_b, "TREND-A-" + suffix,
+ miner_b, site_a, room_a, "TREND-B-" + suffix),
+ )
+ cur.execute(
+ "INSERT INTO location_positions "
+ "(room_id, room_name, rack_name, shelf_name, position_index, miner_id) "
+ "VALUES (%s, %s, %s, '1', 1, %s)",
+ (room_a, "Trend Room " + suffix, "Rack " + suffix, miner_a),
+ )
+ observed = datetime(2026, 8, 17, 11, 56, tzinfo=timezone.utc)
+ cur.execute(
+ "INSERT INTO miner_status_snapshots "
+ "(miner_id, observed_at, mhs_av, power_w) VALUES "
+ "(%s, %s, 100000000, 1000), (%s, %s, 200000000, NULL), "
+ "(%s, %s, 0, 0), (%s, %s, NULL, 3000)",
+ (miner_a, observed, miner_a, observed + timedelta(minutes=1),
+ miner_b, observed, miner_b, observed + timedelta(minutes=1)),
+ )
+ rows = self.repository.read_trends(
+ scope="site", scope_id=site_a,
+ window_start=datetime(2026, 8, 17, 6, 5, tzinfo=timezone.utc),
+ window_end=datetime(2026, 8, 17, 12, 5, tzinfo=timezone.utc),
+ bucket_width=timedelta(minutes=5),
+ )
+ point = next(row for row in rows if row["bucket_start"].hour == 11
+ and row["bucket_start"].minute == 55)
+ self.assertEqual(float(point["hashrate_ths"]), 150.0)
+ self.assertEqual(float(point["power_w"]), 2500.0)
+ self.assertEqual(point["observation_count"], 4)
+ self.assertEqual(point["hashrate_sample_count"], 3)
+ self.assertEqual(point["power_sample_count"], 3)
+ self.assertEqual(point["observed_miner_count"], 2)
+ self.assertEqual(point["hashrate_contributing_miner_count"], 2)
+ self.assertEqual(point["scoped_miner_count"], 2)
+ empty = next(row for row in rows if row["bucket_start"].hour == 11
+ and row["bucket_start"].minute == 50)
+ self.assertIsNone(empty["hashrate_ths"])
+ self.assertEqual(empty["observation_count"], 0)
+ self.assertEqual(len(rows), 72)
+
+ def test_utc_bucket_is_independent_of_session_timezone(self):
+ miner_id = uuid4()
+ with self.connection.cursor() as cur:
+ cur.execute("SET LOCAL TIME ZONE 'Pacific/Honolulu'")
+ cur.execute(
+ "INSERT INTO miners (id, asset_tag, lifecycle_status) "
+ "VALUES (%s, %s, 'active')",
+ (miner_id, "TZ-" + uuid4().hex[:10]),
+ )
+ cur.execute(
+ "INSERT INTO miner_status_snapshots "
+ "(miner_id, observed_at, mhs_av) VALUES (%s, %s, 1000000)",
+ (miner_id, datetime.fromisoformat("2026-08-17T07:01:00-05:00")),
+ )
+ rows = self.repository.read_trends(
+ scope="miner", scope_id=miner_id,
+ window_start=datetime(2026, 8, 17, 6, 5, tzinfo=timezone.utc),
+ window_end=datetime(2026, 8, 17, 12, 5, tzinfo=timezone.utc),
+ bucket_width=timedelta(minutes=5),
+ )
+ value = next(row for row in rows
+ if row["bucket_start"].astimezone(timezone.utc).hour == 12)
+ self.assertEqual(float(value["hashrate_ths"]), 1.0)
+
+ def test_representative_raw_query_is_bounded(self):
+ prefix = "BENCH-" + uuid4().hex[:8]
+ with self.connection.cursor() as cur:
+ cur.execute(
+ "INSERT INTO miners (asset_tag, lifecycle_status) "
+ "SELECT %s || '-' || value, 'active' "
+ "FROM generate_series(1, 500) AS value",
+ (prefix,),
+ )
+ cur.execute(
+ "INSERT INTO miner_status_snapshots "
+ "(miner_id, observed_at, mhs_av, power_w) "
+ "SELECT miners.id, %s::timestamptz - value * interval '1 hour', "
+ "100000000, 3200 FROM miners "
+ "CROSS JOIN generate_series(0, 167) AS value "
+ "WHERE miners.asset_tag LIKE %s",
+ (NOW, prefix + "%"),
+ )
+ started = time.monotonic()
+ rows = self.repository.read_trends(
+ scope="fleet", scope_id=None,
+ window_start=datetime(2026, 8, 10, 13, 0, tzinfo=timezone.utc),
+ window_end=datetime(2026, 8, 17, 13, 0, tzinfo=timezone.utc),
+ bucket_width=timedelta(hours=1),
+ )
+ elapsed = time.monotonic() - started
+ self.assertEqual(len(rows), 168)
+ self.assertLess(elapsed, 15.0)
+ print(f"Monitoring synthetic benchmark: 84,000 rows, 168 points, {elapsed:.3f}s")
+
+
+class MonitoringFrontendTests(unittest.TestCase):
+ def setUp(self):
+ self.app = FastAPI()
+ self.app.include_router(monitoring_frontend_router)
+ self.client = TestClient(self.app)
+
+ def tearDown(self):
+ self.client.close()
+
+ def authenticate(self, permissions):
+ self.app.dependency_overrides[get_current_user] = lambda: {
+ "id": str(uuid4()), "username": "reader", "display_name": "Reader",
+ "role": "Custom", "permissions": list(permissions),
+ }
+
+ def test_page_requires_scope_aligned_permissions_and_renders_shell(self):
+ self.assertEqual(self.client.get("/monitoring/analytics").status_code, 401)
+ self.authenticate(("miners.view",))
+ response = self.client.get("/monitoring/analytics")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn('data-view="analytics"', response.text)
+ self.assertIn("/assets/minerops-monitoring-trends.js", response.text)
+ self.assertNotIn('