feat: add monitoring trends

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

No files matched your search

@@ -29,6 +29,7 @@ def miner_detail_content() -> str:
<a class="quiet-link" id="minerDetailReturn" href="/assets/miners" hidden>Return</a>
<a class="quiet-link" href="/assets/miners">Back to Fleet</a>
<a class="quiet-link" id="minerDetailMap" href="/monitoring/map" hidden>View on Mine Map</a>
<a class="quiet-link" id="minerDetailTrends" href="/monitoring/analytics">View trends</a>
<a class="quiet-link" id="minerDetailRepairs" href="/operations/repairs" hidden>Open filtered Repair Center</a>
<button class="quiet-button" id="minerDetailRefresh" type="button">Refresh</button>
</nav>
@@ -0,0 +1,52 @@
"""Semantic HTML for the Internal Alpha Monitoring Trends page."""
def monitoring_trends_content(can_view_locations: bool) -> str:
location_options = (
'<option value="site">Site</option><option value="room">Room</option>'
if can_view_locations
else ""
)
return f"""
<section class="trend-controls panel" aria-labelledby="trendControlsTitle">
<div class="panel-head"><div><p class="eyebrow">Historical scope</p>
<h2 id="trendControlsTitle">Trend controls</h2></div>
<p class="freshness" id="trendFreshness">Not loaded</p></div>
<form id="trendControls">
<label for="trendScope">Scope
<select id="trendScope">
<option value="fleet">Fleet</option>{location_options}
</select>
</label>
<label for="trendSite" id="trendSiteLabel" hidden>Site
<select id="trendSite"></select>
</label>
<label for="trendRoom" id="trendRoomLabel" hidden>Room
<select id="trendRoom"></select>
</label>
<fieldset class="trend-ranges">
<legend>Time range</legend>
<label><input type="radio" name="trendRange" value="6h">6 hours</label>
<label><input type="radio" name="trendRange" value="24h" checked>24 hours</label>
<label><input type="radio" name="trendRange" value="7d">7 days</label>
</fieldset>
<button class="command-button" type="submit">Apply</button>
</form>
<p class="panel-copy" id="trendScopeNote">Fleet trends use current miner inventory.</p>
</section>
<div class="state-message" id="trendLoading" role="status">
<strong>Loading trends</strong><span>Aggregating bounded raw telemetry.</span>
</div>
<div class="state-message" id="trendError" data-kind="error" role="alert" hidden>
<strong>Trends unavailable</strong><span id="trendErrorText">The request failed.</span>
<button class="command-button" id="trendRetry" type="button">Retry</button>
</div>
<section id="trendContent" aria-label="Historical telemetry trends" hidden>
<div class="trend-summary" aria-live="polite">
<p><strong id="trendScopeLabel">Fleet</strong></p>
<p id="trendRangeSummary"></p>
<p id="trendTimezone"></p>
</div>
<div id="trendCharts" class="trend-charts"></div>
</section>
"""
@@ -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();
})();
+16
View File
@@ -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 = (
'<script src="/assets/minerops-miner-detail.js" defer></script>'
if view == "miner_detail"
else '<script src="/assets/minerops-monitoring-trends.js" defer></script>'
if view == "analytics"
else '<script src="/assets/minerops-mine-map.js" defer></script>'
if view == "map"
else '<script src="/assets/minerops-fleet.js" defer></script>'
@@ -477,6 +488,10 @@ def render_app_page(
if view == "dashboard"
else ""
)
page_style = (
'<link rel="stylesheet" href="/assets/minerops-monitoring-trends.css">'
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(
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escape(title)} | MinerOps</title>
<link rel="stylesheet" href="/assets/minerops-app.css">
{page_style}
</head>
<body data-view="{escape(view)}"
data-can-view-miners="{str(can_view_miners).lower()}"
+3
View File
@@ -640,6 +640,7 @@ def protected_page(path: str) -> 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/"):
+4
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Historical monitoring read models."""
+8
View File
@@ -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())
+13
View File
@@ -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."""
+70
View File
@@ -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),
)
+98
View File
@@ -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
+237
View File
@@ -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()]
+61
View File
@@ -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
+173
View File
@@ -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,
),
)
+448
View File
@@ -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": '<script>alert("site")</script>'},
{"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('<option value="site">', response.text)
response = self.client.get(
"/monitoring/analytics",
params={"scope": "site", "scope_id": uuid4(), "range": "24h"},
)
self.assertEqual(response.status_code, 403)
self.authenticate(("miners.view", "locations.view"))
self.assertEqual(self.client.get(
"/monitoring/analytics",
params={"scope": "site", "scope_id": uuid4(), "range": "24h"},
).status_code, 200)
def test_script_is_one_request_read_only_safe_dom_and_accessible(self):
source = (STATIC_ROOT / "monitoring-trends.js").read_text(encoding="utf-8")
self.assertEqual(source.count('fetch(url,'), 1)
self.assertIn("return el.site.value || state.scopeId", source)
self.assertIn("return el.room.value || state.scopeId", source)
self.assertNotIn("setInterval", source)
for method in ('method: "POST"', 'method: "PATCH"', 'method: "PUT"',
'method: "DELETE"'):
self.assertNotIn(method, source)
for unsafe in ("innerHTML", "outerHTML", "insertAdjacentHTML",
"document.write", "eval(", "Function("):
self.assertNotIn(unsafe, source)
for expected in ("textContent", "tabindex", "aria-label", "View ",
"Gaps indicate", "prefers-reduced-motion"):
combined = source + (STATIC_ROOT / "monitoring-trends.css").read_text()
self.assertIn(expected, combined)
for forbidden in ("threshold", "healthy range", "kWh", "30d"):
self.assertNotIn(forbidden, source)
def test_navigation_identity_and_miner_detail_use_canonical_durable_link(self):
self.assertTrue(protected_page("/monitoring/analytics"))
self.assertEqual(required_page_permission("/monitoring/analytics"), "miners.view")
detail_html = (APP_ROOT / "frontend" / "miner_detail_view.py").read_text()
detail_js = (STATIC_ROOT / "miner-detail.js").read_text()
self.assertIn("View trends", detail_html)
self.assertIn('scope: "miner", scope_id: data.miner.id, range: "24h"', detail_js)
self.assertIn('"/monitoring/analytics?"', detail_js)
class MonitoringArchitectureTests(unittest.TestCase):
def test_layering_routes_and_sql_are_bounded(self):
for relative in ("monitoring/router.py", "monitoring/service.py",
"monitoring/frontend.py"):
source = (APP_ROOT / relative).read_text(encoding="utf-8")
modules = []
for node in ast.walk(ast.parse(source)):
if isinstance(node, ast.Import):
modules.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
modules.append(node.module)
self.assertFalse(any(name.startswith(("psycopg", "asyncpg", "sqlalchemy"))
for name in modules))
for statement in ("SELECT ", "INSERT ", "UPDATE ", "DELETE "):
self.assertNotIn(statement, source.upper())
service = (APP_ROOT / "monitoring" / "service.py").read_text()
repository = (APP_ROOT / "monitoring" / "repository.py").read_text()
self.assertNotIn("fastapi", service.lower())
self.assertEqual(repository.count("cur.execute("), 2)
self.assertIn("miner_status_snapshots", TREND_SQL)
self.assertNotIn("miner_status_hourly_rollups", TREND_SQL)
self.assertNotIn("miner_status_daily_rollups", TREND_SQL)
self.assertIn("date_bin", TREND_SQL)
self.assertIn("%s", TREND_SQL)
self.assertEqual([(route.path, route.methods) for route in monitoring_router.routes],
[("/api/monitoring/trends", {"GET"})])
if __name__ == "__main__":
unittest.main()
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${repo_root}"
readonly source_files=(
backend/app/frontend/miner_detail_view.py
backend/app/frontend/monitoring_trends_view.py
backend/app/frontend/static/app.css
backend/app/frontend/static/miner-detail.js
backend/app/frontend/static/monitoring-trends.css
backend/app/frontend/static/monitoring-trends.js
backend/app/frontend/views.py
backend/app/identity.py
backend/app/main.py
backend/app/monitoring/__init__.py
backend/app/monitoring/dependencies.py
backend/app/monitoring/exceptions.py
backend/app/monitoring/frontend.py
backend/app/monitoring/models.py
backend/app/monitoring/repository.py
backend/app/monitoring/router.py
backend/app/monitoring/service.py
backend/tests/test_monitoring_trends.py
)
for file in "${source_files[@]}"; do
if [[ ! -r "${file}" ]]; then
echo "Monitoring Trends test input is not readable: ${file}" >&2
exit 1
fi
if [[ "$(stat -c '%a' "${file}")" != "644" ]]; then
echo "Monitoring Trends source/test mode must be 0644: ${file}" >&2
exit 1
fi
done
if [[ ! -x scripts/test-monitoring-trends.sh ]] \
|| [[ "$(stat -c '%a' scripts/test-monitoring-trends.sh)" != "755" ]]; then
echo "scripts/test-monitoring-trends.sh must be executable with mode 0755." >&2
exit 1
fi
echo "Monitoring Trends file-mode preflight passed."
if [[ ! -f .env.dev ]]; then
echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
exit 1
fi
project_name="${MINEROPS_MONITORING_TEST_PROJECT:-minerops-monitoring-$$}"
compose=(
docker compose
--project-name "${project_name}"
--env-file .env.dev
-f compose.dev.yaml
)
cleanup() {
status=$?
if (( status != 0 )); then
echo
echo "Monitoring Trends tests failed. Recent PostgreSQL logs:"
"${compose[@]}" logs --no-color --tail 120 postgres || true
fi
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
exit "${status}"
}
trap cleanup EXIT
echo "Starting disposable Monitoring Trends database..."
"${compose[@]}" up --detach postgres
for attempt in $(seq 1 60); do
if "${compose[@]}" exec -T postgres sh -ec \
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1; then
break
fi
if (( attempt == 60 )); then
echo "PostgreSQL did not become ready." >&2
exit 1
fi
sleep 1
done
echo "Building Monitoring Trends test image..."
"${compose[@]}" build backend
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
echo "Running Monitoring Trends focused tests..."
"${compose[@]}" run --rm --no-deps \
-v "${repo_root}/backend/tests:/app/tests:ro" \
backend python -m unittest -v \
tests.test_monitoring_trends \
tests.test_miner_detail \
tests.test_frontend_shell
echo "Monitoring Trends focused tests passed."