diff --git a/backend/app/frontend/miner_detail_view.py b/backend/app/frontend/miner_detail_view.py
new file mode 100644
index 0000000..00ae1f9
--- /dev/null
+++ b/backend/app/frontend/miner_detail_view.py
@@ -0,0 +1,76 @@
+"""Semantic HTML for the Internal Alpha Miner Detail page."""
+
+
+def miner_detail_content() -> str:
+ return """
+
+ Loading miner
+ Requesting current identity and operational context.
+
+
+ Miner detail unavailable
+ The miner could not be loaded.
+
+
+
+
+
+
+
+ Latest facts
+
Current state
+
No observation
+
+
+
+
+ Physical context
+
Location
+
+
+
+ Work context
+
Repair context
+
+
+
+
+
+
+
+ Identity history
+ Open to load Asset ID and network identity history.
+
+
+ Recent observations
+ Open to load up to 100 recent factual observations.
+
+
+
+
+"""
diff --git a/backend/app/frontend/router.py b/backend/app/frontend/router.py
index 557fa5e..458be5b 100644
--- a/backend/app/frontend/router.py
+++ b/backend/app/frontend/router.py
@@ -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)
diff --git a/backend/app/frontend/static/app.css b/backend/app/frontend/static/app.css
index dd10772..01055f5 100644
--- a/backend/app/frontend/static/app.css
+++ b/backend/app/frontend/static/app.css
@@ -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; }
+}
diff --git a/backend/app/frontend/static/fleet.js b/backend/app/frontend/static/fleet.js
index c98a056..1f1728f 100644
--- a/backend/app/frontend/static/fleet.js
+++ b/backend/app/frontend/static/fleet.js
@@ -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) {
diff --git a/backend/app/frontend/static/miner-detail.js b/backend/app/frontend/static/miner-detail.js
new file mode 100644
index 0000000..120a14d
--- /dev/null
+++ b/backend/app/frontend/static/miner-detail.js
@@ -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();
+})();
diff --git a/backend/app/frontend/static/repair-center.js b/backend/app/frontend/static/repair-center.js
index b713956..4ba75c8 100644
--- a/backend/app/frontend/static/repair-center.js
+++ b/backend/app/frontend/static/repair-center.js
@@ -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]
diff --git a/backend/app/frontend/views.py b/backend/app/frontend/views.py
index e994280..7dda519 100644
--- a/backend/app/frontend/views.py
+++ b/backend/app/frontend/views.py
@@ -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'{escape(label)}'
- if key == view
+ if key == active_navigation
else f'{escape(label)}'
)
for key, label, href, permission in NAVIGATION
@@ -437,7 +448,9 @@ def render_app_page(
else ''
)
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 = (
- ''
+ ''
+ if view == "miner_detail"
+ else ''
if view == "map"
else ''
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"""
@@ -472,7 +492,7 @@ def render_app_page(
+ data-can-view-locations="{str(has_permission("locations.view")).lower()}"{object_attributes}>
Skip to main content
diff --git a/backend/app/inventory/repository.py b/backend/app/inventory/repository.py
index 991883d..1005cec 100644
--- a/backend/app/inventory/repository.py
+++ b/backend/app/inventory/repository.py
@@ -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:
diff --git a/backend/app/inventory/service.py b/backend/app/inventory/service.py
index c70175e..db2d718 100644
--- a/backend/app/inventory/service.py
+++ b/backend/app/inventory/service.py
@@ -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,
diff --git a/backend/app/locations/models.py b/backend/app/locations/models.py
index b078f89..5986509 100644
--- a/backend/app/locations/models.py
+++ b/backend/app/locations/models.py
@@ -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
diff --git a/backend/app/locations/repository.py b/backend/app/locations/repository.py
index 20d17b4..10d0550 100644
--- a/backend/app/locations/repository.py
+++ b/backend/app/locations/repository.py
@@ -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
diff --git a/backend/app/locations/service.py b/backend/app/locations/service.py
index 9858242..3e002d1 100644
--- a/backend/app/locations/service.py
+++ b/backend/app/locations/service.py
@@ -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(
diff --git a/backend/app/main.py b/backend/app/main.py
index b6bc3bd..e8d2b13 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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")):
diff --git a/backend/app/miner_detail/__init__.py b/backend/app/miner_detail/__init__.py
new file mode 100644
index 0000000..42902e3
--- /dev/null
+++ b/backend/app/miner_detail/__init__.py
@@ -0,0 +1 @@
+"""Permission-aware Miner Detail read model."""
diff --git a/backend/app/miner_detail/dependencies.py b/backend/app/miner_detail/dependencies.py
new file mode 100644
index 0000000..bfcf3d3
--- /dev/null
+++ b/backend/app/miner_detail/dependencies.py
@@ -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(),
+ )
diff --git a/backend/app/miner_detail/models.py b/backend/app/miner_detail/models.py
new file mode 100644
index 0000000..6fd8662
--- /dev/null
+++ b/backend/app/miner_detail/models.py
@@ -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
+
diff --git a/backend/app/miner_detail/router.py b/backend/app/miner_detail/router.py
new file mode 100644
index 0000000..729028d
--- /dev/null
+++ b/backend/app/miner_detail/router.py
@@ -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
diff --git a/backend/app/miner_detail/service.py b/backend/app/miner_detail/service.py
new file mode 100644
index 0000000..f2e098a
--- /dev/null
+++ b/backend/app/miner_detail/service.py
@@ -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,
+ ),
+ )
diff --git a/backend/app/repairs/center_repository.py b/backend/app/repairs/center_repository.py
index 3b49dd2..3f287cd 100644
--- a/backend/app/repairs/center_repository.py
+++ b/backend/app/repairs/center_repository.py
@@ -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)
diff --git a/backend/app/repairs/center_service.py b/backend/app/repairs/center_service.py
index d49ba64..f8fd90c 100644
--- a/backend/app/repairs/center_service.py
+++ b/backend/app/repairs/center_service.py
@@ -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()
diff --git a/backend/tests/test_miner_detail.py b/backend/tests/test_miner_detail.py
new file mode 100644
index 0000000..d6679d3
--- /dev/null
+++ b/backend/tests/test_miner_detail.py
@@ -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 (
+ "Miner Detail | MinerOps",
+ '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'',
+ )
+ 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()
diff --git a/scripts/test-miner-detail.sh b/scripts/test-miner-detail.sh
new file mode 100755
index 0000000..7bebc4a
--- /dev/null
+++ b/scripts/test-miner-detail.sh
@@ -0,0 +1,102 @@
+#!/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/router.py
+ backend/app/frontend/static/app.css
+ backend/app/frontend/static/fleet.js
+ backend/app/frontend/static/miner-detail.js
+ backend/app/frontend/static/repair-center.js
+ backend/app/frontend/views.py
+ backend/app/inventory/repository.py
+ backend/app/inventory/service.py
+ backend/app/locations/models.py
+ backend/app/locations/repository.py
+ backend/app/locations/service.py
+ backend/app/main.py
+ backend/app/miner_detail/__init__.py
+ backend/app/miner_detail/dependencies.py
+ backend/app/miner_detail/models.py
+ backend/app/miner_detail/router.py
+ backend/app/miner_detail/service.py
+ backend/app/repairs/center_repository.py
+ backend/app/repairs/center_service.py
+ backend/tests/test_miner_detail.py
+)
+
+for file in "${source_files[@]}"; do
+ if [[ ! -r "${file}" ]]; then
+ echo "Miner Detail test input is not readable: ${file}" >&2
+ exit 1
+ fi
+ if [[ "$(stat -c '%a' "${file}")" != "644" ]]; then
+ echo "Miner Detail source/test mode must be 0644: ${file}" >&2
+ exit 1
+ fi
+done
+if [[ ! -x scripts/test-miner-detail.sh ]] \
+ || [[ "$(stat -c '%a' scripts/test-miner-detail.sh)" != "755" ]]; then
+ echo "scripts/test-miner-detail.sh must be executable with mode 0755." >&2
+ exit 1
+fi
+echo "Miner Detail file-mode preflight passed."
+
+project_name="${MINEROPS_MINER_DETAIL_TEST_PROJECT:-minerops-miner-detail-$$}"
+compose=(
+ docker compose
+ --project-name "${project_name}"
+ --env-file .env.dev
+ -f compose.dev.yaml
+)
+
+cleanup() {
+ status=$?
+ if (( status != 0 )); then
+ echo
+ echo "Miner Detail 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
+
+if [[ ! -f .env.dev ]]; then
+ echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
+ exit 1
+fi
+
+echo "Starting disposable Miner Detail 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 Miner Detail test image..."
+"${compose[@]}" build backend
+"${compose[@]}" run --rm --no-deps backend alembic upgrade head
+
+echo "Running Miner Detail and affected-domain focused tests..."
+"${compose[@]}" run --rm --no-deps \
+ -v "${repo_root}/backend/tests:/app/tests:ro" \
+ backend python -m unittest -v \
+ tests.test_miner_detail \
+ tests.test_frontend_shell \
+ tests.test_inventory_domain \
+ tests.test_inventory_router \
+ tests.test_mine_map \
+ tests.test_repair_center
+
+echo "Miner Detail focused tests passed."