feat: add monitoring trends
This commit is contained in:
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" id="minerDetailReturn" href="/assets/miners" hidden>Return</a>
|
||||||
<a class="quiet-link" href="/assets/miners">Back to Fleet</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="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>
|
<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>
|
<button class="quiet-button" id="minerDetailRefresh" type="button">Refresh</button>
|
||||||
</nav>
|
</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"),
|
locationSummary: document.getElementById("minerDetailLocationSummary"),
|
||||||
returnLink: document.getElementById("minerDetailReturn"),
|
returnLink: document.getElementById("minerDetailReturn"),
|
||||||
mapLink: document.getElementById("minerDetailMap"),
|
mapLink: document.getElementById("minerDetailMap"),
|
||||||
|
trendsLink: document.getElementById("minerDetailTrends"),
|
||||||
repairsLink: document.getElementById("minerDetailRepairs"),
|
repairsLink: document.getElementById("minerDetailRepairs"),
|
||||||
identity: document.getElementById("minerIdentityDetails"),
|
identity: document.getElementById("minerIdentityDetails"),
|
||||||
tags: document.getElementById("minerTags"),
|
tags: document.getElementById("minerTags"),
|
||||||
@@ -380,6 +381,9 @@
|
|||||||
el.lifecycle.textContent = "Lifecycle: " + words(data.miner.lifecycle_status);
|
el.lifecycle.textContent = "Lifecycle: " + words(data.miner.lifecycle_status);
|
||||||
renderLocation(data.location, data.miner.id, data.capabilities.can_view_map);
|
renderLocation(data.location, data.miner.id, data.capabilities.can_view_map);
|
||||||
renderRepair(data.repair, data.capabilities);
|
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) {
|
if (data.capabilities.can_open_repair_center) {
|
||||||
const params = new URLSearchParams({ scope: "all", q: data.miner.id });
|
const params = new URLSearchParams({ scope: "all", q: data.miner.id });
|
||||||
el.repairsLink.href = "/operations/repairs?" + params.toString();
|
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();
|
||||||
|
})();
|
||||||
@@ -4,11 +4,13 @@ from html import escape
|
|||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from app.frontend.miner_detail_view import miner_detail_content
|
from app.frontend.miner_detail_view import miner_detail_content
|
||||||
|
from app.frontend.monitoring_trends_view import monitoring_trends_content
|
||||||
|
|
||||||
|
|
||||||
NAVIGATION = (
|
NAVIGATION = (
|
||||||
("dashboard", "Dashboard", "/dashboard", "dashboard.view"),
|
("dashboard", "Dashboard", "/dashboard", "dashboard.view"),
|
||||||
("map", "Mine Map", "/monitoring/map", "locations.view"),
|
("map", "Mine Map", "/monitoring/map", "locations.view"),
|
||||||
|
("analytics", "Trends", "/monitoring/analytics", "miners.view"),
|
||||||
("repairs", "Repair Center", "/operations/repairs", "repairs.view"),
|
("repairs", "Repair Center", "/operations/repairs", "repairs.view"),
|
||||||
("miners", "Miners", "/assets/miners", "miners.view"),
|
("miners", "Miners", "/assets/miners", "miners.view"),
|
||||||
)
|
)
|
||||||
@@ -24,6 +26,11 @@ PAGE_DETAILS = {
|
|||||||
"Mine Map",
|
"Mine Map",
|
||||||
"Inspect where miners and operational conditions are concentrated.",
|
"Inspect where miners and operational conditions are concentrated.",
|
||||||
),
|
),
|
||||||
|
"analytics": (
|
||||||
|
"Monitoring / Analytics",
|
||||||
|
"Trends",
|
||||||
|
"Review bounded historical hashrate and power observations.",
|
||||||
|
),
|
||||||
"repairs": (
|
"repairs": (
|
||||||
"Operations",
|
"Operations",
|
||||||
"Repair Center",
|
"Repair Center",
|
||||||
@@ -450,6 +457,8 @@ def render_app_page(
|
|||||||
content = (
|
content = (
|
||||||
miner_detail_content()
|
miner_detail_content()
|
||||||
if view == "miner_detail"
|
if view == "miner_detail"
|
||||||
|
else monitoring_trends_content(has_permission("locations.view"))
|
||||||
|
if view == "analytics"
|
||||||
else _map_content(has_permission("locations.manage"))
|
else _map_content(has_permission("locations.manage"))
|
||||||
if view == "map"
|
if view == "map"
|
||||||
else _miners_content(has_permission("locations.view"))
|
else _miners_content(has_permission("locations.view"))
|
||||||
@@ -467,6 +476,8 @@ def render_app_page(
|
|||||||
page_script = (
|
page_script = (
|
||||||
'<script src="/assets/minerops-miner-detail.js" defer></script>'
|
'<script src="/assets/minerops-miner-detail.js" defer></script>'
|
||||||
if view == "miner_detail"
|
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>'
|
else '<script src="/assets/minerops-mine-map.js" defer></script>'
|
||||||
if view == "map"
|
if view == "map"
|
||||||
else '<script src="/assets/minerops-fleet.js" defer></script>'
|
else '<script src="/assets/minerops-fleet.js" defer></script>'
|
||||||
@@ -477,6 +488,10 @@ def render_app_page(
|
|||||||
if view == "dashboard"
|
if view == "dashboard"
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
|
page_style = (
|
||||||
|
'<link rel="stylesheet" href="/assets/minerops-monitoring-trends.css">'
|
||||||
|
if view == "analytics" else ""
|
||||||
|
)
|
||||||
object_attributes = ""
|
object_attributes = ""
|
||||||
if miner_id is not None:
|
if miner_id is not None:
|
||||||
object_attributes += f' data-miner-id="{escape(miner_id)}"'
|
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">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{escape(title)} | MinerOps</title>
|
<title>{escape(title)} | MinerOps</title>
|
||||||
<link rel="stylesheet" href="/assets/minerops-app.css">
|
<link rel="stylesheet" href="/assets/minerops-app.css">
|
||||||
|
{page_style}
|
||||||
</head>
|
</head>
|
||||||
<body data-view="{escape(view)}"
|
<body data-view="{escape(view)}"
|
||||||
data-can-view-miners="{str(can_view_miners).lower()}"
|
data-can-view-miners="{str(can_view_miners).lower()}"
|
||||||
|
|||||||
@@ -640,6 +640,7 @@ def protected_page(path: str) -> bool:
|
|||||||
"/system-health",
|
"/system-health",
|
||||||
"/accounts",
|
"/accounts",
|
||||||
"/monitoring/map",
|
"/monitoring/map",
|
||||||
|
"/monitoring/analytics",
|
||||||
"/operations/repairs",
|
"/operations/repairs",
|
||||||
"/assets/miners",
|
"/assets/miners",
|
||||||
}
|
}
|
||||||
@@ -685,6 +686,8 @@ def required_page_permission(path: str) -> str:
|
|||||||
return "repairs.view"
|
return "repairs.view"
|
||||||
if path in {"/locations", "/monitoring/map"}:
|
if path in {"/locations", "/monitoring/map"}:
|
||||||
return "locations.view"
|
return "locations.view"
|
||||||
|
if path == "/monitoring/analytics":
|
||||||
|
return "miners.view"
|
||||||
if path == "/assets/miners":
|
if path == "/assets/miners":
|
||||||
return "miners.view"
|
return "miners.view"
|
||||||
if path.startswith("/agent/"):
|
if path.startswith("/agent/"):
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ from app.inventory.router import router as inventory_router
|
|||||||
from app.locations.router import router as locations_router
|
from app.locations.router import router as locations_router
|
||||||
from app.components.router import router as components_router
|
from app.components.router import router as components_router
|
||||||
from app.miner_detail.router import router as miner_detail_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 (
|
from app.repairs.exceptions import (
|
||||||
ControlBoardReplacementActorRequired,
|
ControlBoardReplacementActorRequired,
|
||||||
InvalidMacAddress,
|
InvalidMacAddress,
|
||||||
@@ -63,6 +65,8 @@ app.include_router(components_router)
|
|||||||
app.include_router(locations_router)
|
app.include_router(locations_router)
|
||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
app.include_router(miner_detail_router)
|
app.include_router(miner_detail_router)
|
||||||
|
app.include_router(monitoring_frontend_router)
|
||||||
|
app.include_router(monitoring_router)
|
||||||
install_identity_middleware(app)
|
install_identity_middleware(app)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Historical monitoring read models."""
|
||||||
@@ -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())
|
||||||
@@ -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."""
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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()]
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
Executable
+96
@@ -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."
|
||||||
Reference in new issue
Block a user