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()