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: Back to Fleet + View trends 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

+
+ + + +
+ Time range + + + +
+ +
+

Fleet trends use current miner inventory.

+
+
+ Loading trendsAggregating bounded raw telemetry. +
+ + +""" 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('