feat: add miner detail view

This commit is contained in:
lussierj committed 2026-08-17 18:43:20 +00:00
1 parent b8b741ccee
commit 19fc29c72c
22 files changed
+1882 -10

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
"""Semantic HTML for the Internal Alpha Miner Detail page."""
def miner_detail_content() -> str:
return """
<div class="state-message" id="minerDetailLoading" role="status">
<strong>Loading miner</strong>
<span>Requesting current identity and operational context.</span>
</div>
<div class="state-message" id="minerDetailError" data-kind="error" role="alert" hidden>
<strong>Miner detail unavailable</strong>
<span id="minerDetailErrorText">The miner could not be loaded.</span>
<button class="command-button" id="minerDetailRetry" type="button">Retry</button>
</div>
<div id="minerDetailContent" hidden>
<section class="miner-detail-hero" aria-labelledby="minerIdentityTitle">
<div>
<p class="eyebrow">Durable miner identity</p>
<h2 id="minerIdentityTitle">Miner</h2>
<p id="minerDetailModel" class="panel-copy">Model unavailable</p>
<p id="minerDetailUuid" class="miner-detail-uuid"></p>
</div>
<div class="miner-detail-state">
<strong id="minerDetailOperational">Operational state unknown</strong>
<span id="minerDetailLifecycle">Lifecycle unavailable</span>
<span id="minerDetailLocationSummary">Location unavailable</span>
</div>
<nav class="miner-detail-actions" aria-label="Miner destinations">
<a class="quiet-link" id="minerDetailReturn" href="/assets/miners" hidden>Return</a>
<a class="quiet-link" href="/assets/miners">Back to Fleet</a>
<a class="quiet-link" id="minerDetailMap" href="/monitoring/map" hidden>View on Mine Map</a>
<a class="quiet-link" id="minerDetailRepairs" href="/operations/repairs" hidden>Open filtered Repair Center</a>
<button class="quiet-button" id="minerDetailRefresh" type="button">Refresh</button>
</nav>
</section>
<div class="miner-detail-grid">
<section class="panel" aria-labelledby="minerIdentitySectionTitle">
<div class="panel-head"><div><p class="eyebrow">Asset record</p>
<h2 id="minerIdentitySectionTitle">Identity</h2></div></div>
<dl class="definition-list" id="minerIdentityDetails"></dl>
<div id="minerTags" class="tag-list" aria-label="Miner tags"></div>
</section>
<section class="panel" aria-labelledby="minerCurrentStateTitle">
<div class="panel-head"><div><p class="eyebrow">Latest facts</p>
<h2 id="minerCurrentStateTitle">Current state</h2></div>
<span class="freshness" id="minerTelemetryAge">No observation</span></div>
<dl class="metric-grid" id="minerTelemetryDetails"></dl>
</section>
</div>
<section class="panel" id="minerLocationSection" aria-labelledby="minerLocationTitle">
<div class="panel-head"><div><p class="eyebrow">Physical context</p>
<h2 id="minerLocationTitle">Location</h2></div></div>
<div id="minerLocationBody"></div>
</section>
<section class="panel" id="minerRepairSection" aria-labelledby="minerRepairTitle">
<div class="panel-head"><div><p class="eyebrow">Work context</p>
<h2 id="minerRepairTitle">Repair context</h2></div></div>
<div id="minerRepairBody"></div>
<div id="minerRepairEpisodes" class="disclosure-list"></div>
</section>
<section class="panel" aria-labelledby="minerHistoryTitle">
<div class="panel-head"><div><p class="eyebrow">Recorded facts</p>
<h2 id="minerHistoryTitle">History</h2></div></div>
<div class="disclosure-list">
<details id="minerIdentityHistory">
<summary>Identity history</summary>
<div class="lazy-content" id="minerIdentityHistoryBody" aria-live="polite">Open to load Asset ID and network identity history.</div>
</details>
<details id="minerObservations">
<summary>Recent observations</summary>
<div class="lazy-content" id="minerObservationsBody" aria-live="polite">Open to load up to 100 recent factual observations.</div>
</details>
</div>
</section>
</div>
"""
+31 -3
View File
@@ -2,12 +2,13 @@
from pathlib import Path
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, 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.identity import require_permission, safe_next_path, user_has_permission
router = APIRouter(tags=["frontend"])
@@ -19,11 +20,12 @@ RepairUser = Annotated[dict, Depends(require_permission("repairs.view"))]
MinerUser = Annotated[dict, Depends(require_permission("miners.view"))]
def render_page(view: str, current_user: dict) -> str:
def render_page(view: str, current_user: dict, **context) -> str:
return render_app_page(
view,
current_user,
lambda permission: user_has_permission(current_user, permission),
**context,
)
@@ -81,6 +83,15 @@ def repair_center_script() -> Response:
headers={"Cache-Control": "no-store"},
)
@router.get("/assets/minerops-miner-detail.js", include_in_schema=False)
def miner_detail_script() -> Response:
return Response(
(STATIC_ROOT / "miner-detail.js").read_text(encoding="utf-8"),
media_type="text/javascript",
headers={"Cache-Control": "no-store"},
)
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
def dashboard_page(current_user: DashboardUser) -> str:
return render_page("dashboard", current_user)
@@ -111,3 +122,20 @@ def repair_center_page(current_user: RepairUser) -> str:
)
def miners_page(current_user: MinerUser) -> str:
return render_page("miners", current_user)
@router.get(
"/miners/{miner_id}/detail",
response_class=HTMLResponse,
include_in_schema=False,
)
def miner_detail_page(
miner_id: UUID,
current_user: MinerUser,
return_to: Annotated[str | None, Query(max_length=2048)] = None,
) -> str:
safe_return = safe_next_path(return_to) if return_to else None
if safe_return != return_to:
safe_return = None
return render_page("miner_detail", current_user, miner_id=str(miner_id),
return_to=safe_return)
+104
View File
@@ -1618,3 +1618,107 @@ button.map-position:hover {
.dashboard-section *,
.attention-list * { scroll-behavior: auto; }
}
/* Internal Alpha Miner Detail */
.miner-detail-hero {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(220px, .8fr);
gap: 18px;
align-items: start;
border: 1px solid var(--line);
border-left: 5px solid var(--accent);
background: #fff;
padding: 18px;
margin-bottom: 16px;
}
.miner-detail-hero h2 { margin: 2px 0 5px; font-size: 24px; }
.miner-detail-uuid { color: var(--muted); font-family: ui-monospace, monospace; font-size: 12px; overflow-wrap: anywhere; }
.miner-detail-state { display: grid; gap: 6px; justify-items: start; }
.miner-detail-state strong {
border: 1px solid var(--line);
border-left: 4px solid #596675;
padding: 8px 10px;
background: #f7f8f9;
}
.miner-detail-state strong[data-status="online"] { border-left-color: var(--success); }
.miner-detail-state strong[data-status="offline"] { border-left-color: var(--danger); }
.miner-detail-state strong[data-status="unstable"] { border-left-color: var(--warn); }
.miner-detail-state strong[data-status="repair"] { border-left-color: #6b4ca5; }
.miner-detail-actions {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.miner-detail-actions .quiet-button { color: var(--ink); }
.miner-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.miner-detail-grid .panel { margin: 0 0 16px; }
.metric-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin: 0;
}
.metric-grid > div {
border: 1px solid var(--line);
border-left: 3px solid #7b8792;
padding: 9px 10px;
background: #f8f9fa;
}
.metric-grid dt { color: var(--muted); font-size: 12px; font-weight: 700; }
.metric-grid dd { margin: 4px 0 0; font-size: 16px; font-weight: 800; overflow-wrap: anywhere; }
.tag-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.tag { border: 1px solid #aeb8c2; background: #f4f6f7; padding: 3px 7px; font-size: 12px; }
.disclosure-list { display: grid; gap: 9px; margin-top: 12px; }
.disclosure-list details {
border: 1px solid var(--line);
background: #fff;
}
.disclosure-list summary {
cursor: pointer;
min-height: 44px;
padding: 11px 13px;
font-weight: 750;
}
.disclosure-list summary:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.lazy-content,
.disclosure-list details > .definition-list,
.disclosure-list details > .permission-note { padding: 0 13px 13px; }
.repair-episode-summary { display: flex; justify-content: space-between; gap: 12px; }
.repair-episode-summary .secondary { font-weight: 500; }
.repair-timeline-list { display: grid; gap: 10px; padding-left: 24px; }
.repair-timeline-list li { padding-left: 4px; }
.repair-timeline-list li > span { display: block; margin-top: 3px; }
.event-payload { margin-top: 7px; }
.compact-history-table { margin-top: 8px; max-height: 420px; overflow: auto; }
.compact-history-table th { position: sticky; top: 0; background: #eef1f3; }
.permission-note { color: #5b466f; border-left: 4px solid #8b6cad; padding: 9px 11px; background: #f7f3fa; }
.empty-copy { color: var(--muted); }
@media (max-width: 820px) {
.miner-detail-hero,
.miner-detail-grid { grid-template-columns: 1fr; }
.miner-detail-actions { grid-column: 1; }
}
@media (max-width: 560px) {
.miner-detail-hero { padding: 13px; }
.metric-grid { grid-template-columns: 1fr; }
.miner-detail-actions > * { width: 100%; text-align: center; }
.repair-episode-summary { align-items: flex-start; flex-direction: column; }
.compact-history-table table { min-width: 620px; }
}
@media (prefers-reduced-motion: reduce) {
.miner-detail-hero *, .disclosure-list * { scroll-behavior: auto; }
}
+3 -1
View File
@@ -112,7 +112,9 @@
}
function detailLink(miner) {
return "/miners/" + encodeURIComponent(miner.id) + "/detail";
const returnTo = window.location.pathname + window.location.search;
const path = "/miners/" + encodeURIComponent(miner.id) + "/detail";
return path + "?return_to=" + encodeURIComponent(returnTo);
}
function mapLink(miner) {
+421
View File
@@ -0,0 +1,421 @@
(function () {
"use strict";
if (document.body.dataset.view !== "miner_detail") return;
const minerId = document.body.dataset.minerId;
const returnTo = document.body.dataset.returnTo || "";
const el = {
loading: document.getElementById("minerDetailLoading"),
error: document.getElementById("minerDetailError"),
errorText: document.getElementById("minerDetailErrorText"),
content: document.getElementById("minerDetailContent"),
retry: document.getElementById("minerDetailRetry"),
refresh: document.getElementById("minerDetailRefresh"),
title: document.getElementById("minerIdentityTitle"),
model: document.getElementById("minerDetailModel"),
uuid: document.getElementById("minerDetailUuid"),
operational: document.getElementById("minerDetailOperational"),
lifecycle: document.getElementById("minerDetailLifecycle"),
locationSummary: document.getElementById("minerDetailLocationSummary"),
returnLink: document.getElementById("minerDetailReturn"),
mapLink: document.getElementById("minerDetailMap"),
repairsLink: document.getElementById("minerDetailRepairs"),
identity: document.getElementById("minerIdentityDetails"),
tags: document.getElementById("minerTags"),
telemetry: document.getElementById("minerTelemetryDetails"),
telemetryAge: document.getElementById("minerTelemetryAge"),
location: document.getElementById("minerLocationBody"),
repair: document.getElementById("minerRepairBody"),
episodes: document.getElementById("minerRepairEpisodes"),
identityHistory: document.getElementById("minerIdentityHistory"),
identityHistoryBody: document.getElementById("minerIdentityHistoryBody"),
observations: document.getElementById("minerObservations"),
observationsBody: document.getElementById("minerObservationsBody")
};
function create(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function display(value, fallback) {
if (value === null || value === undefined || value === "") {
return fallback === undefined ? "Unavailable" : fallback;
}
return String(value);
}
function words(value) {
const text = display(value, "unknown").replaceAll("_", " ").toLowerCase();
return text.charAt(0).toUpperCase() + text.slice(1);
}
function dateTime(value) {
if (!value) return "Unavailable";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? "Unavailable" : parsed.toLocaleString();
}
function age(value) {
if (!value) return "No observation available";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "Observation time unavailable";
const seconds = Math.max(0, Math.floor((Date.now() - parsed.getTime()) / 1000));
if (seconds < 60) return seconds + " seconds ago";
if (seconds < 3600) return Math.floor(seconds / 60) + " minutes ago";
if (seconds < 86400) return Math.floor(seconds / 3600) + " hours ago";
return Math.floor(seconds / 86400) + " days ago";
}
function metric(value, suffix) {
if (value === null || value === undefined || value === "") return "Unavailable";
const number = Number(value);
if (!Number.isFinite(number)) return "Unavailable";
return String(number) + suffix;
}
function addDefinition(list, label, value) {
const row = create("div");
row.append(create("dt", "", label), create("dd", "", display(value)));
list.appendChild(row);
}
function prefixed(value, prefix) {
const label = display(value, "");
if (!label) return "";
return new RegExp("^" + prefix + "\\b", "i").test(label)
? label : prefix + " " + label;
}
function locationText(location) {
if (!location) return "Not mapped";
const parts = [location.site_name, location.room_name,
prefixed(location.rack_label, "Rack"),
prefixed(location.shelf_label, "Shelf"),
location.position_label || (location.position_index === null
? "Position unavailable" : "Position " + location.position_index)];
return parts.filter(Boolean).join(" / ");
}
async function api(path) {
const response = await fetch(path, {
credentials: "same-origin",
headers: { Accept: "application/json" }
});
if (response.status === 401) {
window.location.assign("/login?next=" + encodeURIComponent(
window.location.pathname + window.location.search
));
throw new Error("Login required");
}
let payload = null;
try { payload = await response.json(); } catch (_error) { payload = null; }
if (!response.ok) {
throw new Error(payload && payload.detail
? String(payload.detail) : "Request failed (" + response.status + ")");
}
return payload ? payload.data : null;
}
function table(headers, rows) {
const wrap = create("div", "table-wrap compact-history-table");
const tableNode = create("table");
const head = create("thead");
const headRow = create("tr");
headers.forEach(function (header) { headRow.appendChild(create("th", "", header)); });
head.appendChild(headRow);
const body = create("tbody");
rows.forEach(function (values) {
const row = create("tr");
values.forEach(function (value) { row.appendChild(create("td", "", display(value))); });
body.appendChild(row);
});
tableNode.append(head, body);
wrap.appendChild(tableNode);
return wrap;
}
function renderIdentity(miner) {
const identity = miner.asset_id || miner.current_mac || "Miner " + miner.id.slice(0, 8);
el.title.textContent = identity;
el.model.textContent = display(miner.model, "Model unavailable");
el.uuid.textContent = "Miner UUID: " + miner.id;
const pageTitle = document.querySelector(".page-head h1");
if (pageTitle) pageTitle.textContent = identity;
document.title = identity + " | MinerOps";
el.identity.replaceChildren();
[
["Asset ID", miner.asset_id], ["Model", miner.model],
["Miner serial", miner.miner_sn], ["Control-board serial", miner.control_board_sn],
["PSU serial", miner.psu_serial], ["Current MAC", miner.current_mac],
["Current IP", miner.current_ip], ["Hostname", miner.hostname],
["Firmware", miner.firmware_version], ["Created", dateTime(miner.created_at)],
["First seen", dateTime(miner.first_seen_at)], ["Last seen", dateTime(miner.last_seen_at)]
].forEach(function (item) { addDefinition(el.identity, item[0], item[1]); });
el.tags.replaceChildren();
(miner.tags || []).forEach(function (tag) {
el.tags.appendChild(create("span", "tag", tag.name));
});
}
function renderTelemetry(miner) {
el.telemetry.replaceChildren();
[
["Runtime", words(miner.runtime_status)],
["Observed", dateTime(miner.observed_at)],
["Hashrate", metric(miner.hashrate_ths, " TH/s")],
["Power", metric(miner.power_w, " W")],
["Max chip temperature", metric(miner.chip_temp_max_c, " C")],
["Hash stability", miner.hash_stable === null || miner.hash_stable === undefined
? "Unavailable" : miner.hash_stable ? "Stable" : "Unstable"]
].forEach(function (item) { addDefinition(el.telemetry, item[0], item[1]); });
el.telemetryAge.textContent = age(miner.observed_at);
}
function renderLocation(section, minerIdValue, canViewMap) {
el.location.replaceChildren();
if (section.restricted) {
el.location.appendChild(create("p", "permission-note", "Location information restricted."));
el.locationSummary.textContent = "Location restricted";
return;
}
if (!section.location) {
el.location.appendChild(create("p", "empty-copy", "Not mapped"));
el.locationSummary.textContent = "Not mapped";
return;
}
const location = section.location;
const list = create("dl", "definition-list");
[["Site", location.site_name], ["Room", location.room_name],
["Rack", prefixed(location.rack_label, "Rack")],
["Shelf", prefixed(location.shelf_label, "Shelf")],
["Position", location.position_label || (location.position_index === null
? null : "Position " + location.position_index)]
].forEach(function (item) { addDefinition(list, item[0], item[1]); });
el.location.appendChild(list);
const summary = locationText(location);
el.locationSummary.textContent = summary;
if (canViewMap) {
const params = new URLSearchParams({ miner_id: minerIdValue });
params.set("site_id", location.site_id);
params.set("room_id", location.room_id);
el.mapLink.href = "/monitoring/map?" + params.toString();
el.mapLink.hidden = false;
}
}
function eventPayload(payload) {
const list = create("dl", "definition-list event-payload");
Object.entries(payload || {}).forEach(function (entry) {
const value = typeof entry[1] === "object"
? JSON.stringify(entry[1]) : entry[1];
addDefinition(list, words(entry[0]), value);
});
return list;
}
function renderRepairDetails(target, timeline, parts, canReadComponents) {
target.replaceChildren();
const events = timeline && timeline.events ? timeline.events : [];
const heading = create("h4", "", "Timeline");
const timelineList = create("ol", "repair-timeline-list");
events.forEach(function (event) {
const item = create("li");
item.append(create("strong", "", words(event.event_type)),
create("span", "secondary", dateTime(event.occurred_at) + " / " +
display(event.actor && event.actor.display_name, "Unknown actor")),
eventPayload(event.payload));
timelineList.appendChild(item);
});
if (!events.length) timelineList.appendChild(create("li", "empty-copy", "No timeline events recorded."));
target.append(heading, timelineList, create("h4", "", "Parts and components"));
const entries = parts && parts.entries ? parts.entries : [];
if (!entries.length) {
target.appendChild(create("p", "empty-copy", "No parts recorded for this repair."));
return;
}
const rows = entries.map(function (entry) {
let description = "Tracked component";
if (entry.consumable) {
description = entry.consumable.description + " / " +
entry.consumable.quantity + " " + entry.consumable.unit;
} else if (entry.component_snapshot) {
const snapshot = entry.component_snapshot;
description = [words(snapshot.component_type), snapshot.asset_id,
snapshot.manufacturer, snapshot.model, snapshot.manufacturer_serial]
.filter(Boolean).join(" / ");
if (canReadComponents && entry.component_id) {
description += " / Component UUID " + entry.component_id;
}
}
return [description, entry.notes, dateTime(entry.recorded_at),
entry.voided_at ? "Voided: " + display(entry.void_reason) : "Recorded"];
});
target.appendChild(table(["Part or component", "Notes", "Recorded", "State"], rows));
}
function repairEpisode(episode, canReadComponents) {
const details = create("details");
const summary = create("summary", "repair-episode-summary");
summary.append(create("strong", "", (episode.source === "legacy" ? "Legacy compatibility" : words(episode.status))),
create("span", "secondary", [words(episode.reason), dateTime(episode.opened_at)]
.filter(function (value) { return value !== "Unavailable"; }).join(" / ")));
details.appendChild(summary);
const overview = create("dl", "definition-list");
[["Status", words(episode.status)], ["Reason", words(episode.reason)],
["Opened", dateTime(episode.opened_at)], ["Closed", dateTime(episode.closed_at)],
["Latest context", episode.latest_context], ["Parts", episode.parts_count]
].forEach(function (item) { addDefinition(overview, item[0], item[1]); });
details.appendChild(overview);
if (episode.source === "modular" && episode.repair_id) {
const lazy = create("div", "lazy-content", "Open to load timeline and parts.");
details.appendChild(lazy);
details.addEventListener("toggle", async function () {
if (!details.open || details.dataset.loaded || details.dataset.loading) return;
details.dataset.loading = "true";
lazy.textContent = "Loading repair timeline and parts...";
try {
const results = await Promise.all([
api("/api/repairs/" + encodeURIComponent(episode.repair_id) + "/events"),
api("/api/repairs/" + encodeURIComponent(episode.repair_id) + "/parts")
]);
renderRepairDetails(lazy, results[0], results[1], canReadComponents);
details.dataset.loaded = "true";
} catch (error) {
lazy.textContent = "Repair history unavailable: " + error.message;
} finally { delete details.dataset.loading; }
});
} else {
details.appendChild(create("p", "permission-note",
"Legacy compatibility context has no modular repair timeline."));
}
return details;
}
function renderRepair(section, capabilities) {
el.repair.replaceChildren();
el.episodes.replaceChildren();
if (section.restricted) {
el.repair.appendChild(create("p", "permission-note", "Repair information restricted."));
return;
}
if (section.active_repair) {
const active = section.active_repair;
const list = create("dl", "definition-list");
[["Status", words(active.status)], ["Reason", words(active.reason)],
["Opened", dateTime(active.opened_at)], ["Source", "Modular repair workflow"],
["Latest context", active.latest_context], ["Parts", active.parts_count]
].forEach(function (item) { addDefinition(list, item[0], item[1]); });
el.repair.appendChild(list);
} else if ((section.episodes || []).some(function (item) { return item.source === "legacy" && item.active; })) {
el.repair.appendChild(create("p", "permission-note", "Legacy compatibility repair context is recorded. No modular repair UUID exists."));
} else {
el.repair.appendChild(create("p", "empty-copy", "No active repair"));
}
(section.episodes || []).forEach(function (episode) {
el.episodes.appendChild(repairEpisode(episode, capabilities.can_read_components));
});
}
async function loadIdentityHistory() {
if (el.identityHistory.dataset.loaded || el.identityHistory.dataset.loading) return;
el.identityHistory.dataset.loading = "true";
el.identityHistoryBody.textContent = "Loading identity history...";
try {
const results = await Promise.all([
api("/api/inventory/miners/" + encodeURIComponent(minerId) + "/asset-id-history"),
api("/api/miners/" + encodeURIComponent(minerId) + "/network-identities")
]);
el.identityHistoryBody.replaceChildren(create("h3", "", "Asset ID history"));
const assetRows = (results[0] || []).map(function (entry) {
return [entry.old_asset_id, entry.new_asset_id, dateTime(entry.changed_at),
entry.changed_by_display_name];
});
el.identityHistoryBody.appendChild(assetRows.length
? table(["Previous", "New", "Changed", "Actor"], assetRows)
: create("p", "empty-copy", "No Asset ID changes recorded."));
el.identityHistoryBody.appendChild(create("h3", "", "Network identity history"));
const networkRows = (results[1] || []).map(function (entry) {
return [entry.mac_address, entry.ip_address, entry.hostname,
entry.is_current ? "Current" : "Historical", dateTime(entry.last_seen_at)];
});
el.identityHistoryBody.appendChild(networkRows.length
? table(["MAC", "IP", "Hostname", "State", "Last seen"], networkRows)
: create("p", "empty-copy", "No network identity history recorded."));
el.identityHistory.dataset.loaded = "true";
} catch (error) {
el.identityHistoryBody.textContent = "Identity history unavailable: " + error.message;
} finally { delete el.identityHistory.dataset.loading; }
}
async function loadObservations() {
if (el.observations.dataset.loaded || el.observations.dataset.loading) return;
el.observations.dataset.loading = "true";
el.observationsBody.textContent = "Loading recent observations...";
try {
const observations = await api("/api/miners/" + encodeURIComponent(minerId) + "/status-snapshots");
const rows = (observations || []).map(function (item) {
return [dateTime(item.observed_at), metric(item.mhs_av === null ? null : Number(item.mhs_av) / 1000000, " TH/s"),
metric(item.power_w, " W"), metric(item.chip_temp_max_c, " C"),
item.hash_stable === null || item.hash_stable === undefined
? "Unavailable" : item.hash_stable ? "Stable" : "Unstable"];
});
el.observationsBody.replaceChildren(rows.length
? table(["Observed", "Hashrate", "Power", "Max chip temp", "Hash stability"], rows)
: create("p", "empty-copy", "No observations recorded."));
el.observations.dataset.loaded = "true";
} catch (error) {
el.observationsBody.textContent = "Recent observations unavailable: " + error.message;
} finally { delete el.observations.dataset.loading; }
}
function renderOverview(data) {
renderIdentity(data.miner);
renderTelemetry(data.miner);
el.operational.textContent = words(data.operational_state);
el.operational.dataset.status = data.operational_state;
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);
if (data.capabilities.can_open_repair_center) {
const params = new URLSearchParams({ scope: "all", q: data.miner.id });
el.repairsLink.href = "/operations/repairs?" + params.toString();
el.repairsLink.hidden = false;
}
if (returnTo) {
el.returnLink.href = returnTo;
el.returnLink.textContent = returnTo.startsWith("/monitoring/map")
? "Return to Mine Map" : "Return to previous view";
el.returnLink.hidden = false;
}
}
async function loadOverview() {
el.loading.hidden = false;
el.error.hidden = true;
el.content.hidden = true;
try {
const data = await api("/api/miners/" + encodeURIComponent(minerId) + "/overview");
renderOverview(data);
el.loading.hidden = true;
el.content.hidden = false;
} catch (error) {
el.loading.hidden = true;
el.error.hidden = false;
el.errorText.textContent = error.message;
}
}
el.retry.addEventListener("click", loadOverview);
el.refresh.addEventListener("click", loadOverview);
el.identityHistory.addEventListener("toggle", function () {
if (el.identityHistory.open) loadIdentityHistory();
});
el.observations.addEventListener("toggle", function () {
if (el.observations.open) loadObservations();
});
loadOverview();
})();
+3 -1
View File
@@ -193,7 +193,9 @@
const label = item.asset_id || item.current_mac || "Miner " + item.miner_id.slice(0, 8);
if (item.capabilities.can_open_miner) {
const link = create("a", "object-link", label);
link.href = "/miners/" + encodeURIComponent(item.miner_id) + "/detail";
const returnTo = window.location.pathname + window.location.search;
const path = "/miners/" + encodeURIComponent(item.miner_id) + "/detail";
link.href = path + "?return_to=" + encodeURIComponent(returnTo);
wrap.appendChild(link);
} else wrap.appendChild(create("strong", "", label));
const details = [item.model, item.miner_sn, item.current_mac, item.current_ip]
+24 -4
View File
@@ -3,6 +3,8 @@
from html import escape
from typing import Callable
from app.frontend.miner_detail_view import miner_detail_content
NAVIGATION = (
("dashboard", "Dashboard", "/dashboard", "dashboard.view"),
@@ -32,6 +34,11 @@ PAGE_DETAILS = {
"Miners",
"Search durable miner inventory and inspect current identity and state.",
),
"miner_detail": (
"Assets / Miners",
"Miner Detail",
"Current identity, operating facts, location, and authorized history.",
),
}
@@ -407,6 +414,9 @@ def render_app_page(
view: str,
current_user: dict,
has_permission: Callable[[str], bool],
*,
miner_id: str | None = None,
return_to: str | None = None,
) -> str:
context, title, summary = PAGE_DETAILS[view]
display_name = escape(
@@ -418,10 +428,11 @@ def render_app_page(
)
role = escape(str(current_user.get("role") or "Authenticated user"))
can_view_miners = has_permission("miners.view")
active_navigation = "miners" if view == "miner_detail" else view
navigation = "".join(
(
f'<li><a href="{href}" aria-current="page">{escape(label)}</a></li>'
if key == view
if key == active_navigation
else f'<li><a href="{href}">{escape(label)}</a></li>'
)
for key, label, href, permission in NAVIGATION
@@ -437,7 +448,9 @@ def render_app_page(
else '<div class="global-search-unavailable" aria-hidden="true"></div>'
)
content = (
_map_content(has_permission("locations.manage"))
miner_detail_content()
if view == "miner_detail"
else _map_content(has_permission("locations.manage"))
if view == "map"
else _miners_content(has_permission("locations.view"))
if view == "miners"
@@ -452,7 +465,9 @@ def render_app_page(
)
)
page_script = (
'<script src="/assets/minerops-mine-map.js" defer></script>'
'<script src="/assets/minerops-miner-detail.js" defer></script>'
if view == "miner_detail"
else '<script src="/assets/minerops-mine-map.js" defer></script>'
if view == "map"
else '<script src="/assets/minerops-fleet.js" defer></script>'
if view == "miners"
@@ -462,6 +477,11 @@ def render_app_page(
if view == "dashboard"
else ""
)
object_attributes = ""
if miner_id is not None:
object_attributes += f' data-miner-id="{escape(miner_id)}"'
if return_to is not None:
object_attributes += f' data-return-to="{escape(return_to)}"'
return f"""<!doctype html>
<html lang="en">
<head>
@@ -472,7 +492,7 @@ def render_app_page(
</head>
<body data-view="{escape(view)}"
data-can-view-miners="{str(can_view_miners).lower()}"
data-can-view-locations="{str(has_permission("locations.view")).lower()}">
data-can-view-locations="{str(has_permission("locations.view")).lower()}"{object_attributes}>
<a class="skip-link" href="#mainContent">Skip to main content</a>
<div class="app-shell">
<header class="topbar">
+71
View File
@@ -59,6 +59,66 @@ MINER_SELECT = f"""
) tag_data ON true
"""
MINER_CURRENT_SELECT = f"""
SELECT
miners.id,
miners.site_id,
miners.room_id,
miners.asset_tag,
miners.current_mac,
host(miners.current_ip) AS current_ip,
miners.hostname,
miners.miner_sn,
miners.control_board_sn,
miners.power_sn,
miners.model,
miners.firmware_version,
miners.lifecycle_status,
miners.status,
miners.notes,
miners.first_seen_at,
miners.last_seen_at,
miners.created_at,
miners.updated_at,
tag_data.tags,
latest.observed_at,
latest.hashrate_ths,
latest.power_w,
latest.chip_temp_max_c,
latest.hash_stable
FROM miners
LEFT JOIN LATERAL (
SELECT COALESCE(
jsonb_agg(
jsonb_build_object(
'id', tags.id,
'name', tags.name,
'created_at', tags.created_at,
'updated_at', tags.updated_at
)
ORDER BY lower({TAG_TRIMMED_NAME_SQL}), tags.id
),
'[]'::jsonb
) AS tags
FROM miner_tags
JOIN tags ON tags.id = miner_tags.tag_id
WHERE miner_tags.miner_id = miners.id
) tag_data ON true
LEFT JOIN LATERAL (
SELECT snapshots.observed_at,
ROUND((snapshots.mhs_av / 1000000.0)::numeric, 2)::float
AS hashrate_ths,
ROUND(snapshots.power_w::numeric, 2)::float AS power_w,
ROUND(snapshots.chip_temp_max_c::numeric, 2)::float
AS chip_temp_max_c,
snapshots.hash_stable
FROM miner_status_snapshots AS snapshots
WHERE snapshots.miner_id = miners.id
ORDER BY snapshots.observed_at DESC, snapshots.id DESC
LIMIT 1
) latest ON true
"""
ACTIVE_REPAIR_STATUSES = (
"OPEN",
"IN_PROGRESS",
@@ -260,6 +320,17 @@ class InventoryRepository:
with connection.cursor(row_factory=dict_row) as cur:
return self._fetch_miner(cur, "miners.id = %s", (miner_id,))
def get_current_miner_by_id(self, miner_id: UUID) -> dict | None:
"""Return Inventory identity with its latest factual telemetry."""
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"{MINER_CURRENT_SELECT} WHERE miners.id = %s",
(miner_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def get_miner_by_id_for_update(self, miner_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
+7
View File
@@ -137,6 +137,13 @@ class InventoryService:
raise MinerNotFound(miner_id)
return self._detail_model(row)
def get_current_miner(self, miner_id: UUID) -> InventoryMinerDetail:
"""Return current Inventory identity and the latest observation."""
row = self.repository.get_current_miner_by_id(miner_id)
if not row:
raise MinerNotFound(miner_id)
return self._detail_model(row)
def get_asset_id_history(
self,
miner_id: UUID,
+12
View File
@@ -118,6 +118,18 @@ class MineMapResponse(BaseModel):
error: None = None
class MinerLocationDetail(BaseModel):
site_id: UUID | None = None
site_name: str | None = None
room_id: UUID | None = None
room_name: str | None = None
position_id: UUID
rack_label: str | None = None
shelf_label: str | None = None
position_label: str | None = None
position_index: int | None = None
class LocationSummary(BaseModel):
sites: int
rooms: int
+26
View File
@@ -229,3 +229,29 @@ class LocationMapRepository:
"""
)
return dict(cur.fetchone())
def get_miner_location(self, miner_id: UUID) -> dict[str, Any] | None:
"""Return the one current physical position assigned to a miner."""
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT sites.id AS site_id, sites.name AS site_name,
rooms.id AS room_id,
COALESCE(rooms.name, position.room_name) AS room_name,
position.id AS position_id,
position.rack_name AS rack_label,
position.shelf_name AS shelf_label,
position.position_label,
position.position_index
FROM location_positions AS position
LEFT JOIN rooms ON rooms.id = position.room_id
LEFT JOIN sites ON sites.id = rooms.site_id
WHERE position.miner_id = %s
ORDER BY position.id
LIMIT 1
""",
(miner_id,),
)
row = cur.fetchone()
return dict(row) if row else None
+7
View File
@@ -14,6 +14,7 @@ from app.locations.models import (
MineMapData,
MineMapMeta,
MineMapResponse,
MinerLocationDetail,
)
from app.locations.repository import LocationMapRepository
@@ -87,6 +88,12 @@ class LocationMapService:
),
)
def get_miner_location(self, miner_id: UUID) -> MinerLocationDetail | None:
row = self.repository.get_miner_location(miner_id)
if row is None:
return None
return MinerLocationDetail.model_validate(row)
def summarize_locations(self, *, include_miner_counts: bool) -> LocationSummary:
return LocationSummary.model_validate(
self.repository.summarize_locations(
+13 -1
View File
@@ -23,11 +23,13 @@ from app.identity import (
)
from app.frontend.router import (
dashboard_page as internal_alpha_dashboard_page,
miner_detail_page as internal_alpha_miner_detail_page,
router as frontend_router,
)
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.repairs.exceptions import (
ControlBoardReplacementActorRequired,
InvalidMacAddress,
@@ -60,6 +62,7 @@ app.include_router(inventory_router)
app.include_router(components_router)
app.include_router(locations_router)
app.include_router(dashboard_router)
app.include_router(miner_detail_router)
install_identity_middleware(app)
@@ -1814,7 +1817,7 @@ def get_miner(miner_id: str, current_user: dict = Depends(get_current_user)):
@app.get("/api/miners/{miner_id}/network-identities")
def list_miner_network_identities(miner_id: str, current_user: dict = Depends(get_current_user)):
def list_miner_network_identities(miner_id: str, current_user: dict = Depends(require_permission("miners.view"))):
conn = None
try:
conn = get_db_connection()
@@ -29087,6 +29090,9 @@ async def minerops_inject_detail_extra_v2(request, call_next):
response = await call_next(request)
try:
if request.scope.get("endpoint") is internal_alpha_miner_detail_page:
return response
path_value = request.url.path or ""
if not (path_value.startswith("/miners/") and path_value.endswith("/detail")):
@@ -29683,6 +29689,9 @@ async def minerops_inject_clean_detail_v3(request, call_next):
response = await call_next(request)
try:
if request.scope.get("endpoint") is internal_alpha_miner_detail_page:
return response
path_value = request.url.path or ""
if not (path_value.startswith("/miners/") and path_value.endswith("/detail")):
@@ -30921,6 +30930,9 @@ async def minerops_inject_location_management(request, call_next):
response = await call_next(request)
try:
if request.scope.get("endpoint") is internal_alpha_miner_detail_page:
return response
path = request.url.path or ""
if not (path.startswith("/miners/") and path.endswith("/detail")):
+1
View File
@@ -0,0 +1 @@
"""Permission-aware Miner Detail read model."""
+14
View File
@@ -0,0 +1,14 @@
"""Dependency construction for the Miner Detail composition service."""
from app.inventory.dependencies import get_inventory_service
from app.locations.dependencies import get_location_map_service
from app.miner_detail.service import MinerDetailService
from app.repairs.dependencies import get_repair_center_service
def get_miner_detail_service() -> MinerDetailService:
return MinerDetailService(
inventory_service=get_inventory_service(),
location_service=get_location_map_service(),
repair_service=get_repair_center_service(),
)
+76
View File
@@ -0,0 +1,76 @@
"""Typed contracts for the Internal Alpha Miner Detail overview."""
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, Field
from app.inventory.models import InventoryMinerDetail
from app.locations.models import MinerLocationDetail
OperationalState = Literal[
"online", "offline", "unknown", "unstable", "repair", "spare", "retired"
]
class MinerDetailAccess(BaseModel):
can_view_locations: bool = False
can_read_repairs: bool = False
can_view_repairs: bool = False
can_read_components: bool = False
class MinerDetailLocationSection(BaseModel):
available: bool
restricted: bool
location: MinerLocationDetail | None = None
class MinerDetailRepairEpisode(BaseModel):
repair_id: UUID | None = None
source: Literal["modular", "legacy"]
active: bool
status: str
reason: str | None = None
opened_at: datetime | None = None
closed_at: datetime | None = None
latest_context: str | None = None
latest_context_at: datetime | None = None
parts_count: int | None = None
parts_summary: list[str] = Field(default_factory=list)
class MinerDetailRepairSection(BaseModel):
available: bool
restricted: bool
active_repair: MinerDetailRepairEpisode | None = None
episodes: list[MinerDetailRepairEpisode] = Field(default_factory=list)
class MinerDetailCapabilities(BaseModel):
can_view_map: bool = False
can_open_repair_center: bool = False
can_read_repair_history: bool = False
can_read_components: bool = False
class MinerDetailOverviewData(BaseModel):
miner: InventoryMinerDetail
operational_state: OperationalState
location: MinerDetailLocationSection
repair: MinerDetailRepairSection
capabilities: MinerDetailCapabilities
class MinerDetailOverviewMeta(BaseModel):
generated_at: datetime
database_query_count: int = Field(ge=1, le=3)
class MinerDetailOverviewResponse(BaseModel):
data: MinerDetailOverviewData
meta: MinerDetailOverviewMeta
error: None = None
+59
View File
@@ -0,0 +1,59 @@
"""HTTP boundary for the Internal Alpha Miner Detail overview."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Response, status
from app.identity import require_permission, user_has_permission
from app.inventory.exceptions import MinerNotFound
from app.miner_detail.dependencies import get_miner_detail_service
from app.miner_detail.models import (
MinerDetailAccess,
MinerDetailOverviewResponse,
)
from app.miner_detail.service import MinerDetailService
router = APIRouter(prefix="/api/miners", tags=["miners"])
MinerDetailUser = Annotated[dict, Depends(require_permission("miners.view"))]
MinerDetailServiceDependency = Annotated[
MinerDetailService,
Depends(get_miner_detail_service),
]
@router.get(
"/{miner_id}/overview",
response_model=MinerDetailOverviewResponse,
)
def get_miner_detail_overview(
miner_id: UUID,
current_user: MinerDetailUser,
service: MinerDetailServiceDependency,
response: Response,
) -> MinerDetailOverviewResponse:
response.headers["Cache-Control"] = "no-store"
try:
return service.get_overview(
miner_id,
MinerDetailAccess(
can_view_locations=user_has_permission(
current_user, "locations.view"
),
can_read_repairs=user_has_permission(
current_user, "repairs.read"
),
can_view_repairs=user_has_permission(
current_user, "repairs.view"
),
can_read_components=user_has_permission(
current_user, "components.read"
),
),
)
except MinerNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Miner not found",
) from exc
+152
View File
@@ -0,0 +1,152 @@
"""Composition service for the Internal Alpha Miner Detail overview."""
from datetime import datetime, timezone
from math import isfinite
from uuid import UUID
from app.inventory.models import InventoryMinerDetail
from app.inventory.service import InventoryService
from app.locations.service import LocationMapService
from app.miner_detail.models import (
MinerDetailAccess,
MinerDetailCapabilities,
MinerDetailLocationSection,
MinerDetailOverviewData,
MinerDetailOverviewMeta,
MinerDetailOverviewResponse,
MinerDetailRepairEpisode,
MinerDetailRepairSection,
OperationalState,
)
from app.repairs.center_service import RepairCenterService
from app.repairs.models import RepairCenterAccess, RepairCenterItem
class MinerDetailService:
"""Coordinate domain-owned current projections without transport logic."""
def __init__(
self,
inventory_service: InventoryService,
location_service: LocationMapService,
repair_service: RepairCenterService,
):
self.inventory_service = inventory_service
self.location_service = location_service
self.repair_service = repair_service
@staticmethod
def _episode(item: RepairCenterItem) -> MinerDetailRepairEpisode:
return MinerDetailRepairEpisode(
repair_id=item.repair_id,
source=item.source,
active=item.active,
status=item.repair_status,
reason=item.reason,
opened_at=item.opened_at,
closed_at=item.closed_at,
latest_context=item.latest_context,
latest_context_at=item.latest_context_at,
parts_count=item.parts_count,
parts_summary=item.parts_summary,
)
@staticmethod
def _operational_state(
miner: InventoryMinerDetail,
active_repair: MinerDetailRepairEpisode | None,
) -> OperationalState:
if active_repair is not None or miner.lifecycle_status == "in_repair":
return "repair"
if miner.lifecycle_status == "retired":
return "retired"
if miner.lifecycle_status == "spare":
return "spare"
runtime = (miner.runtime_status or "").strip().lower()
if runtime == "offline":
return "offline"
if miner.hash_stable is False:
return "unstable"
if runtime == "online":
return "online"
return "unknown"
def get_overview(
self,
miner_id: UUID,
access: MinerDetailAccess,
) -> MinerDetailOverviewResponse:
miner = self.inventory_service.get_current_miner(miner_id)
# Location is owned by the permission-aware section below. Keep the
# shared projection from leaking location identifiers. Non-finite
# telemetry is not a factual display value and remains unavailable.
redacted = {
field: None
for field in (
"site_id", "site_name", "room_id", "room_name",
"location_position_id", "rack_label", "shelf_label",
"position_label", "position_index",
)
}
for field in ("hashrate_ths", "power_w", "chip_temp_max_c"):
value = getattr(miner, field)
if value is not None and not isfinite(value):
redacted[field] = None
miner = miner.model_copy(
update=redacted
)
location = (
self.location_service.get_miner_location(miner_id)
if access.can_view_locations
else None
)
repair_items = (
self.repair_service.list_miner_repairs(
miner_id,
RepairCenterAccess(
can_read_repairs=True,
can_view_miners=True,
),
)
if access.can_read_repairs
else []
)
episodes = [self._episode(item) for item in repair_items]
active_repair = next(
(
episode for episode in episodes
if episode.active and episode.source == "modular"
),
None,
)
query_count = 1 + int(access.can_view_locations) + int(access.can_read_repairs)
return MinerDetailOverviewResponse(
data=MinerDetailOverviewData(
miner=miner,
operational_state=self._operational_state(miner, active_repair),
location=MinerDetailLocationSection(
available=access.can_view_locations,
restricted=not access.can_view_locations,
location=location,
),
repair=MinerDetailRepairSection(
available=access.can_read_repairs,
restricted=not access.can_read_repairs,
active_repair=active_repair,
episodes=episodes,
),
capabilities=MinerDetailCapabilities(
can_view_map=bool(
access.can_view_locations and location is not None
and location.site_id and location.room_id
),
can_open_repair_center=access.can_view_repairs,
can_read_repair_history=access.can_read_repairs,
can_read_components=access.can_read_components,
),
),
meta=MinerDetailOverviewMeta(
generated_at=datetime.now(timezone.utc),
database_query_count=query_count,
),
)
+22
View File
@@ -315,6 +315,28 @@ class RepairCenterRepository:
connection.close()
return {"items": items, "summary": summary, "location_options": options}
def list_miner_repairs(self, miner_id) -> list[dict[str, Any]]:
"""Return a bounded, exact repair history for one durable miner."""
ctes = self._ctes(show_miners=False, show_locations=False)
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"""
{ctes}
SELECT candidate.* FROM candidates AS candidate
WHERE candidate.miner_id = %s
ORDER BY candidate.active DESC,
candidate.opened_at DESC NULLS LAST,
candidate.repair_id NULLS LAST
LIMIT 50
""",
(miner_id,),
)
return [dict(row) for row in cur.fetchall()]
finally:
connection.close()
def summarize_active_repairs(self) -> dict[str, Any]:
"""Return exact active counts without loading Repair Center rows."""
ctes = self._ctes(show_miners=False, show_locations=False)
+22
View File
@@ -96,6 +96,28 @@ class RepairCenterService:
),
)
def list_miner_repairs(
self, miner_id, access: RepairCenterAccess
) -> list[RepairCenterItem]:
items = []
for row in self.repository.list_miner_repairs(miner_id):
mapped = {
**row,
"miner_status": row.get("runtime_status"),
"latest_event_status": row.get("legacy_event_status"),
"latest_event_description": row.get("latest_context"),
"latest_event_at": row.get("latest_context_at"),
"asset_state": row.get("legacy_asset_state"),
"state_notes": row.get("latest_context"),
"reported_error": row.get("reported_problem"),
}
item = RepairCenterItem.model_validate(
{**mapped, "capabilities": RepairCenterCapabilities()}
)
item.capabilities = self._capabilities(item, access)
items.append(item)
return items
def summarize_active_repairs(self) -> ActiveRepairSummary:
return ActiveRepairSummary.model_validate(
self.repository.summarize_active_repairs()
+636
View File
@@ -0,0 +1,636 @@
import ast
import re
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
from uuid import UUID, uuid4
import psycopg
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.config import settings
from app.frontend.router import miner_detail_page, router as frontend_router
from app.identity import get_current_user
from app.inventory.exceptions import MinerNotFound
from app.inventory.models import InventoryMinerDetail
from app.inventory.repository import InventoryRepository
from app.inventory.service import InventoryService
from app.locations.models import MinerLocationDetail
from app.locations.repository import LocationMapRepository
from app.miner_detail.dependencies import get_miner_detail_service
from app.miner_detail.models import MinerDetailAccess
from app.miner_detail.router import router as detail_router
from app.miner_detail.service import MinerDetailService
from app.repairs.center_repository import RepairCenterRepository
from app.repairs.models import RepairCenterCapabilities, RepairCenterItem
NOW = datetime(2026, 8, 17, 12, 0, tzinfo=timezone.utc)
APP_ROOT = Path(__file__).parents[1] / "app"
STATIC_ROOT = APP_ROOT / "frontend" / "static"
def current_miner(**updates):
values = {
"id": uuid4(),
"asset_id": "MOP-001",
"current_mac": "aa:bb:cc:dd:ee:ff",
"current_ip": "192.0.2.10",
"model": "M50",
"miner_sn": "MINER-SN",
"lifecycle_status": "active",
"runtime_status": "online",
"site_id": uuid4(),
"site_name": "Restricted Site",
"room_id": uuid4(),
"room_name": "Restricted Room",
"location_position_id": uuid4(),
"rack_label": "Rack A",
"shelf_label": "Shelf 1",
"position_label": "A-01",
"position_index": 1,
"observed_at": NOW,
"hashrate_ths": 0.0,
"power_w": 0.0,
"chip_temp_max_c": 0.0,
"hash_stable": None,
"tags": [],
"created_at": NOW - timedelta(days=10),
"updated_at": NOW,
"last_seen_at": NOW,
"hostname": "miner-001",
"control_board_sn": "CB-SN",
"psu_serial": "PSU-SN",
"firmware_version": "1.2.3",
"notes": None,
"first_seen_at": NOW - timedelta(days=9),
}
values.update(updates)
return InventoryMinerDetail(**values)
def location():
return MinerLocationDetail(
site_id=uuid4(),
site_name="North Site",
room_id=uuid4(),
room_name="Room 1",
position_id=uuid4(),
rack_label="Rack A",
shelf_label="Shelf 1",
position_label="A-01",
position_index=1,
)
def repair_item(*, source="modular", active=True):
return RepairCenterItem(
repair_id=uuid4() if source == "modular" else None,
source=source,
active=active,
opened_at=NOW - timedelta(hours=2),
repair_status="OPEN" if active else "COMPLETED",
reason="CONTROL_BOARD" if source == "modular" else None,
latest_context="Board diagnostics",
latest_context_at=NOW,
miner_id=uuid4(),
lifecycle_status="in_repair" if active else "active",
parts_count=1 if source == "modular" else None,
parts_summary=["control board"] if source == "modular" else [],
capabilities=RepairCenterCapabilities(),
)
class FakeInventoryService:
def __init__(self, miner=None, *, missing=False):
self.miner = miner or current_miner()
self.missing = missing
self.calls = []
def get_current_miner(self, miner_id):
self.calls.append(miner_id)
if self.missing:
raise MinerNotFound(miner_id)
return self.miner
class FakeLocationService:
def __init__(self, value=None):
self.value = value
self.calls = []
def get_miner_location(self, miner_id):
self.calls.append(miner_id)
return self.value
class FakeRepairService:
def __init__(self, values=None):
self.values = list(values or [])
self.calls = []
def list_miner_repairs(self, miner_id, access):
self.calls.append((miner_id, access))
return self.values
class MinerDetailServiceTests(unittest.TestCase):
def build(self, *, miner=None, mapped=None, repairs=None, missing=False):
self.inventory = FakeInventoryService(miner, missing=missing)
self.locations = FakeLocationService(mapped)
self.repairs = FakeRepairService(repairs)
return MinerDetailService(self.inventory, self.locations, self.repairs)
def test_full_access_uses_three_domain_queries_and_preserves_zero(self):
miner_id = uuid4()
mapped = location()
active = repair_item()
service = self.build(mapped=mapped, repairs=[active])
response = service.get_overview(
miner_id,
MinerDetailAccess(
can_view_locations=True,
can_read_repairs=True,
can_view_repairs=True,
can_read_components=True,
),
)
self.assertEqual(self.inventory.calls, [miner_id])
self.assertEqual(self.locations.calls, [miner_id])
self.assertEqual(len(self.repairs.calls), 1)
self.assertEqual(response.meta.database_query_count, 3)
self.assertEqual(response.data.miner.hashrate_ths, 0)
self.assertEqual(response.data.miner.power_w, 0)
self.assertEqual(response.data.miner.chip_temp_max_c, 0)
self.assertIsNone(response.data.miner.hash_stable)
self.assertEqual(response.data.location.location, mapped)
self.assertEqual(response.data.repair.active_repair.repair_id, active.repair_id)
self.assertEqual(response.data.operational_state, "repair")
self.assertTrue(response.data.capabilities.can_view_map)
self.assertTrue(response.data.capabilities.can_open_repair_center)
def test_restricted_domains_are_not_queried_and_location_never_leaks(self):
service = self.build(mapped=location(), repairs=[repair_item()])
response = service.get_overview(uuid4(), MinerDetailAccess())
self.assertEqual(response.meta.database_query_count, 1)
self.assertEqual(self.locations.calls, [])
self.assertEqual(self.repairs.calls, [])
self.assertTrue(response.data.location.restricted)
self.assertIsNone(response.data.location.location)
self.assertTrue(response.data.repair.restricted)
self.assertEqual(response.data.repair.episodes, [])
miner = response.data.miner
for field in (
"site_id", "site_name", "room_id", "room_name",
"location_position_id", "rack_label", "shelf_label",
"position_label", "position_index",
):
self.assertIsNone(getattr(miner, field), field)
serialized = response.model_dump_json()
self.assertNotIn("Restricted Site", serialized)
self.assertNotIn("Restricted Room", serialized)
def test_no_repair_and_legacy_repair_are_truthfully_distinct(self):
no_repair = self.build().get_overview(
uuid4(), MinerDetailAccess(can_read_repairs=True)
)
self.assertTrue(no_repair.data.repair.available)
self.assertFalse(no_repair.data.repair.restricted)
self.assertIsNone(no_repair.data.repair.active_repair)
self.assertEqual(no_repair.data.repair.episodes, [])
legacy = repair_item(source="legacy")
legacy_response = self.build(repairs=[legacy]).get_overview(
uuid4(), MinerDetailAccess(can_read_repairs=True)
)
self.assertIsNone(legacy_response.data.repair.active_repair)
self.assertEqual(legacy_response.data.repair.episodes[0].source, "legacy")
self.assertIsNone(legacy_response.data.repair.episodes[0].repair_id)
def test_unscoped_legacy_position_is_truthful_but_not_map_linkable(self):
unscoped = location().model_copy(
update={"site_id": None, "site_name": None, "room_id": None}
)
response = self.build(mapped=unscoped).get_overview(
uuid4(), MinerDetailAccess(can_view_locations=True)
)
self.assertTrue(response.data.location.available)
self.assertEqual(response.data.location.location.position_label, "A-01")
self.assertFalse(response.data.capabilities.can_view_map)
def test_factual_state_uses_no_temperature_or_age_threshold(self):
cases = (
({"runtime_status": "offline", "chip_temp_max_c": 99.0}, "offline"),
({"runtime_status": "online", "hash_stable": False}, "unstable"),
({"runtime_status": "online", "observed_at": NOW - timedelta(days=90)}, "online"),
({"runtime_status": "unknown", "chip_temp_max_c": 1000.0}, "unknown"),
({"runtime_status": "online", "lifecycle_status": "spare"}, "spare"),
({"runtime_status": "online", "lifecycle_status": "retired"}, "retired"),
)
for updates, expected in cases:
with self.subTest(expected=expected):
response = self.build(miner=current_miner(**updates)).get_overview(
uuid4(), MinerDetailAccess()
)
self.assertEqual(response.data.operational_state, expected)
response = self.build(
miner=current_miner(hashrate_ths=float("nan"))
).get_overview(uuid4(), MinerDetailAccess())
self.assertIsNone(response.data.miner.hashrate_ths)
self.assertEqual(response.data.operational_state, "online")
def test_unknown_miner_propagates_domain_not_found_without_other_queries(self):
service = self.build(missing=True, mapped=location(), repairs=[repair_item()])
with self.assertRaises(MinerNotFound):
service.get_overview(
uuid4(),
MinerDetailAccess(can_view_locations=True, can_read_repairs=True),
)
self.assertEqual(self.locations.calls, [])
self.assertEqual(self.repairs.calls, [])
class StubMinerDetailService:
def __init__(self, *, missing=False):
self.missing = missing
self.calls = []
self.delegate = MinerDetailService(
FakeInventoryService(),
FakeLocationService(location()),
FakeRepairService([repair_item()]),
)
def get_overview(self, miner_id, access):
self.calls.append((miner_id, access))
if self.missing:
raise MinerNotFound(miner_id)
return self.delegate.get_overview(miner_id, access)
class MinerDetailRouterTests(unittest.TestCase):
def setUp(self):
self.service = StubMinerDetailService()
self.app = FastAPI()
self.app.include_router(detail_router)
self.app.dependency_overrides[get_miner_detail_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_requires_authentication_and_miners_view(self):
path = f"/api/miners/{uuid4()}/overview"
self.assertEqual(self.client.get(path).status_code, 401)
self.authenticate(("locations.view", "repairs.read"))
self.assertEqual(self.client.get(path).status_code, 403)
def test_returns_typed_permission_aware_no_store_envelope(self):
miner_id = uuid4()
self.authenticate((
"miners.view", "locations.view", "repairs.read",
"repairs.view", "components.read",
))
response = self.client.get(f"/api/miners/{miner_id}/overview")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["cache-control"], "no-store")
payload = response.json()
self.assertEqual(payload["data"]["miner"]["id"], str(self.service.delegate.inventory_service.miner.id))
self.assertEqual(payload["meta"]["database_query_count"], 3)
self.assertIsNone(payload["error"])
_, access = self.service.calls[-1]
self.assertTrue(access.can_view_locations)
self.assertTrue(access.can_read_repairs)
self.assertTrue(access.can_view_repairs)
self.assertTrue(access.can_read_components)
def test_malformed_and_unknown_uuid_use_existing_validation_and_404(self):
self.authenticate(("miners.view",))
self.assertEqual(self.client.get("/api/miners/not-a-uuid/overview").status_code, 422)
self.service.missing = True
response = self.client.get(f"/api/miners/{uuid4()}/overview")
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["detail"], "Miner not found")
class MinerDetailRepositoryIntegrationTests(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,
)
def tearDown(self):
self.connection.rollback()
self.connection.close()
def test_inventory_and_location_projections_use_latest_factual_rows(self):
miner_id, site_id, room_id = uuid4(), uuid4(), uuid4()
earlier = NOW - timedelta(hours=1)
with self.connection.cursor() as cur:
cur.execute("INSERT INTO sites (id, name) VALUES (%s, 'Detail Site')", (site_id,))
cur.execute(
"INSERT INTO rooms (id, site_id, name) VALUES (%s, %s, 'Detail Room')",
(room_id, site_id),
)
cur.execute(
"INSERT INTO miners (id, asset_tag, current_mac, current_ip, model, "
"status, lifecycle_status, first_seen_at, last_seen_at) "
"VALUES (%s, %s, 'aa:bb:cc:00:00:01', '192.0.2.22', 'M50', "
"'online', 'active', NULL, NULL)",
(miner_id, "DETAIL-" + uuid4().hex[:8]),
)
cur.execute(
"INSERT INTO location_positions (room_id, room_name, rack_name, "
"shelf_name, position_index, position_label, miner_id) "
"VALUES (%s, 'Detail Room', 'Rack A', 'Shelf 1', 2, 'A-02', %s)",
(room_id, miner_id),
)
cur.execute(
"INSERT INTO miner_status_snapshots "
"(id, miner_id, observed_at, mhs_av, power_w, chip_temp_max_c, hash_stable) "
"VALUES (%s, %s, %s, 120000000, 3300, 70, true), "
"(%s, %s, %s, 0, 0, 0, NULL)",
(uuid4(), miner_id, earlier, uuid4(), miner_id, NOW),
)
inventory = InventoryService(
InventoryRepository(connection=self.connection)
).get_current_miner(miner_id)
self.assertEqual(inventory.hashrate_ths, 0)
self.assertEqual(inventory.power_w, 0)
self.assertEqual(inventory.chip_temp_max_c, 0)
self.assertIsNone(inventory.hash_stable)
self.assertEqual(inventory.observed_at, NOW)
placement = LocationMapRepository(
connection=self.connection
).get_miner_location(miner_id)
self.assertEqual(placement["site_id"], site_id)
self.assertEqual(placement["room_id"], room_id)
self.assertEqual(placement["rack_label"], "Rack A")
self.assertIsInstance(placement["position_id"], UUID)
self.assertEqual(RepairCenterRepository().list_miner_repairs(miner_id), [])
class DummyCursor:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def execute(self, *args):
return None
def fetchall(self):
return []
class DummyConnection:
def cursor(self, **kwargs):
return DummyCursor()
def close(self):
return None
class NetworkIdentityPermissionTests(unittest.TestCase):
def setUp(self):
from app.main import list_miner_network_identities
self.app = FastAPI()
self.app.get("/api/miners/{miner_id}/network-identities")(
list_miner_network_identities
)
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def test_network_history_requires_miners_view(self):
path = f"/api/miners/{uuid4()}/network-identities"
self.assertEqual(self.client.get(path).status_code, 401)
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()), "role": "Custom", "permissions": ["locations.view"]
}
self.assertEqual(self.client.get(path).status_code, 403)
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()), "role": "Custom", "permissions": ["miners.view"]
}
with patch("app.main.get_db_connection", return_value=DummyConnection()):
response = self.client.get(path)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"], [])
class MinerDetailFrontendTests(unittest.TestCase):
def setUp(self):
self.app = FastAPI()
self.app.include_router(frontend_router)
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()),
"username": "reader",
"display_name": "Detail Reader",
"role": "Read Only",
"permissions": [
"miners.view", "locations.view", "repairs.read", "repairs.view"
],
}
self.client = TestClient(self.app)
self.miner_id = uuid4()
def tearDown(self):
self.client.close()
def test_page_requires_permission_and_renders_only_internal_alpha_shell(self):
path = f"/miners/{self.miner_id}/detail"
response = self.client.get(path)
self.assertEqual(response.status_code, 200)
for expected in (
"<title>Miner Detail | MinerOps</title>",
'data-view="miner_detail"',
f'data-miner-id="{self.miner_id}"',
"/assets/minerops-miner-detail.js",
'id="minerIdentityDetails"',
'id="minerRepairEpisodes"',
'id="minerObservations"',
):
self.assertIn(expected, response.text)
for legacy in (
"minerops-miner-detail-extra", "minerops-miner-detail-clean-v3",
"minerops-location-management", "Manual Edit Missing Details",
"Reboot", "Performance Mode",
):
self.assertNotIn(legacy, response.text)
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()), "role": "Custom", "permissions": []
}
self.assertEqual(self.client.get(path).status_code, 403)
def test_return_to_accepts_protected_local_context_and_rejects_external(self):
path = f"/miners/{self.miner_id}/detail"
local = "/monitoring/map?site_id=" + str(uuid4()) + "&miner_id=" + str(self.miner_id)
response = self.client.get(path, params={"return_to": local})
self.assertIn("data-return-to=", response.text)
self.assertIn("/monitoring/map?site_id=", response.text)
response = self.client.get(
path, params={"return_to": "https://example.invalid/steal"}
)
self.assertNotIn("data-return-to=", response.text)
self.assertNotIn("example.invalid", response.text)
def test_refresh_action_has_visible_name_and_keeps_existing_behavior(self):
response = self.client.get(f"/miners/{self.miner_id}/detail")
self.assertRegex(
response.text,
r'<button class="quiet-button" id="minerDetailRefresh" '
r'type="button">Refresh</button>',
)
source = (STATIC_ROOT / "miner-detail.js").read_text(encoding="utf-8")
self.assertIn(
'el.refresh.addEventListener("click", loadOverview);',
source,
)
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
self.assertIn(".miner-detail-actions .quiet-button { color: var(--ink); }", css)
def test_live_route_precedence_and_middleware_isolation(self):
from app.main import app
detail_handlers = [
route.endpoint for route in app.routes
if route.path == "/miners/{miner_id}/detail"
and "GET" in (route.methods or set())
]
self.assertGreaterEqual(len(detail_handlers), 2)
self.assertIs(detail_handlers[0], miner_detail_page)
current_user = {
"id": str(uuid4()), "username": "reader",
"display_name": "Detail Reader", "role": "Read Only",
"permissions": ["miners.view"],
}
app.dependency_overrides[get_current_user] = lambda: current_user
try:
with patch("app.identity.request_user_or_none", return_value=current_user):
with TestClient(app) as client:
response = client.get(f"/miners/{self.miner_id}/detail")
finally:
app.dependency_overrides.pop(get_current_user, None)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.text.count('data-view="miner_detail"'), 1)
self.assertIn("/assets/minerops-miner-detail.js", response.text)
for legacy in (
"/assets/minerops-miner-detail-extra.js",
"/assets/minerops-miner-detail-extra-v2.js",
"/assets/minerops-miner-detail-clean-v3.js",
"/assets/minerops-location-management.js",
):
self.assertNotIn(legacy, response.text)
paths = {route.path for route in app.routes}
for legacy_api in (
"/api/miners/{miner_id}",
"/api/miners/{miner_id}/detail-extra",
"/api/miners/{miner_id}/detail-clean-v3",
"/api/miners/{miner_id}/network-identities",
"/api/miners/{miner_id}/status-snapshots",
):
self.assertIn(legacy_api, paths)
def test_frontend_is_lazy_read_only_bounded_and_xss_safe(self):
source = (STATIC_ROOT / "miner-detail.js").read_text(encoding="utf-8")
self.assertEqual(
source.count(
'api("/api/miners/" + encodeURIComponent(minerId) + "/overview")'
),
1,
)
self.assertEqual(source.count("Promise.all(["), 2)
self.assertIn("/asset-id-history", source)
self.assertIn("/network-identities", source)
self.assertIn("/status-snapshots", source)
self.assertIn("/events", source)
self.assertIn("/parts", source)
self.assertNotIn("setInterval", source)
for mutation in (
'method: "POST"', 'method: "PATCH"', 'method: "PUT"',
'method: "DELETE"', "reboot", "change-mac", "manual-details",
):
self.assertNotIn(mutation, source)
for unsafe in ("innerHTML", "outerHTML", "insertAdjacentHTML", "document.write"):
self.assertNotIn(unsafe, source)
self.assertIn("textContent", source)
self.assertIn("new URLSearchParams", source)
self.assertIn("encodeURIComponent", source)
self.assertNotRegex(
source.lower(),
r"(temperature|hashrate|stale).{0,40}(threshold|>=\s*90|>\s*90)",
)
main_source = (APP_ROOT / "main.py").read_text(encoding="utf-8")
snapshot = re.search(
r'def list_miner_status_snapshots\(.*?LIMIT 100;',
main_source,
re.DOTALL,
)
self.assertIsNotNone(snapshot)
def test_cross_navigation_preserves_context_with_durable_uuid(self):
fleet = (STATIC_ROOT / "fleet.js").read_text(encoding="utf-8")
repairs = (STATIC_ROOT / "repair-center.js").read_text(encoding="utf-8")
for source in (fleet, repairs):
self.assertIn('return_to', source)
self.assertIn("window.location.pathname + window.location.search", source)
self.assertIn("encodeURIComponent", source)
detail = (STATIC_ROOT / "miner-detail.js").read_text(encoding="utf-8")
self.assertIn('new URLSearchParams({ miner_id: minerIdValue })', detail)
self.assertIn('params.set("site_id", location.site_id)', detail)
self.assertIn('params.set("room_id", location.room_id)', detail)
self.assertIn('new URLSearchParams({ scope: "all", q: data.miner.id })', detail)
class MinerDetailArchitectureTests(unittest.TestCase):
def test_router_and_service_preserve_dependency_boundaries(self):
for relative in ("miner_detail/router.py", "miner_detail/service.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", "sqlalchemy", "asyncpg"))
for name in modules)
)
for keyword in ("SELECT ", "INSERT ", "UPDATE ", "DELETE "):
self.assertNotIn(keyword, source.upper())
service_source = (APP_ROOT / "miner_detail" / "service.py").read_text(
encoding="utf-8"
)
self.assertNotIn("fastapi", service_source.lower())
def test_exactly_one_new_api_route_and_no_state_changing_page_get(self):
routes = [
(route.path, route.methods)
for route in detail_router.routes
]
self.assertEqual(routes, [("/api/miners/{miner_id}/overview", {"GET"})])
page = next(
route for route in frontend_router.routes
if route.path == "/miners/{miner_id}/detail"
)
self.assertEqual(page.methods, {"GET"})
if __name__ == "__main__":
unittest.main()