feat: add miner detail view
This commit is contained in:
1 parent
b8b741ccee
commit
19fc29c72c
22 files changed
+1882
-10
No files matched your search
@@ -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>
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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]
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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")):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Permission-aware Miner Detail read model."""
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
import ast
|
||||
import re
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import psycopg
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import settings
|
||||
from app.frontend.router import miner_detail_page, router as frontend_router
|
||||
from app.identity import get_current_user
|
||||
from app.inventory.exceptions import MinerNotFound
|
||||
from app.inventory.models import InventoryMinerDetail
|
||||
from app.inventory.repository import InventoryRepository
|
||||
from app.inventory.service import InventoryService
|
||||
from app.locations.models import MinerLocationDetail
|
||||
from app.locations.repository import LocationMapRepository
|
||||
from app.miner_detail.dependencies import get_miner_detail_service
|
||||
from app.miner_detail.models import MinerDetailAccess
|
||||
from app.miner_detail.router import router as detail_router
|
||||
from app.miner_detail.service import MinerDetailService
|
||||
from app.repairs.center_repository import RepairCenterRepository
|
||||
from app.repairs.models import RepairCenterCapabilities, RepairCenterItem
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 17, 12, 0, tzinfo=timezone.utc)
|
||||
APP_ROOT = Path(__file__).parents[1] / "app"
|
||||
STATIC_ROOT = APP_ROOT / "frontend" / "static"
|
||||
|
||||
|
||||
def current_miner(**updates):
|
||||
values = {
|
||||
"id": uuid4(),
|
||||
"asset_id": "MOP-001",
|
||||
"current_mac": "aa:bb:cc:dd:ee:ff",
|
||||
"current_ip": "192.0.2.10",
|
||||
"model": "M50",
|
||||
"miner_sn": "MINER-SN",
|
||||
"lifecycle_status": "active",
|
||||
"runtime_status": "online",
|
||||
"site_id": uuid4(),
|
||||
"site_name": "Restricted Site",
|
||||
"room_id": uuid4(),
|
||||
"room_name": "Restricted Room",
|
||||
"location_position_id": uuid4(),
|
||||
"rack_label": "Rack A",
|
||||
"shelf_label": "Shelf 1",
|
||||
"position_label": "A-01",
|
||||
"position_index": 1,
|
||||
"observed_at": NOW,
|
||||
"hashrate_ths": 0.0,
|
||||
"power_w": 0.0,
|
||||
"chip_temp_max_c": 0.0,
|
||||
"hash_stable": None,
|
||||
"tags": [],
|
||||
"created_at": NOW - timedelta(days=10),
|
||||
"updated_at": NOW,
|
||||
"last_seen_at": NOW,
|
||||
"hostname": "miner-001",
|
||||
"control_board_sn": "CB-SN",
|
||||
"psu_serial": "PSU-SN",
|
||||
"firmware_version": "1.2.3",
|
||||
"notes": None,
|
||||
"first_seen_at": NOW - timedelta(days=9),
|
||||
}
|
||||
values.update(updates)
|
||||
return InventoryMinerDetail(**values)
|
||||
|
||||
|
||||
def location():
|
||||
return MinerLocationDetail(
|
||||
site_id=uuid4(),
|
||||
site_name="North Site",
|
||||
room_id=uuid4(),
|
||||
room_name="Room 1",
|
||||
position_id=uuid4(),
|
||||
rack_label="Rack A",
|
||||
shelf_label="Shelf 1",
|
||||
position_label="A-01",
|
||||
position_index=1,
|
||||
)
|
||||
|
||||
|
||||
def repair_item(*, source="modular", active=True):
|
||||
return RepairCenterItem(
|
||||
repair_id=uuid4() if source == "modular" else None,
|
||||
source=source,
|
||||
active=active,
|
||||
opened_at=NOW - timedelta(hours=2),
|
||||
repair_status="OPEN" if active else "COMPLETED",
|
||||
reason="CONTROL_BOARD" if source == "modular" else None,
|
||||
latest_context="Board diagnostics",
|
||||
latest_context_at=NOW,
|
||||
miner_id=uuid4(),
|
||||
lifecycle_status="in_repair" if active else "active",
|
||||
parts_count=1 if source == "modular" else None,
|
||||
parts_summary=["control board"] if source == "modular" else [],
|
||||
capabilities=RepairCenterCapabilities(),
|
||||
)
|
||||
|
||||
|
||||
class FakeInventoryService:
|
||||
def __init__(self, miner=None, *, missing=False):
|
||||
self.miner = miner or current_miner()
|
||||
self.missing = missing
|
||||
self.calls = []
|
||||
|
||||
def get_current_miner(self, miner_id):
|
||||
self.calls.append(miner_id)
|
||||
if self.missing:
|
||||
raise MinerNotFound(miner_id)
|
||||
return self.miner
|
||||
|
||||
|
||||
class FakeLocationService:
|
||||
def __init__(self, value=None):
|
||||
self.value = value
|
||||
self.calls = []
|
||||
|
||||
def get_miner_location(self, miner_id):
|
||||
self.calls.append(miner_id)
|
||||
return self.value
|
||||
|
||||
|
||||
class FakeRepairService:
|
||||
def __init__(self, values=None):
|
||||
self.values = list(values or [])
|
||||
self.calls = []
|
||||
|
||||
def list_miner_repairs(self, miner_id, access):
|
||||
self.calls.append((miner_id, access))
|
||||
return self.values
|
||||
|
||||
|
||||
class MinerDetailServiceTests(unittest.TestCase):
|
||||
def build(self, *, miner=None, mapped=None, repairs=None, missing=False):
|
||||
self.inventory = FakeInventoryService(miner, missing=missing)
|
||||
self.locations = FakeLocationService(mapped)
|
||||
self.repairs = FakeRepairService(repairs)
|
||||
return MinerDetailService(self.inventory, self.locations, self.repairs)
|
||||
|
||||
def test_full_access_uses_three_domain_queries_and_preserves_zero(self):
|
||||
miner_id = uuid4()
|
||||
mapped = location()
|
||||
active = repair_item()
|
||||
service = self.build(mapped=mapped, repairs=[active])
|
||||
response = service.get_overview(
|
||||
miner_id,
|
||||
MinerDetailAccess(
|
||||
can_view_locations=True,
|
||||
can_read_repairs=True,
|
||||
can_view_repairs=True,
|
||||
can_read_components=True,
|
||||
),
|
||||
)
|
||||
self.assertEqual(self.inventory.calls, [miner_id])
|
||||
self.assertEqual(self.locations.calls, [miner_id])
|
||||
self.assertEqual(len(self.repairs.calls), 1)
|
||||
self.assertEqual(response.meta.database_query_count, 3)
|
||||
self.assertEqual(response.data.miner.hashrate_ths, 0)
|
||||
self.assertEqual(response.data.miner.power_w, 0)
|
||||
self.assertEqual(response.data.miner.chip_temp_max_c, 0)
|
||||
self.assertIsNone(response.data.miner.hash_stable)
|
||||
self.assertEqual(response.data.location.location, mapped)
|
||||
self.assertEqual(response.data.repair.active_repair.repair_id, active.repair_id)
|
||||
self.assertEqual(response.data.operational_state, "repair")
|
||||
self.assertTrue(response.data.capabilities.can_view_map)
|
||||
self.assertTrue(response.data.capabilities.can_open_repair_center)
|
||||
|
||||
def test_restricted_domains_are_not_queried_and_location_never_leaks(self):
|
||||
service = self.build(mapped=location(), repairs=[repair_item()])
|
||||
response = service.get_overview(uuid4(), MinerDetailAccess())
|
||||
self.assertEqual(response.meta.database_query_count, 1)
|
||||
self.assertEqual(self.locations.calls, [])
|
||||
self.assertEqual(self.repairs.calls, [])
|
||||
self.assertTrue(response.data.location.restricted)
|
||||
self.assertIsNone(response.data.location.location)
|
||||
self.assertTrue(response.data.repair.restricted)
|
||||
self.assertEqual(response.data.repair.episodes, [])
|
||||
miner = response.data.miner
|
||||
for field in (
|
||||
"site_id", "site_name", "room_id", "room_name",
|
||||
"location_position_id", "rack_label", "shelf_label",
|
||||
"position_label", "position_index",
|
||||
):
|
||||
self.assertIsNone(getattr(miner, field), field)
|
||||
serialized = response.model_dump_json()
|
||||
self.assertNotIn("Restricted Site", serialized)
|
||||
self.assertNotIn("Restricted Room", serialized)
|
||||
|
||||
def test_no_repair_and_legacy_repair_are_truthfully_distinct(self):
|
||||
no_repair = self.build().get_overview(
|
||||
uuid4(), MinerDetailAccess(can_read_repairs=True)
|
||||
)
|
||||
self.assertTrue(no_repair.data.repair.available)
|
||||
self.assertFalse(no_repair.data.repair.restricted)
|
||||
self.assertIsNone(no_repair.data.repair.active_repair)
|
||||
self.assertEqual(no_repair.data.repair.episodes, [])
|
||||
|
||||
legacy = repair_item(source="legacy")
|
||||
legacy_response = self.build(repairs=[legacy]).get_overview(
|
||||
uuid4(), MinerDetailAccess(can_read_repairs=True)
|
||||
)
|
||||
self.assertIsNone(legacy_response.data.repair.active_repair)
|
||||
self.assertEqual(legacy_response.data.repair.episodes[0].source, "legacy")
|
||||
self.assertIsNone(legacy_response.data.repair.episodes[0].repair_id)
|
||||
|
||||
def test_unscoped_legacy_position_is_truthful_but_not_map_linkable(self):
|
||||
unscoped = location().model_copy(
|
||||
update={"site_id": None, "site_name": None, "room_id": None}
|
||||
)
|
||||
response = self.build(mapped=unscoped).get_overview(
|
||||
uuid4(), MinerDetailAccess(can_view_locations=True)
|
||||
)
|
||||
self.assertTrue(response.data.location.available)
|
||||
self.assertEqual(response.data.location.location.position_label, "A-01")
|
||||
self.assertFalse(response.data.capabilities.can_view_map)
|
||||
|
||||
def test_factual_state_uses_no_temperature_or_age_threshold(self):
|
||||
cases = (
|
||||
({"runtime_status": "offline", "chip_temp_max_c": 99.0}, "offline"),
|
||||
({"runtime_status": "online", "hash_stable": False}, "unstable"),
|
||||
({"runtime_status": "online", "observed_at": NOW - timedelta(days=90)}, "online"),
|
||||
({"runtime_status": "unknown", "chip_temp_max_c": 1000.0}, "unknown"),
|
||||
({"runtime_status": "online", "lifecycle_status": "spare"}, "spare"),
|
||||
({"runtime_status": "online", "lifecycle_status": "retired"}, "retired"),
|
||||
)
|
||||
for updates, expected in cases:
|
||||
with self.subTest(expected=expected):
|
||||
response = self.build(miner=current_miner(**updates)).get_overview(
|
||||
uuid4(), MinerDetailAccess()
|
||||
)
|
||||
self.assertEqual(response.data.operational_state, expected)
|
||||
|
||||
response = self.build(
|
||||
miner=current_miner(hashrate_ths=float("nan"))
|
||||
).get_overview(uuid4(), MinerDetailAccess())
|
||||
self.assertIsNone(response.data.miner.hashrate_ths)
|
||||
self.assertEqual(response.data.operational_state, "online")
|
||||
|
||||
def test_unknown_miner_propagates_domain_not_found_without_other_queries(self):
|
||||
service = self.build(missing=True, mapped=location(), repairs=[repair_item()])
|
||||
with self.assertRaises(MinerNotFound):
|
||||
service.get_overview(
|
||||
uuid4(),
|
||||
MinerDetailAccess(can_view_locations=True, can_read_repairs=True),
|
||||
)
|
||||
self.assertEqual(self.locations.calls, [])
|
||||
self.assertEqual(self.repairs.calls, [])
|
||||
|
||||
|
||||
class StubMinerDetailService:
|
||||
def __init__(self, *, missing=False):
|
||||
self.missing = missing
|
||||
self.calls = []
|
||||
self.delegate = MinerDetailService(
|
||||
FakeInventoryService(),
|
||||
FakeLocationService(location()),
|
||||
FakeRepairService([repair_item()]),
|
||||
)
|
||||
|
||||
def get_overview(self, miner_id, access):
|
||||
self.calls.append((miner_id, access))
|
||||
if self.missing:
|
||||
raise MinerNotFound(miner_id)
|
||||
return self.delegate.get_overview(miner_id, access)
|
||||
|
||||
|
||||
class MinerDetailRouterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.service = StubMinerDetailService()
|
||||
self.app = FastAPI()
|
||||
self.app.include_router(detail_router)
|
||||
self.app.dependency_overrides[get_miner_detail_service] = lambda: self.service
|
||||
self.client = TestClient(self.app)
|
||||
|
||||
def tearDown(self):
|
||||
self.client.close()
|
||||
|
||||
def authenticate(self, permissions):
|
||||
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||
"id": str(uuid4()),
|
||||
"role": "Custom",
|
||||
"permissions": list(permissions),
|
||||
}
|
||||
|
||||
def test_requires_authentication_and_miners_view(self):
|
||||
path = f"/api/miners/{uuid4()}/overview"
|
||||
self.assertEqual(self.client.get(path).status_code, 401)
|
||||
self.authenticate(("locations.view", "repairs.read"))
|
||||
self.assertEqual(self.client.get(path).status_code, 403)
|
||||
|
||||
def test_returns_typed_permission_aware_no_store_envelope(self):
|
||||
miner_id = uuid4()
|
||||
self.authenticate((
|
||||
"miners.view", "locations.view", "repairs.read",
|
||||
"repairs.view", "components.read",
|
||||
))
|
||||
response = self.client.get(f"/api/miners/{miner_id}/overview")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["data"]["miner"]["id"], str(self.service.delegate.inventory_service.miner.id))
|
||||
self.assertEqual(payload["meta"]["database_query_count"], 3)
|
||||
self.assertIsNone(payload["error"])
|
||||
_, access = self.service.calls[-1]
|
||||
self.assertTrue(access.can_view_locations)
|
||||
self.assertTrue(access.can_read_repairs)
|
||||
self.assertTrue(access.can_view_repairs)
|
||||
self.assertTrue(access.can_read_components)
|
||||
|
||||
def test_malformed_and_unknown_uuid_use_existing_validation_and_404(self):
|
||||
self.authenticate(("miners.view",))
|
||||
self.assertEqual(self.client.get("/api/miners/not-a-uuid/overview").status_code, 422)
|
||||
self.service.missing = True
|
||||
response = self.client.get(f"/api/miners/{uuid4()}/overview")
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.json()["detail"], "Miner not found")
|
||||
|
||||
|
||||
class MinerDetailRepositoryIntegrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.connection = psycopg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
dbname=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.connection.rollback()
|
||||
self.connection.close()
|
||||
|
||||
def test_inventory_and_location_projections_use_latest_factual_rows(self):
|
||||
miner_id, site_id, room_id = uuid4(), uuid4(), uuid4()
|
||||
earlier = NOW - timedelta(hours=1)
|
||||
with self.connection.cursor() as cur:
|
||||
cur.execute("INSERT INTO sites (id, name) VALUES (%s, 'Detail Site')", (site_id,))
|
||||
cur.execute(
|
||||
"INSERT INTO rooms (id, site_id, name) VALUES (%s, %s, 'Detail Room')",
|
||||
(room_id, site_id),
|
||||
)
|
||||
cur.execute(
|
||||
"INSERT INTO miners (id, asset_tag, current_mac, current_ip, model, "
|
||||
"status, lifecycle_status, first_seen_at, last_seen_at) "
|
||||
"VALUES (%s, %s, 'aa:bb:cc:00:00:01', '192.0.2.22', 'M50', "
|
||||
"'online', 'active', NULL, NULL)",
|
||||
(miner_id, "DETAIL-" + uuid4().hex[:8]),
|
||||
)
|
||||
cur.execute(
|
||||
"INSERT INTO location_positions (room_id, room_name, rack_name, "
|
||||
"shelf_name, position_index, position_label, miner_id) "
|
||||
"VALUES (%s, 'Detail Room', 'Rack A', 'Shelf 1', 2, 'A-02', %s)",
|
||||
(room_id, miner_id),
|
||||
)
|
||||
cur.execute(
|
||||
"INSERT INTO miner_status_snapshots "
|
||||
"(id, miner_id, observed_at, mhs_av, power_w, chip_temp_max_c, hash_stable) "
|
||||
"VALUES (%s, %s, %s, 120000000, 3300, 70, true), "
|
||||
"(%s, %s, %s, 0, 0, 0, NULL)",
|
||||
(uuid4(), miner_id, earlier, uuid4(), miner_id, NOW),
|
||||
)
|
||||
inventory = InventoryService(
|
||||
InventoryRepository(connection=self.connection)
|
||||
).get_current_miner(miner_id)
|
||||
self.assertEqual(inventory.hashrate_ths, 0)
|
||||
self.assertEqual(inventory.power_w, 0)
|
||||
self.assertEqual(inventory.chip_temp_max_c, 0)
|
||||
self.assertIsNone(inventory.hash_stable)
|
||||
self.assertEqual(inventory.observed_at, NOW)
|
||||
placement = LocationMapRepository(
|
||||
connection=self.connection
|
||||
).get_miner_location(miner_id)
|
||||
self.assertEqual(placement["site_id"], site_id)
|
||||
self.assertEqual(placement["room_id"], room_id)
|
||||
self.assertEqual(placement["rack_label"], "Rack A")
|
||||
self.assertIsInstance(placement["position_id"], UUID)
|
||||
self.assertEqual(RepairCenterRepository().list_miner_repairs(miner_id), [])
|
||||
|
||||
|
||||
class DummyCursor:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def execute(self, *args):
|
||||
return None
|
||||
|
||||
def fetchall(self):
|
||||
return []
|
||||
|
||||
|
||||
class DummyConnection:
|
||||
def cursor(self, **kwargs):
|
||||
return DummyCursor()
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
class NetworkIdentityPermissionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from app.main import list_miner_network_identities
|
||||
|
||||
self.app = FastAPI()
|
||||
self.app.get("/api/miners/{miner_id}/network-identities")(
|
||||
list_miner_network_identities
|
||||
)
|
||||
self.client = TestClient(self.app)
|
||||
|
||||
def tearDown(self):
|
||||
self.client.close()
|
||||
|
||||
def test_network_history_requires_miners_view(self):
|
||||
path = f"/api/miners/{uuid4()}/network-identities"
|
||||
self.assertEqual(self.client.get(path).status_code, 401)
|
||||
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||
"id": str(uuid4()), "role": "Custom", "permissions": ["locations.view"]
|
||||
}
|
||||
self.assertEqual(self.client.get(path).status_code, 403)
|
||||
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||
"id": str(uuid4()), "role": "Custom", "permissions": ["miners.view"]
|
||||
}
|
||||
with patch("app.main.get_db_connection", return_value=DummyConnection()):
|
||||
response = self.client.get(path)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["data"], [])
|
||||
|
||||
|
||||
class MinerDetailFrontendTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.app = FastAPI()
|
||||
self.app.include_router(frontend_router)
|
||||
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||
"id": str(uuid4()),
|
||||
"username": "reader",
|
||||
"display_name": "Detail Reader",
|
||||
"role": "Read Only",
|
||||
"permissions": [
|
||||
"miners.view", "locations.view", "repairs.read", "repairs.view"
|
||||
],
|
||||
}
|
||||
self.client = TestClient(self.app)
|
||||
self.miner_id = uuid4()
|
||||
|
||||
def tearDown(self):
|
||||
self.client.close()
|
||||
|
||||
def test_page_requires_permission_and_renders_only_internal_alpha_shell(self):
|
||||
path = f"/miners/{self.miner_id}/detail"
|
||||
response = self.client.get(path)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
for expected in (
|
||||
"<title>Miner Detail | MinerOps</title>",
|
||||
'data-view="miner_detail"',
|
||||
f'data-miner-id="{self.miner_id}"',
|
||||
"/assets/minerops-miner-detail.js",
|
||||
'id="minerIdentityDetails"',
|
||||
'id="minerRepairEpisodes"',
|
||||
'id="minerObservations"',
|
||||
):
|
||||
self.assertIn(expected, response.text)
|
||||
for legacy in (
|
||||
"minerops-miner-detail-extra", "minerops-miner-detail-clean-v3",
|
||||
"minerops-location-management", "Manual Edit Missing Details",
|
||||
"Reboot", "Performance Mode",
|
||||
):
|
||||
self.assertNotIn(legacy, response.text)
|
||||
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||
"id": str(uuid4()), "role": "Custom", "permissions": []
|
||||
}
|
||||
self.assertEqual(self.client.get(path).status_code, 403)
|
||||
|
||||
def test_return_to_accepts_protected_local_context_and_rejects_external(self):
|
||||
path = f"/miners/{self.miner_id}/detail"
|
||||
local = "/monitoring/map?site_id=" + str(uuid4()) + "&miner_id=" + str(self.miner_id)
|
||||
response = self.client.get(path, params={"return_to": local})
|
||||
self.assertIn("data-return-to=", response.text)
|
||||
self.assertIn("/monitoring/map?site_id=", response.text)
|
||||
response = self.client.get(
|
||||
path, params={"return_to": "https://example.invalid/steal"}
|
||||
)
|
||||
self.assertNotIn("data-return-to=", response.text)
|
||||
self.assertNotIn("example.invalid", response.text)
|
||||
|
||||
def test_refresh_action_has_visible_name_and_keeps_existing_behavior(self):
|
||||
response = self.client.get(f"/miners/{self.miner_id}/detail")
|
||||
self.assertRegex(
|
||||
response.text,
|
||||
r'<button class="quiet-button" id="minerDetailRefresh" '
|
||||
r'type="button">Refresh</button>',
|
||||
)
|
||||
source = (STATIC_ROOT / "miner-detail.js").read_text(encoding="utf-8")
|
||||
self.assertIn(
|
||||
'el.refresh.addEventListener("click", loadOverview);',
|
||||
source,
|
||||
)
|
||||
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
|
||||
self.assertIn(".miner-detail-actions .quiet-button { color: var(--ink); }", css)
|
||||
|
||||
def test_live_route_precedence_and_middleware_isolation(self):
|
||||
from app.main import app
|
||||
|
||||
detail_handlers = [
|
||||
route.endpoint for route in app.routes
|
||||
if route.path == "/miners/{miner_id}/detail"
|
||||
and "GET" in (route.methods or set())
|
||||
]
|
||||
self.assertGreaterEqual(len(detail_handlers), 2)
|
||||
self.assertIs(detail_handlers[0], miner_detail_page)
|
||||
current_user = {
|
||||
"id": str(uuid4()), "username": "reader",
|
||||
"display_name": "Detail Reader", "role": "Read Only",
|
||||
"permissions": ["miners.view"],
|
||||
}
|
||||
app.dependency_overrides[get_current_user] = lambda: current_user
|
||||
try:
|
||||
with patch("app.identity.request_user_or_none", return_value=current_user):
|
||||
with TestClient(app) as client:
|
||||
response = client.get(f"/miners/{self.miner_id}/detail")
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_current_user, None)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.text.count('data-view="miner_detail"'), 1)
|
||||
self.assertIn("/assets/minerops-miner-detail.js", response.text)
|
||||
for legacy in (
|
||||
"/assets/minerops-miner-detail-extra.js",
|
||||
"/assets/minerops-miner-detail-extra-v2.js",
|
||||
"/assets/minerops-miner-detail-clean-v3.js",
|
||||
"/assets/minerops-location-management.js",
|
||||
):
|
||||
self.assertNotIn(legacy, response.text)
|
||||
paths = {route.path for route in app.routes}
|
||||
for legacy_api in (
|
||||
"/api/miners/{miner_id}",
|
||||
"/api/miners/{miner_id}/detail-extra",
|
||||
"/api/miners/{miner_id}/detail-clean-v3",
|
||||
"/api/miners/{miner_id}/network-identities",
|
||||
"/api/miners/{miner_id}/status-snapshots",
|
||||
):
|
||||
self.assertIn(legacy_api, paths)
|
||||
|
||||
def test_frontend_is_lazy_read_only_bounded_and_xss_safe(self):
|
||||
source = (STATIC_ROOT / "miner-detail.js").read_text(encoding="utf-8")
|
||||
self.assertEqual(
|
||||
source.count(
|
||||
'api("/api/miners/" + encodeURIComponent(minerId) + "/overview")'
|
||||
),
|
||||
1,
|
||||
)
|
||||
self.assertEqual(source.count("Promise.all(["), 2)
|
||||
self.assertIn("/asset-id-history", source)
|
||||
self.assertIn("/network-identities", source)
|
||||
self.assertIn("/status-snapshots", source)
|
||||
self.assertIn("/events", source)
|
||||
self.assertIn("/parts", source)
|
||||
self.assertNotIn("setInterval", source)
|
||||
for mutation in (
|
||||
'method: "POST"', 'method: "PATCH"', 'method: "PUT"',
|
||||
'method: "DELETE"', "reboot", "change-mac", "manual-details",
|
||||
):
|
||||
self.assertNotIn(mutation, source)
|
||||
for unsafe in ("innerHTML", "outerHTML", "insertAdjacentHTML", "document.write"):
|
||||
self.assertNotIn(unsafe, source)
|
||||
self.assertIn("textContent", source)
|
||||
self.assertIn("new URLSearchParams", source)
|
||||
self.assertIn("encodeURIComponent", source)
|
||||
self.assertNotRegex(
|
||||
source.lower(),
|
||||
r"(temperature|hashrate|stale).{0,40}(threshold|>=\s*90|>\s*90)",
|
||||
)
|
||||
main_source = (APP_ROOT / "main.py").read_text(encoding="utf-8")
|
||||
snapshot = re.search(
|
||||
r'def list_miner_status_snapshots\(.*?LIMIT 100;',
|
||||
main_source,
|
||||
re.DOTALL,
|
||||
)
|
||||
self.assertIsNotNone(snapshot)
|
||||
|
||||
def test_cross_navigation_preserves_context_with_durable_uuid(self):
|
||||
fleet = (STATIC_ROOT / "fleet.js").read_text(encoding="utf-8")
|
||||
repairs = (STATIC_ROOT / "repair-center.js").read_text(encoding="utf-8")
|
||||
for source in (fleet, repairs):
|
||||
self.assertIn('return_to', source)
|
||||
self.assertIn("window.location.pathname + window.location.search", source)
|
||||
self.assertIn("encodeURIComponent", source)
|
||||
detail = (STATIC_ROOT / "miner-detail.js").read_text(encoding="utf-8")
|
||||
self.assertIn('new URLSearchParams({ miner_id: minerIdValue })', detail)
|
||||
self.assertIn('params.set("site_id", location.site_id)', detail)
|
||||
self.assertIn('params.set("room_id", location.room_id)', detail)
|
||||
self.assertIn('new URLSearchParams({ scope: "all", q: data.miner.id })', detail)
|
||||
|
||||
|
||||
class MinerDetailArchitectureTests(unittest.TestCase):
|
||||
def test_router_and_service_preserve_dependency_boundaries(self):
|
||||
for relative in ("miner_detail/router.py", "miner_detail/service.py"):
|
||||
source = (APP_ROOT / relative).read_text(encoding="utf-8")
|
||||
modules = []
|
||||
for node in ast.walk(ast.parse(source)):
|
||||
if isinstance(node, ast.Import):
|
||||
modules.extend(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
modules.append(node.module)
|
||||
self.assertFalse(
|
||||
any(name.startswith(("psycopg", "sqlalchemy", "asyncpg"))
|
||||
for name in modules)
|
||||
)
|
||||
for keyword in ("SELECT ", "INSERT ", "UPDATE ", "DELETE "):
|
||||
self.assertNotIn(keyword, source.upper())
|
||||
service_source = (APP_ROOT / "miner_detail" / "service.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertNotIn("fastapi", service_source.lower())
|
||||
|
||||
def test_exactly_one_new_api_route_and_no_state_changing_page_get(self):
|
||||
routes = [
|
||||
(route.path, route.methods)
|
||||
for route in detail_router.routes
|
||||
]
|
||||
self.assertEqual(routes, [("/api/miners/{miner_id}/overview", {"GET"})])
|
||||
page = next(
|
||||
route for route in frontend_router.routes
|
||||
if route.path == "/miners/{miner_id}/detail"
|
||||
)
|
||||
self.assertEqual(page.methods, {"GET"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user