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 pathlib import Path
|
||||||
from typing import Annotated
|
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 fastapi.responses import HTMLResponse, Response
|
||||||
|
|
||||||
from app.frontend.views import render_app_page
|
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"])
|
router = APIRouter(tags=["frontend"])
|
||||||
@@ -19,11 +20,12 @@ RepairUser = Annotated[dict, Depends(require_permission("repairs.view"))]
|
|||||||
MinerUser = Annotated[dict, Depends(require_permission("miners.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(
|
return render_app_page(
|
||||||
view,
|
view,
|
||||||
current_user,
|
current_user,
|
||||||
lambda permission: user_has_permission(current_user, permission),
|
lambda permission: user_has_permission(current_user, permission),
|
||||||
|
**context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -81,6 +83,15 @@ def repair_center_script() -> Response:
|
|||||||
headers={"Cache-Control": "no-store"},
|
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)
|
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
|
||||||
def dashboard_page(current_user: DashboardUser) -> str:
|
def dashboard_page(current_user: DashboardUser) -> str:
|
||||||
return render_page("dashboard", current_user)
|
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:
|
def miners_page(current_user: MinerUser) -> str:
|
||||||
return render_page("miners", current_user)
|
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 *,
|
.dashboard-section *,
|
||||||
.attention-list * { scroll-behavior: auto; }
|
.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) {
|
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) {
|
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);
|
const label = item.asset_id || item.current_mac || "Miner " + item.miner_id.slice(0, 8);
|
||||||
if (item.capabilities.can_open_miner) {
|
if (item.capabilities.can_open_miner) {
|
||||||
const link = create("a", "object-link", label);
|
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);
|
wrap.appendChild(link);
|
||||||
} else wrap.appendChild(create("strong", "", label));
|
} else wrap.appendChild(create("strong", "", label));
|
||||||
const details = [item.model, item.miner_sn, item.current_mac, item.current_ip]
|
const details = [item.model, item.miner_sn, item.current_mac, item.current_ip]
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
from html import escape
|
from html import escape
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
from app.frontend.miner_detail_view import miner_detail_content
|
||||||
|
|
||||||
|
|
||||||
NAVIGATION = (
|
NAVIGATION = (
|
||||||
("dashboard", "Dashboard", "/dashboard", "dashboard.view"),
|
("dashboard", "Dashboard", "/dashboard", "dashboard.view"),
|
||||||
@@ -32,6 +34,11 @@ PAGE_DETAILS = {
|
|||||||
"Miners",
|
"Miners",
|
||||||
"Search durable miner inventory and inspect current identity and state.",
|
"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,
|
view: str,
|
||||||
current_user: dict,
|
current_user: dict,
|
||||||
has_permission: Callable[[str], bool],
|
has_permission: Callable[[str], bool],
|
||||||
|
*,
|
||||||
|
miner_id: str | None = None,
|
||||||
|
return_to: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
context, title, summary = PAGE_DETAILS[view]
|
context, title, summary = PAGE_DETAILS[view]
|
||||||
display_name = escape(
|
display_name = escape(
|
||||||
@@ -418,10 +428,11 @@ def render_app_page(
|
|||||||
)
|
)
|
||||||
role = escape(str(current_user.get("role") or "Authenticated user"))
|
role = escape(str(current_user.get("role") or "Authenticated user"))
|
||||||
can_view_miners = has_permission("miners.view")
|
can_view_miners = has_permission("miners.view")
|
||||||
|
active_navigation = "miners" if view == "miner_detail" else view
|
||||||
navigation = "".join(
|
navigation = "".join(
|
||||||
(
|
(
|
||||||
f'<li><a href="{href}" aria-current="page">{escape(label)}</a></li>'
|
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>'
|
else f'<li><a href="{href}">{escape(label)}</a></li>'
|
||||||
)
|
)
|
||||||
for key, label, href, permission in NAVIGATION
|
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>'
|
else '<div class="global-search-unavailable" aria-hidden="true"></div>'
|
||||||
)
|
)
|
||||||
content = (
|
content = (
|
||||||
_map_content(has_permission("locations.manage"))
|
miner_detail_content()
|
||||||
|
if view == "miner_detail"
|
||||||
|
else _map_content(has_permission("locations.manage"))
|
||||||
if view == "map"
|
if view == "map"
|
||||||
else _miners_content(has_permission("locations.view"))
|
else _miners_content(has_permission("locations.view"))
|
||||||
if view == "miners"
|
if view == "miners"
|
||||||
@@ -452,7 +465,9 @@ def render_app_page(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
page_script = (
|
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"
|
if view == "map"
|
||||||
else '<script src="/assets/minerops-fleet.js" defer></script>'
|
else '<script src="/assets/minerops-fleet.js" defer></script>'
|
||||||
if view == "miners"
|
if view == "miners"
|
||||||
@@ -462,6 +477,11 @@ def render_app_page(
|
|||||||
if view == "dashboard"
|
if view == "dashboard"
|
||||||
else ""
|
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>
|
return f"""<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -472,7 +492,7 @@ def render_app_page(
|
|||||||
</head>
|
</head>
|
||||||
<body data-view="{escape(view)}"
|
<body data-view="{escape(view)}"
|
||||||
data-can-view-miners="{str(can_view_miners).lower()}"
|
data-can-view-miners="{str(can_view_miners).lower()}"
|
||||||
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>
|
<a class="skip-link" href="#mainContent">Skip to main content</a>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
|
|||||||
@@ -59,6 +59,66 @@ MINER_SELECT = f"""
|
|||||||
) tag_data ON true
|
) 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 = (
|
ACTIVE_REPAIR_STATUSES = (
|
||||||
"OPEN",
|
"OPEN",
|
||||||
"IN_PROGRESS",
|
"IN_PROGRESS",
|
||||||
@@ -260,6 +320,17 @@ class InventoryRepository:
|
|||||||
with connection.cursor(row_factory=dict_row) as cur:
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
return self._fetch_miner(cur, "miners.id = %s", (miner_id,))
|
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:
|
def get_miner_by_id_for_update(self, miner_id: UUID) -> dict | None:
|
||||||
with self._connection_scope() as connection:
|
with self._connection_scope() as connection:
|
||||||
with connection.cursor(row_factory=dict_row) as cur:
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
|||||||
@@ -137,6 +137,13 @@ class InventoryService:
|
|||||||
raise MinerNotFound(miner_id)
|
raise MinerNotFound(miner_id)
|
||||||
return self._detail_model(row)
|
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(
|
def get_asset_id_history(
|
||||||
self,
|
self,
|
||||||
miner_id: UUID,
|
miner_id: UUID,
|
||||||
|
|||||||
@@ -118,6 +118,18 @@ class MineMapResponse(BaseModel):
|
|||||||
error: None = None
|
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):
|
class LocationSummary(BaseModel):
|
||||||
sites: int
|
sites: int
|
||||||
rooms: int
|
rooms: int
|
||||||
|
|||||||
@@ -229,3 +229,29 @@ class LocationMapRepository:
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
return dict(cur.fetchone())
|
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,
|
MineMapData,
|
||||||
MineMapMeta,
|
MineMapMeta,
|
||||||
MineMapResponse,
|
MineMapResponse,
|
||||||
|
MinerLocationDetail,
|
||||||
)
|
)
|
||||||
from app.locations.repository import LocationMapRepository
|
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:
|
def summarize_locations(self, *, include_miner_counts: bool) -> LocationSummary:
|
||||||
return LocationSummary.model_validate(
|
return LocationSummary.model_validate(
|
||||||
self.repository.summarize_locations(
|
self.repository.summarize_locations(
|
||||||
|
|||||||
+13
-1
@@ -23,11 +23,13 @@ from app.identity import (
|
|||||||
)
|
)
|
||||||
from app.frontend.router import (
|
from app.frontend.router import (
|
||||||
dashboard_page as internal_alpha_dashboard_page,
|
dashboard_page as internal_alpha_dashboard_page,
|
||||||
|
miner_detail_page as internal_alpha_miner_detail_page,
|
||||||
router as frontend_router,
|
router as frontend_router,
|
||||||
)
|
)
|
||||||
from app.inventory.router import router as inventory_router
|
from app.inventory.router import router as inventory_router
|
||||||
from app.locations.router import router as locations_router
|
from app.locations.router import router as locations_router
|
||||||
from app.components.router import router as components_router
|
from app.components.router import router as components_router
|
||||||
|
from app.miner_detail.router import router as miner_detail_router
|
||||||
from app.repairs.exceptions import (
|
from app.repairs.exceptions import (
|
||||||
ControlBoardReplacementActorRequired,
|
ControlBoardReplacementActorRequired,
|
||||||
InvalidMacAddress,
|
InvalidMacAddress,
|
||||||
@@ -60,6 +62,7 @@ app.include_router(inventory_router)
|
|||||||
app.include_router(components_router)
|
app.include_router(components_router)
|
||||||
app.include_router(locations_router)
|
app.include_router(locations_router)
|
||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
|
app.include_router(miner_detail_router)
|
||||||
install_identity_middleware(app)
|
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")
|
@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
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -29087,6 +29090,9 @@ async def minerops_inject_detail_extra_v2(request, call_next):
|
|||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if request.scope.get("endpoint") is internal_alpha_miner_detail_page:
|
||||||
|
return response
|
||||||
|
|
||||||
path_value = request.url.path or ""
|
path_value = request.url.path or ""
|
||||||
|
|
||||||
if not (path_value.startswith("/miners/") and path_value.endswith("/detail")):
|
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)
|
response = await call_next(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if request.scope.get("endpoint") is internal_alpha_miner_detail_page:
|
||||||
|
return response
|
||||||
|
|
||||||
path_value = request.url.path or ""
|
path_value = request.url.path or ""
|
||||||
|
|
||||||
if not (path_value.startswith("/miners/") and path_value.endswith("/detail")):
|
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)
|
response = await call_next(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if request.scope.get("endpoint") is internal_alpha_miner_detail_page:
|
||||||
|
return response
|
||||||
|
|
||||||
path = request.url.path or ""
|
path = request.url.path or ""
|
||||||
|
|
||||||
if not (path.startswith("/miners/") and path.endswith("/detail")):
|
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()
|
connection.close()
|
||||||
return {"items": items, "summary": summary, "location_options": options}
|
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]:
|
def summarize_active_repairs(self) -> dict[str, Any]:
|
||||||
"""Return exact active counts without loading Repair Center rows."""
|
"""Return exact active counts without loading Repair Center rows."""
|
||||||
ctes = self._ctes(show_miners=False, show_locations=False)
|
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:
|
def summarize_active_repairs(self) -> ActiveRepairSummary:
|
||||||
return ActiveRepairSummary.model_validate(
|
return ActiveRepairSummary.model_validate(
|
||||||
self.repository.summarize_active_repairs()
|
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()
|
||||||
Executable
+102
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
|
||||||
|
readonly source_files=(
|
||||||
|
backend/app/frontend/miner_detail_view.py
|
||||||
|
backend/app/frontend/router.py
|
||||||
|
backend/app/frontend/static/app.css
|
||||||
|
backend/app/frontend/static/fleet.js
|
||||||
|
backend/app/frontend/static/miner-detail.js
|
||||||
|
backend/app/frontend/static/repair-center.js
|
||||||
|
backend/app/frontend/views.py
|
||||||
|
backend/app/inventory/repository.py
|
||||||
|
backend/app/inventory/service.py
|
||||||
|
backend/app/locations/models.py
|
||||||
|
backend/app/locations/repository.py
|
||||||
|
backend/app/locations/service.py
|
||||||
|
backend/app/main.py
|
||||||
|
backend/app/miner_detail/__init__.py
|
||||||
|
backend/app/miner_detail/dependencies.py
|
||||||
|
backend/app/miner_detail/models.py
|
||||||
|
backend/app/miner_detail/router.py
|
||||||
|
backend/app/miner_detail/service.py
|
||||||
|
backend/app/repairs/center_repository.py
|
||||||
|
backend/app/repairs/center_service.py
|
||||||
|
backend/tests/test_miner_detail.py
|
||||||
|
)
|
||||||
|
|
||||||
|
for file in "${source_files[@]}"; do
|
||||||
|
if [[ ! -r "${file}" ]]; then
|
||||||
|
echo "Miner Detail test input is not readable: ${file}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$(stat -c '%a' "${file}")" != "644" ]]; then
|
||||||
|
echo "Miner Detail source/test mode must be 0644: ${file}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ ! -x scripts/test-miner-detail.sh ]] \
|
||||||
|
|| [[ "$(stat -c '%a' scripts/test-miner-detail.sh)" != "755" ]]; then
|
||||||
|
echo "scripts/test-miner-detail.sh must be executable with mode 0755." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Miner Detail file-mode preflight passed."
|
||||||
|
|
||||||
|
project_name="${MINEROPS_MINER_DETAIL_TEST_PROJECT:-minerops-miner-detail-$$}"
|
||||||
|
compose=(
|
||||||
|
docker compose
|
||||||
|
--project-name "${project_name}"
|
||||||
|
--env-file .env.dev
|
||||||
|
-f compose.dev.yaml
|
||||||
|
)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
if (( status != 0 )); then
|
||||||
|
echo
|
||||||
|
echo "Miner Detail tests failed. Recent PostgreSQL logs:"
|
||||||
|
"${compose[@]}" logs --no-color --tail 120 postgres || true
|
||||||
|
fi
|
||||||
|
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
if [[ ! -f .env.dev ]]; then
|
||||||
|
echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting disposable Miner Detail database..."
|
||||||
|
"${compose[@]}" up --detach postgres
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
if "${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if (( attempt == 60 )); then
|
||||||
|
echo "PostgreSQL did not become ready." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Building Miner Detail test image..."
|
||||||
|
"${compose[@]}" build backend
|
||||||
|
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
|
||||||
|
|
||||||
|
echo "Running Miner Detail and affected-domain focused tests..."
|
||||||
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
-v "${repo_root}/backend/tests:/app/tests:ro" \
|
||||||
|
backend python -m unittest -v \
|
||||||
|
tests.test_miner_detail \
|
||||||
|
tests.test_frontend_shell \
|
||||||
|
tests.test_inventory_domain \
|
||||||
|
tests.test_inventory_router \
|
||||||
|
tests.test_mine_map \
|
||||||
|
tests.test_repair_center
|
||||||
|
|
||||||
|
echo "Miner Detail focused tests passed."
|
||||||
Reference in new issue
Block a user