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