feat: add Repair Center operations view

This commit is contained in:
lussierj committed 2026-08-11 20:17:16 +00:00
1 parent 786a1ab910
commit 4a1959ecd6
14 files changed
+1963 -338

No files matched your search

+9
View File
@@ -63,6 +63,15 @@ def fleet_script() -> Response:
)
@router.get("/assets/minerops-repair-center.js", include_in_schema=False)
def repair_center_script() -> Response:
return Response(
(STATIC_ROOT / "repair-center.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)
+132
View File
@@ -1357,3 +1357,135 @@ button.map-position:hover {
.fleet-search, .fleet-filter-actions { grid-column: auto; }
.fleet-table td { grid-template-columns: 88px minmax(0, 1fr); }
}
/* Repair Center */
.repair-center { padding: 14px; }
.repair-summary {
display: grid;
grid-template-columns: repeat(3, minmax(100px, 160px));
gap: 8px;
margin: 10px 0 12px;
}
.repair-summary > div {
display: flex;
justify-content: space-between;
gap: 12px;
border-left: 3px solid var(--accent);
padding: 7px 9px;
background: #f6f8fa;
}
.repair-summary span { color: var(--muted); font-size: 12px; }
.repair-filters {
display: grid;
grid-template-columns: minmax(230px, 1.6fr) repeat(6, minmax(125px, 1fr));
align-items: end;
gap: 8px;
margin-bottom: 12px;
}
.repair-filters .field { min-width: 0; }
.repair-filters input,
.repair-filters select { min-height: 38px; }
.repair-search { grid-column: span 2; }
.repair-filter-actions { display: flex; gap: 7px; }
.repair-filter-actions .quiet-button,
.repair-pagination .quiet-button,
.repair-row-actions .quiet-button { color: var(--ink); background: #fff; }
.repair-table { min-width: 1260px; }
.repair-table th,
.repair-table td { padding: 8px 9px; vertical-align: top; }
.repair-row[data-source="legacy"] { border-left: 4px solid #8390a0; }
.repair-row[data-source="modular"] { border-left: 4px solid #176b9a; }
.repair-row[data-active="false"] { opacity: .78; }
.repair-miner { display: grid; gap: 3px; min-width: 150px; }
.source-label { color: var(--muted); font-size: 11px; font-weight: 700; }
.repair-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 800;
}
.repair-status::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #718096; }
.repair-status[data-status="waiting_for_parts"]::before,
.repair-status[data-status="waiting_parts"]::before,
.repair-status[data-status="needs_followup"]::before { background: var(--warn); }
.repair-status[data-status="completed"]::before { background: var(--good); }
.repair-status[data-status="failed"]::before,
.repair-status[data-status="cancelled"]::before { background: var(--danger); }
.repair-row-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.quiet-link {
display: inline-flex;
align-items: center;
min-height: 40px;
border: 1px solid #8390a0;
border-radius: 5px;
padding: 8px 11px;
background: #fff;
text-decoration: none;
}
.repair-result-summary { color: var(--muted); font-size: 13px; }
.repair-pagination { display: flex; justify-content: flex-end; align-items: center; gap: 9px; margin-top: 12px; }
.repair-inspector {
position: fixed;
inset: 68px 0 0 auto;
z-index: 70;
width: min(520px, 100%);
overflow-y: auto;
border-left: 1px solid var(--line);
padding: 18px;
background: var(--surface);
box-shadow: -10px 0 26px rgb(15 23 42 / 18%);
}
.inspector-head { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; }
.repair-detail-section { padding: 14px 0; border-top: 1px solid var(--line); }
.repair-detail-section h3 { margin: 0 0 9px; font-size: 16px; }
.repair-timeline,
.repair-parts-list { display: grid; gap: 9px; margin: 0; padding-left: 22px; }
.repair-timeline li,
.repair-parts-list li { padding: 7px 0; }
.repair-timeline li > span,
.repair-parts-list li > span { display: block; }
.voided-part { text-decoration: line-through; }
.repair-action-form { display: grid; gap: 8px; margin: 10px 0; }
.repair-action-form input,
.repair-action-form select,
.repair-action-form textarea { width: 100%; min-height: 40px; }
.repair-action-form textarea { min-height: 78px; resize: vertical; }
.danger-button {
min-height: 40px;
border: 1px solid var(--danger);
border-radius: 5px;
padding: 8px 11px;
color: var(--danger);
background: #fff;
font-weight: 750;
cursor: pointer;
}
.state-inline-error { color: var(--danger); font-weight: 700; }
@media (max-width: 980px) {
.repair-filters { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.repair-search { grid-column: span 2; }
}
@media (max-width: 680px) {
.repair-center { padding: 12px; }
.repair-summary { grid-template-columns: repeat(3, 1fr); }
.repair-summary > div { display: grid; gap: 2px; }
.repair-filters { grid-template-columns: 1fr 1fr; }
.repair-search, .repair-filter-actions { grid-column: 1 / -1; }
.repair-filter-actions > * { flex: 1; }
.repair-table { min-width: 0; }
.repair-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.repair-table tbody { display: grid; gap: 12px; }
.repair-table tr { display: block; border: 1px solid var(--line); padding: 8px; }
.repair-table td { display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: 8px; border: 0; }
.repair-table td::before { content: attr(data-label); color: var(--muted); font-size: 11px; font-weight: 800; }
.repair-pagination { justify-content: space-between; flex-wrap: wrap; }
.repair-inspector { inset: 0; width: 100%; }
}
@media (max-width: 420px) {
.repair-filters { grid-template-columns: 1fr; }
.repair-search, .repair-filter-actions { grid-column: auto; }
.repair-table td { grid-template-columns: 90px minmax(0, 1fr); }
}
-198
View File
@@ -1,93 +1,6 @@
(function () {
"use strict";
const view = document.body.dataset.view || "";
const canViewMiners =
document.body.dataset.canViewMiners === "true";
function text(value, fallback) {
if (value === null || value === undefined || String(value).trim() === "") {
return fallback || "Not available";
}
return String(value);
}
function statusName(value) {
return text(value, "unknown").toLowerCase().replaceAll(" ", "_");
}
function create(tag, options) {
const element = document.createElement(tag);
const values = options || {};
if (values.className) element.className = values.className;
if (values.text !== undefined) element.textContent = values.text;
if (values.href) element.href = values.href;
return element;
}
function showState(name, message) {
for (const state of document.querySelectorAll("[data-view-state]")) {
state.hidden = state.dataset.viewState !== name;
}
const active = document.querySelector(
'[data-view-state="' + name + '"]'
);
const detail = active && active.querySelector("[data-state-detail]");
if (detail && message) detail.textContent = message;
}
async function api(path) {
const controller = new AbortController();
const timeout = window.setTimeout(function () {
controller.abort();
}, 15000);
let response;
try {
response = await fetch(path, {
credentials: "same-origin",
headers: { Accept: "application/json" },
signal: controller.signal
});
} catch (error) {
if (error && error.name === "AbortError") {
throw new Error(
"MinerOps took too long to respond. Retry in a moment."
);
}
throw new Error(
"MinerOps could not reach the backend. Check the connection and retry."
);
} finally {
window.clearTimeout(timeout);
}
if (response.status === 401) {
window.location.replace(
"/login?next=" +
encodeURIComponent(window.location.pathname + window.location.search)
);
throw new Error("Your session expired. Sign in again to continue.");
}
if (response.status === 403) {
const denied = new Error(
"Your account does not have permission to view this information."
);
denied.permissionDenied = true;
throw denied;
}
if (!response.ok) {
throw new Error(
"MinerOps could not load this information. Retry in a moment."
);
}
try {
return await response.json();
} catch (error) {
throw new Error(
"MinerOps returned an invalid response. Retry in a moment."
);
}
}
function setupShell() {
const menu = document.getElementById("menuButton");
const nav = document.getElementById("primaryNav");
@@ -128,118 +41,7 @@
}
}
function addCell(row, label, content) {
const cell = create("td");
cell.dataset.label = label;
if (content instanceof Node) cell.appendChild(content);
else cell.textContent = text(content);
row.appendChild(cell);
}
function status(value) {
const label = create("span", {
className: "status-text",
text: text(value, "Unknown").replaceAll("_", " ")
});
label.dataset.status = statusName(value);
return label;
}
function dateTime(value) {
if (!value) return "Not available";
const parsed = new Date(value);
return Number.isNaN(parsed.valueOf())
? String(value)
: parsed.toLocaleString();
}
async function loadRepairs() {
const rows = document.getElementById("repairRows");
if (!rows) return;
showState("loading");
try {
const envelope = await api("/api/repairs/queue");
if (
!envelope ||
!envelope.data ||
typeof envelope.data !== "object" ||
!Array.isArray(envelope.data.items)
) {
throw new Error(
"MinerOps returned an invalid repair response."
);
}
const items = envelope.data.items;
if (!items.every(function (item) {
return item &&
typeof item === "object" &&
typeof item.miner_id === "string" &&
item.miner_id.trim() !== "";
})) {
throw new Error(
"MinerOps returned an incomplete repair response."
);
}
rows.replaceChildren();
if (!items.length) {
showState("empty");
return;
}
for (const repair of items) {
const row = create("tr");
const identity = create("div");
const minerLabel = text(
repair.current_mac,
text(repair.miner_id, "Unknown miner")
);
if (canViewMiners) {
identity.appendChild(create("a", {
className: "object-link",
text: minerLabel,
href: "/miners/" +
encodeURIComponent(repair.miner_id) +
"/detail"
}));
} else {
identity.appendChild(create("strong", {
text: minerLabel
}));
}
identity.appendChild(create("span", {
className: "secondary",
text: text(repair.current_ip, "No current IP")
}));
addCell(row, "Miner", identity);
addCell(row, "Model", repair.model);
addCell(
row,
"Repair state",
status(repair.latest_event_status || repair.asset_state)
);
addCell(
row,
"Reported issue",
repair.reported_error ||
repair.latest_event_title ||
"No reported issue"
);
addCell(
row,
"Last update",
dateTime(repair.latest_event_at || repair.state_updated_at)
);
rows.appendChild(row);
}
showState("ready");
} catch (error) {
showState(
error.permissionDenied ? "denied" : "error",
error.message
);
}
}
setupShell();
if (view === "repairs") loadRepairs();
})();
@@ -0,0 +1,610 @@
(function () {
"use strict";
const optionalFieldIds = ["repairModel", "repairSite", "repairRoom"];
const ids = [
"repairFilters", "repairQuery", "repairScope", "repairStatus",
"repairLifecycle", "repairReset", "repairRows",
"repairTotal", "repairActive", "repairHistorical", "repairFreshness",
"repairResultSummary", "repairPrevious", "repairNext", "repairLimit",
"repairInspector", "repairInspectorTitle", "repairInspectorBody",
"repairInspectorClose"
];
const el = {};
for (const id of ids) el[id] = document.getElementById(id);
for (const id of optionalFieldIds) el[id] = document.getElementById(id);
if (!el.repairFilters || !el.repairRows) return;
const STATUS_LABELS = {
OPEN: "Open", IN_PROGRESS: "In progress",
WAITING_FOR_PARTS: "Waiting for parts", READY_FOR_TEST: "Ready for test",
COMPLETED: "Completed", CANCELLED: "Cancelled",
open: "Open", diagnosing: "Diagnosing", waiting_parts: "Waiting parts",
in_progress: "In progress", testing: "Testing",
ready_to_shelf: "Ready to shelf", needs_followup: "Needs follow-up",
completed: "Completed", failed: "Failed", cancelled: "Cancelled",
repair: "In repair", unknown: "Unknown"
};
const state = { offset: 0, limit: 50, items: new Map(), rooms: [], opener: null };
let controller = null;
function text(value, fallback) {
if (value === null || value === undefined || String(value).trim() === "") {
return fallback || "Not available";
}
return String(value);
}
function create(tag, className, value) {
const node = document.createElement(tag);
if (className) node.className = className;
if (value !== undefined) node.textContent = value;
return node;
}
function button(label, action, className) {
const node = create("button", className || "quiet-button", label);
node.type = "button";
node.dataset.action = action;
return node;
}
function showState(name, message) {
for (const node of document.querySelectorAll("[data-view-state]")) {
node.hidden = node.dataset.viewState !== name;
}
const active = document.querySelector('[data-view-state="' + name + '"]');
const detail = active && active.querySelector("[data-state-detail]");
if (detail && message) detail.textContent = message;
}
function friendlyStatus(value) {
return STATUS_LABELS[value] || text(value, "Unknown").replaceAll("_", " ");
}
function dateTime(value) {
if (!value) return "Not available";
const parsed = new Date(value);
return Number.isNaN(parsed.valueOf()) ? String(value) : parsed.toLocaleString();
}
function age(value) {
if (!value) return "Age unavailable";
const milliseconds = Date.now() - new Date(value).valueOf();
if (!Number.isFinite(milliseconds) || milliseconds < 0) return "Age unavailable";
const hours = Math.floor(milliseconds / 3600000);
if (hours < 24) return hours + "h open";
return Math.floor(hours / 24) + "d open";
}
function labelPart(prefix, value) {
if (!value) return null;
const clean = String(value).trim();
return clean.toLowerCase().startsWith(prefix.toLowerCase() + " ")
? clean : prefix + " " + clean;
}
function locationText(item) {
const parts = [item.site_name, item.room_name,
labelPart("Rack", item.rack_label), labelPart("Shelf", item.shelf_label),
item.position_label || (item.position_index !== null ? "Position " + item.position_index : null)];
return parts.filter(Boolean).join(" / ") || "Unplaced";
}
async function api(path, options) {
const response = await fetch(path, Object.assign({
credentials: "same-origin", headers: { Accept: "application/json" }
}, options || {}));
if (response.status === 401) {
window.location.replace("/login?next=" + encodeURIComponent(
window.location.pathname + window.location.search));
throw new Error("Your session expired.");
}
const payload = await response.json().catch(function () { return null; });
if (!response.ok) {
const error = new Error(payload && payload.detail
? String(payload.detail) : "The repair request failed.");
error.permissionDenied = response.status === 403;
throw error;
}
return payload;
}
function jsonOptions(method, body) {
return { method: method, headers: { Accept: "application/json",
"Content-Type": "application/json" }, body: JSON.stringify(body) };
}
function readUrl() {
const params = new URLSearchParams(window.location.search);
el.repairQuery.value = params.get("query") || "";
el.repairScope.value = params.get("scope") || "active";
el.repairStatus.value = params.get("status") || "";
if (el.repairModel) el.repairModel.value = params.get("model") || "";
el.repairLifecycle.value = params.get("lifecycle_status") || "";
if (el.repairSite) el.repairSite.value = params.get("site_id") || "";
state.pendingRoom = params.get("room_id") || "";
state.offset = Math.max(0, Number(params.get("offset")) || 0);
state.limit = [25, 50, 100, 250].includes(Number(params.get("limit")))
? Number(params.get("limit")) : 50;
el.repairLimit.value = String(state.limit);
}
function currentParams(includePaging) {
const params = new URLSearchParams();
const values = {
query: el.repairQuery.value.trim(), scope: el.repairScope.value,
status: el.repairStatus.value,
model: el.repairModel ? el.repairModel.value.trim() : "",
lifecycle_status: el.repairLifecycle.value,
site_id: el.repairSite ? el.repairSite.value : "",
room_id: el.repairRoom ? el.repairRoom.value : ""
};
for (const [key, value] of Object.entries(values)) {
if (value && !(key === "scope" && value === "active")) params.set(key, value);
}
if (includePaging) {
params.set("limit", String(state.limit));
if (state.offset) params.set("offset", String(state.offset));
}
return params;
}
function syncUrl(mode) {
const query = currentParams(true).toString();
const target = window.location.pathname + (query ? "?" + query : "");
window.history[mode === "replace" ? "replaceState" : "pushState"]({}, "", target);
}
function syncLocationOptions(options) {
if (!el.repairSite || !el.repairRoom) return;
const selectedSite = el.repairSite.value;
const selectedRoom = state.pendingRoom || el.repairRoom.value;
el.repairSite.replaceChildren(new Option("All sites", ""));
for (const site of options.sites || []) {
el.repairSite.appendChild(new Option(site.name, site.id));
}
if ([...el.repairSite.options].some(function (option) {
return option.value === selectedSite;
})) el.repairSite.value = selectedSite;
state.rooms = options.rooms || [];
el.repairRoom.replaceChildren(new Option("All rooms", ""));
for (const room of state.rooms) {
if (!el.repairSite.value || String(room.site_id) === el.repairSite.value) {
el.repairRoom.appendChild(new Option(room.name, room.id));
}
}
if ([...el.repairRoom.options].some(function (option) {
return option.value === selectedRoom;
})) el.repairRoom.value = selectedRoom;
state.pendingRoom = "";
}
function addCell(row, label, content) {
const cell = create("td");
cell.dataset.label = label;
if (content instanceof Node) cell.appendChild(content);
else cell.textContent = text(content);
row.appendChild(cell);
}
function identityCell(item) {
const wrap = create("div", "repair-miner");
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";
wrap.appendChild(link);
} else wrap.appendChild(create("strong", "", label));
const details = [item.model, item.miner_sn, item.current_mac, item.current_ip]
.filter(Boolean).join(" / ");
if (details) wrap.appendChild(create("span", "secondary", details));
wrap.appendChild(create("span", "source-label", item.source === "modular"
? "Repair domain" : "Legacy compatibility"));
return wrap;
}
function actionCell(item) {
const wrap = create("div", "repair-row-actions");
if (item.capabilities.can_open_map) {
const params = new URLSearchParams({ site_id: item.site_id,
room_id: item.room_id, miner_id: item.miner_id });
const link = create("a", "quiet-link", "Mine Map");
link.href = "/monitoring/map?" + params.toString();
wrap.appendChild(link);
}
if (Object.values(item.capabilities).some(Boolean)) {
const details = button("Details", "details");
details.dataset.key = item.source + ":" + (item.repair_id || item.miner_id);
wrap.appendChild(details);
}
return wrap;
}
function renderRows(items) {
el.repairRows.replaceChildren();
state.items.clear();
for (const item of items) {
const key = item.source + ":" + (item.repair_id || item.miner_id);
state.items.set(key, item);
const row = create("tr", "repair-row");
row.dataset.source = item.source;
row.dataset.active = String(item.active);
addCell(row, "Miner", identityCell(item));
const status = create("span", "repair-status", friendlyStatus(item.repair_status));
status.dataset.status = String(item.repair_status).toLowerCase();
const stateWrap = create("div");
stateWrap.append(status, create("span", "secondary", text(item.lifecycle_status)));
addCell(row, "Repair state", stateWrap);
addCell(row, "Reported problem", item.reported_problem || item.reason);
addCell(row, "Latest context", item.latest_context);
addCell(row, "Location", locationText(item));
const opened = create("div");
opened.append(create("span", "", dateTime(item.opened_at)),
create("span", "secondary", item.active ? age(item.opened_at) : "Historical"));
addCell(row, "Opened / age", opened);
addCell(row, "Parts", item.parts_count === null
? "Legacy record" : item.parts_count + " recorded");
addCell(row, "Actions", actionCell(item));
el.repairRows.appendChild(row);
}
}
function section(title) {
const node = create("section", "repair-detail-section");
node.appendChild(create("h3", "", title));
return node;
}
function renderTimeline(events, legacy) {
const node = section(legacy ? "Legacy repair history" : "Repair timeline");
if (!events.length) {
node.appendChild(create("p", "secondary", "No history entries recorded."));
return node;
}
const list = create("ol", "repair-timeline");
for (const event of events) {
const entry = create("li");
const title = legacy
? text(event.event_title, event.event_type)
: friendlyStatus(event.event_type);
entry.append(create("strong", "", title),
create("span", "secondary", dateTime(event.occurred_at || event.event_at || event.created_at)));
const payload = legacy ? event.description : Object.entries(event.payload || {})
.map(function (pair) { return pair[0].replaceAll("_", " ") + ": " + pair[1]; })
.join(" / ");
if (payload) entry.appendChild(create("p", "", String(payload)));
list.appendChild(entry);
}
node.appendChild(list);
return node;
}
function partLabel(part) {
if (part.consumable) {
return part.consumable.quantity + " " + part.consumable.unit + " " +
part.consumable.description;
}
const snapshot = part.component_snapshot || {};
return [snapshot.component_type, snapshot.asset_id,
snapshot.manufacturer_serial].filter(Boolean).join(" / ") || "Tracked component";
}
function renderParts(item, parts) {
const node = section("Parts used");
if (!parts.length) node.appendChild(create("p", "secondary", "No parts recorded."));
const list = create("ul", "repair-parts-list");
for (const part of parts) {
const entry = create("li");
entry.appendChild(create("span", part.voided_at ? "voided-part" : "", partLabel(part)));
if (part.notes) entry.appendChild(create("span", "secondary", part.notes));
if (part.voided_at) {
entry.appendChild(create("span", "secondary", "Voided: " + text(part.void_reason)));
} else if (item.capabilities.can_manage_modular_parts) {
const voidButton = button("Void entry", "void-part", "danger-button");
voidButton.dataset.partId = part.repair_part_id;
entry.appendChild(voidButton);
}
list.appendChild(entry);
}
node.appendChild(list);
if (item.capabilities.can_manage_modular_parts) {
const form = create("form", "repair-action-form");
form.dataset.action = "record-part";
const kind = create("select");
kind.name = "kind";
kind.append(new Option("Consumable", "consumable"),
new Option("Tracked component", "tracked_component"));
const description = create("input");
description.name = "description"; description.placeholder = "Consumable description";
const quantity = create("input");
quantity.name = "quantity"; quantity.type = "number";
quantity.min = "0.001"; quantity.step = "0.001"; quantity.value = "1";
const unit = create("select"); unit.name = "unit";
for (const value of ["each", "gram", "millilitre", "metre", "centimetre"]) {
unit.appendChild(new Option(value, value));
}
const component = create("input");
component.name = "component"; component.placeholder = "Tracked component UUID";
const notes = create("input"); notes.name = "notes"; notes.placeholder = "Optional notes";
form.append(kind, description, quantity, unit, component, notes,
create("button", "command-button", "Record part"));
form.lastChild.type = "submit";
node.appendChild(form);
}
return node;
}
function modularActions(item) {
const node = section("Next action");
if (item.capabilities.can_complete_modular_repair) {
const form = create("form", "repair-action-form");
form.dataset.action = "complete-repair";
const target = create("select"); target.name = "target";
target.append(new Option("Return active", "active"), new Option("Return as spare", "spare"));
const summary = create("textarea"); summary.name = "summary";
summary.placeholder = "Optional completion summary";
const submit = create("button", "command-button", "Complete repair");
submit.type = "submit";
form.append(target, summary, submit);
node.appendChild(form);
} else node.appendChild(create("p", "secondary", "No repair action is available."));
return node;
}
function legacyActions(item) {
const node = section("Legacy actions");
if (!item.capabilities.can_manage_legacy) {
node.appendChild(create("p", "secondary", "Legacy repair actions are read-only."));
return node;
}
const form = create("form", "repair-action-form");
form.dataset.action = "legacy-update";
const status = create("select"); status.name = "status";
for (const value of ["open", "diagnosing", "waiting_parts", "in_progress",
"testing", "ready_to_shelf", "needs_followup", "completed", "failed", "cancelled"]) {
const option = new Option(friendlyStatus(value), value);
option.selected = value === item.repair_status;
status.appendChild(option);
}
const note = create("textarea"); note.name = "note"; note.placeholder = "Repair note";
const noteButton = create("button", "quiet-button", "Add note");
noteButton.type = "submit"; noteButton.value = "note"; noteButton.name = "intent";
const statusButton = create("button", "quiet-button", "Save status");
statusButton.type = "submit"; statusButton.value = "status"; statusButton.name = "intent";
form.append(status, note, noteButton, statusButton);
node.append(form, button("Mark repaired", "legacy-repaired", "command-button"),
button("Decommission", "legacy-decommission", "danger-button"));
return node;
}
function closeInspector() {
el.repairInspector.hidden = true;
el.repairInspectorBody.replaceChildren();
if (state.opener) state.opener.focus();
}
async function openInspector(item, opener) {
state.currentItem = item;
state.opener = opener;
el.repairInspector.hidden = false;
el.repairInspectorTitle.textContent = item.asset_id || item.current_mac || "Repair details";
el.repairInspectorBody.replaceChildren();
const overview = section("Repair overview");
overview.append(
create("p", "", friendlyStatus(item.repair_status) + " / " + text(item.lifecycle_status)),
create("p", "", text(item.reported_problem, "No reported problem")),
create("p", "secondary", locationText(item)),
create("p", "secondary", "Opened " + dateTime(item.opened_at))
);
el.repairInspectorBody.appendChild(overview);
el.repairInspectorClose.focus();
try {
if (item.source === "modular") {
const canRead = item.capabilities.can_read_modular_timeline;
const requests = canRead ? [
api("/api/repairs/" + encodeURIComponent(item.repair_id) + "/events"),
api("/api/repairs/" + encodeURIComponent(item.repair_id) + "/parts")
] : [];
const results = await Promise.all(requests);
if (canRead) {
el.repairInspectorBody.append(
renderTimeline(results[0].data.events || [], false),
renderParts(item, results[1].data.entries || [])
);
}
el.repairInspectorBody.appendChild(modularActions(item));
} else {
if (item.capabilities.can_read_legacy_history) {
const history = await api("/api/miners/" +
encodeURIComponent(item.miner_id) + "/maintenance-events");
const events = (history.data || []).filter(function (event) {
return String(event.event_type || "").toLowerCase() === "repair";
});
el.repairInspectorBody.appendChild(renderTimeline(events, true));
}
el.repairInspectorBody.appendChild(legacyActions(item));
}
} catch (error) {
el.repairInspectorBody.appendChild(create("p", "state-inline-error", error.message));
}
}
function selectedItem(target) {
const keyNode = target.closest("[data-key]");
return keyNode ? state.items.get(keyNode.dataset.key) : null;
}
async function reloadAfterMutation(message) {
closeInspector();
await load();
el.repairResultSummary.textContent = message + " " + el.repairResultSummary.textContent;
}
async function handleInspectorSubmit(event) {
event.preventDefault();
const form = event.target;
const item = state.currentItem;
if (!item) return;
const data = new FormData(form);
try {
if (form.dataset.action === "complete-repair") {
if (!window.confirm("Complete this repair and change the miner lifecycle?")) return;
await api("/api/repairs/" + encodeURIComponent(item.repair_id) + "/complete",
jsonOptions("POST", { target_lifecycle: data.get("target"),
completion_summary: String(data.get("summary") || "").trim() || null }));
await reloadAfterMutation("Repair completed.");
} else if (form.dataset.action === "record-part") {
const kind = data.get("kind");
const body = { notes: String(data.get("notes") || "").trim() || null };
if (kind === "consumable") {
body.consumable = { description: String(data.get("description") || "").trim(),
quantity: String(data.get("quantity") || ""), unit: data.get("unit") };
} else body.tracked_component_uuid = String(data.get("component") || "").trim();
await api("/api/repairs/" + encodeURIComponent(item.repair_id) + "/parts",
jsonOptions("POST", body));
await openInspector(item, state.opener);
} else if (form.dataset.action === "legacy-update") {
const intent = event.submitter && event.submitter.value;
const note = String(data.get("note") || "").trim();
const path = "/api/repairs/" + encodeURIComponent(item.miner_id) +
(intent === "note" ? "/note" : "/status");
const body = intent === "note"
? { note: note, event_status: data.get("status") }
: { event_status: data.get("status"), note: note || null };
await api(path, jsonOptions("POST", body));
await reloadAfterMutation(intent === "note" ? "Repair note added." : "Repair status updated.");
}
} catch (error) {
form.prepend(create("p", "state-inline-error", error.message));
}
}
async function handleInspectorClick(event) {
const action = event.target.closest("[data-action]");
const item = state.currentItem;
if (!action || !item) return;
try {
if (action.dataset.action === "void-part") {
const reason = window.prompt("Reason for voiding this repair-part entry:", "");
if (!reason || !reason.trim() || !window.confirm("Void this repair-part entry? Its history will remain recorded.")) return;
await api("/api/repairs/" + encodeURIComponent(item.repair_id) + "/parts/" +
encodeURIComponent(action.dataset.partId) + "/void",
jsonOptions("POST", { reason: reason.trim() }));
await openInspector(item, state.opener);
} else if (action.dataset.action === "legacy-repaired") {
if (!window.confirm("Mark this miner repaired and move it to Waiting Placement?")) return;
await api("/api/repairs/" + encodeURIComponent(item.miner_id) + "/state",
jsonOptions("POST", { asset_state: "waiting_placement",
note: "Repair marked complete. Miner is waiting for placement." }));
await reloadAfterMutation("Miner moved to Waiting Placement.");
} else if (action.dataset.action === "legacy-decommission") {
const typed = window.prompt("Type DECOMMISSION to confirm:", "");
if (typed !== "DECOMMISSION") return;
await api("/api/repairs/" + encodeURIComponent(item.miner_id) + "/state",
jsonOptions("POST", { asset_state: "decommissioned",
note: "Miner decommissioned from Repair Center." }));
await reloadAfterMutation("Miner decommissioned.");
}
} catch (error) {
el.repairInspectorBody.prepend(create("p", "state-inline-error", error.message));
}
}
function populateStatusOptions(counts) {
const selected = el.repairStatus.value;
const values = Object.keys(counts || {}).sort(function (left, right) {
return friendlyStatus(left).localeCompare(friendlyStatus(right));
});
el.repairStatus.replaceChildren(new Option("All statuses", ""));
for (const value of values) {
el.repairStatus.appendChild(new Option(
friendlyStatus(value) + " (" + counts[value] + ")", value));
}
if ([...el.repairStatus.options].some(function (option) {
return option.value === selected;
})) el.repairStatus.value = selected;
}
async function load() {
if (controller) controller.abort();
controller = new AbortController();
showState("loading");
el.repairFreshness.textContent = "Loading repair work";
try {
const params = currentParams(true);
const envelope = await api("/api/repairs/queue?" + params.toString(),
{ signal: controller.signal });
if (!envelope || !envelope.data || !Array.isArray(envelope.data.items) ||
!envelope.data.summary || !envelope.meta) {
throw new Error("MinerOps returned an invalid Repair Center response.");
}
const items = envelope.data.items;
if (!items.every(function (item) {
return item && item.miner_id && item.source && item.capabilities;
})) throw new Error("MinerOps returned incomplete repair records.");
const summary = envelope.data.summary;
el.repairTotal.textContent = String(summary.total);
el.repairActive.textContent = String(summary.active);
el.repairHistorical.textContent = String(summary.historical);
populateStatusOptions(summary.status_counts);
syncLocationOptions(envelope.meta.location_options || {});
renderRows(items);
const start = summary.total ? state.offset + 1 : 0;
const end = state.offset + items.length;
el.repairResultSummary.textContent = "Showing " + start + "-" + end +
" of " + summary.total + " matching repairs.";
el.repairPrevious.disabled = state.offset === 0;
el.repairNext.disabled = end >= summary.total;
el.repairFreshness.textContent = "Loaded " + new Date().toLocaleTimeString();
showState(items.length ? "ready" : "empty");
} catch (error) {
if (error.name === "AbortError") return;
showState(error.permissionDenied ? "denied" : "error", error.message);
el.repairFreshness.textContent = "Repair data unavailable";
}
}
el.repairRows.addEventListener("click", function (event) {
const action = event.target.closest('[data-action="details"]');
const item = selectedItem(event.target);
if (!action || !item) return;
state.currentItem = item;
openInspector(item, action);
});
el.repairInspectorClose.addEventListener("click", closeInspector);
el.repairInspector.addEventListener("submit", handleInspectorSubmit);
el.repairInspector.addEventListener("click", handleInspectorClick);
document.addEventListener("keydown", function (event) {
if (event.key === "Escape" && !el.repairInspector.hidden) closeInspector();
});
el.repairFilters.addEventListener("submit", function (event) {
event.preventDefault(); state.offset = 0; syncUrl("push"); load();
});
el.repairReset.addEventListener("click", function () {
el.repairFilters.reset(); state.offset = 0; state.limit = 50;
state.pendingRoom = ""; el.repairLimit.value = "50";
syncUrl("push"); load();
});
if (el.repairSite) el.repairSite.addEventListener("change", function () {
state.pendingRoom = "";
syncLocationOptions({ sites: [...el.repairSite.options].slice(1).map(function (option) {
return { id: option.value, name: option.textContent };
}), rooms: state.rooms });
});
el.repairPrevious.addEventListener("click", function () {
state.offset = Math.max(0, state.offset - state.limit); syncUrl("push"); load();
});
el.repairNext.addEventListener("click", function () {
state.offset += state.limit; syncUrl("push"); load();
});
el.repairLimit.addEventListener("change", function () {
state.limit = Number(el.repairLimit.value); state.offset = 0; syncUrl("push"); load();
});
window.addEventListener("popstate", function () { readUrl(); load(); });
readUrl();
syncUrl("replace");
load();
})();
+73 -14
View File
@@ -199,26 +199,79 @@ def _state_messages(noun: str) -> str:
"""
def _repairs_content() -> str:
def _repairs_content(can_view_locations: bool, can_view_miners: bool) -> str:
location_filters = """
<div class="field"><label for="repairSite">Site</label>
<select id="repairSite"><option value="">All sites</option></select></div>
<div class="field"><label for="repairRoom">Room</label>
<select id="repairRoom"><option value="">All rooms</option></select></div>
""" if can_view_locations else ""
search_placeholder = (
"Miner, model, status, reason, or context"
if can_view_miners else "Repair status, reason, or context"
)
model_filter = """
<div class="field"><label for="repairModel">Model</label>
<input id="repairModel" type="text" autocomplete="off" placeholder="Any model"></div>
""" if can_view_miners else ""
return f"""
<section class="panel" aria-labelledby="repairQueueTitle">
<section class="panel repair-center" aria-labelledby="repairQueueTitle">
<div class="panel-head">
<div>
<h2 id="repairQueueTitle">Current repair queue</h2>
<p class="panel-copy">Real records from the existing repair queue
API. Open a miner to continue in its current detail view.</p>
<div><h2 id="repairQueueTitle">Repair Center</h2>
<p class="panel-copy">Active work and historical modular repairs, with legacy queue records clearly identified.</p></div>
<span class="freshness" id="repairFreshness">Loading repair work</span>
</div>
<div class="repair-summary" aria-label="Repair summary">
<div><span>Total results</span><strong id="repairTotal">-</strong></div>
<div><span>Active</span><strong id="repairActive">-</strong></div>
<div><span>Historical</span><strong id="repairHistorical">-</strong></div>
</div>
<form class="repair-filters" id="repairFilters" role="search">
<div class="field repair-search"><label for="repairQuery">Search repairs</label>
<input id="repairQuery" type="search" autocomplete="off"
placeholder="{search_placeholder}"></div>
<div class="field"><label for="repairScope">Scope</label>
<select id="repairScope"><option value="active">Active</option>
<option value="historical">Historical</option><option value="all">All</option></select></div>
<div class="field"><label for="repairStatus">Repair status</label>
<select id="repairStatus"><option value="">All statuses</option></select></div>
{model_filter}
<div class="field"><label for="repairLifecycle">Miner lifecycle</label>
<select id="repairLifecycle"><option value="">All lifecycle states</option>
<option value="active">Active</option><option value="spare">Spare</option>
<option value="in_repair">In repair</option><option value="retired">Retired</option></select></div>
{location_filters}
<div class="repair-filter-actions">
<button class="command-button" type="submit">Apply</button>
<button class="quiet-button" id="repairReset" type="button">Reset</button>
</div>
<span class="freshness">Loaded on request</span>
</div>
</form>
{_state_messages("repair records")}
<div class="table-wrap" data-view-state="ready" hidden>
<table>
<thead><tr><th>Miner</th><th>Model</th><th>Repair state</th>
<th>Reported issue</th><th>Last update</th></tr></thead>
<tbody id="repairRows"></tbody>
</table>
<div data-view-state="ready" hidden>
<p class="repair-result-summary" id="repairResultSummary" aria-live="polite"></p>
<div class="table-wrap repair-table-wrap">
<table class="repair-table">
<thead><tr><th>Miner</th><th>Repair state</th><th>Reported problem</th>
<th>Latest context</th><th>Location</th><th>Opened / age</th>
<th>Parts</th><th>Actions</th></tr></thead>
<tbody id="repairRows"></tbody>
</table>
</div>
</div>
<nav class="repair-pagination" aria-label="Repair result pages">
<button class="quiet-button" id="repairPrevious" type="button" disabled>Previous</button>
<label for="repairLimit">Rows per page</label>
<select id="repairLimit"><option value="25">25</option><option value="50">50</option>
<option value="100">100</option><option value="250">250</option></select>
<button class="quiet-button" id="repairNext" type="button" disabled>Next</button>
</nav>
</section>
<aside class="repair-inspector" id="repairInspector" aria-labelledby="repairInspectorTitle" hidden>
<div class="inspector-head"><div><span class="eyebrow">Repair context</span>
<h2 id="repairInspectorTitle">Repair details</h2></div>
<button class="icon-button" id="repairInspectorClose" type="button" aria-label="Close repair details">X</button></div>
<div id="repairInspectorBody"></div>
</aside>
"""
@@ -338,6 +391,10 @@ def render_app_page(
if view == "map"
else _miners_content(has_permission("locations.view"))
if view == "miners"
else _repairs_content(
has_permission("locations.view"), has_permission("miners.view")
)
if view == "repairs"
else CONTENT[view]()
)
page_script = (
@@ -345,6 +402,8 @@ def render_app_page(
if view == "map"
else '<script src="/assets/minerops-fleet.js" defer></script>'
if view == "miners"
else '<script src="/assets/minerops-repair-center.js" defer></script>'
if view == "repairs"
else ""
)
return f"""<!doctype html>
+8
View File
@@ -753,6 +753,14 @@ def component_request(path: str) -> bool:
def required_api_permission(method: str, path: str) -> str | None:
parts = path.split("/")
if (
method == "GET"
and len(parts) == 5
and parts[:3] == ["", "api", "miners"]
and parts[4] == "maintenance-events"
):
return "repairs.read"
if component_request(path):
return "components.create" if method == "POST" else "components.read"
if repair_parts_request(path):
+97 -116
View File
@@ -7,7 +7,7 @@ import psycopg
import redis
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import Request, Depends, FastAPI, Header, HTTPException, status
from fastapi import Request, Depends, FastAPI, Header, HTTPException, Query, status
from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError
from app.config import settings
@@ -17,6 +17,7 @@ from app.identity import (
get_current_user,
install_identity_middleware,
require_permission,
user_has_permission,
router as identity_router,
)
from app.frontend.router import router as frontend_router
@@ -30,11 +31,17 @@ from app.repairs.exceptions import (
RepairMinerNotFound,
)
from app.repairs.models import (
RepairCenterAccess,
RepairCenterFilters,
RepairCenterScope,
RepairMinerLifecycle,
ControlBoardReplacementReason,
ControlBoardReplacementRequest,
RemovedBoardDisposition,
)
from app.repairs.repository import RepairRepository
from app.repairs.center_repository import RepairCenterRepository
from app.repairs.center_service import RepairCenterService
from app.repairs.service import RepairService
from app.repairs.router import (
control_board_router,
@@ -31732,122 +31739,72 @@ def minerops_repair_allowed_status(value):
@app.get("/api/repairs/queue")
def minerops_repair_queue_api(
query: str | None = Query(default=None, max_length=255),
scope: RepairCenterScope = "active",
status_filter: str | None = Query(default=None, alias="status", max_length=64),
model: str | None = Query(default=None, max_length=255),
lifecycle_status: RepairMinerLifecycle | None = None,
site_id: UUID | None = None,
room_id: UUID | None = None,
limit: int = Query(default=250, ge=1, le=250),
offset: int = Query(default=0, ge=0),
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
ensure_maintenance_tables()
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
WITH latest_repair_event AS (
SELECT DISTINCT ON (miner_id)
id::text AS event_id,
miner_id,
event_status,
event_title,
description,
event_at,
created_at
FROM miner_maintenance_events
WHERE event_type = 'repair'
ORDER BY miner_id, event_at DESC, created_at DESC
),
event_counts AS (
SELECT
miner_id,
COUNT(*) AS repair_event_count,
COUNT(*) FILTER (
WHERE event_status IN (
'open',
'diagnosing',
'waiting_parts',
'in_progress',
'testing',
'needs_followup'
)
) AS open_repair_event_count
FROM miner_maintenance_events
WHERE event_type = 'repair'
GROUP BY miner_id
)
SELECT
m.id::text AS miner_id,
m.current_mac,
host(m.current_ip) AS current_ip,
COALESCE(m.miner_type, m.model) AS model,
m.miner_sn,
m.power_sn,
m.status AS miner_status,
m.created_at AS miner_created_at,
m.updated_at AS miner_updated_at,
m.manual_details,
mls.asset_state,
mls.state_notes,
mls.updated_at AS state_updated_at,
lre.event_id AS latest_event_id,
lre.event_status AS latest_event_status,
lre.event_title AS latest_event_title,
lre.description AS latest_event_description,
lre.event_at AS latest_event_at,
COALESCE(ec.repair_event_count, 0) AS repair_event_count,
COALESCE(ec.open_repair_event_count, 0) AS open_repair_event_count,
m.manual_details -> 'office_repair_import' ->> 'reported_error' AS reported_error,
m.manual_details -> 'office_repair_import' ->> 'reported_hashrate_ths' AS reported_hashrate_ths,
m.manual_details -> 'office_repair_import' ->> 'reported_voltage' AS reported_voltage,
m.manual_details -> 'office_repair_import' ->> 'location_note' AS location_note,
m.manual_details -> 'office_repair_import' ->> 'source' AS import_source
FROM miner_location_states mls
JOIN miners m ON m.id = mls.miner_id
LEFT JOIN latest_repair_event lre ON lre.miner_id = m.id
LEFT JOIN event_counts ec ON ec.miner_id = m.id
WHERE mls.asset_state = 'repair'
ORDER BY
COALESCE(mls.updated_at, m.updated_at) DESC,
m.current_mac;
"""
)
rows = [dict(row) for row in cur.fetchall()]
status_counts = {}
model_counts = {}
error_counts = {}
for row in rows:
status = row.get("latest_event_status") or "unknown"
model = row.get("model") or "unknown"
error = row.get("reported_error") or "No reported error"
status_counts[status] = status_counts.get(status, 0) + 1
model_counts[model] = model_counts.get(model, 0) + 1
error_counts[error] = error_counts.get(error, 0) + 1
return api_response(
data={
"items": rows,
"summary": {
"total": len(rows),
"status_counts": status_counts,
"model_counts": model_counts,
"error_counts": error_counts,
},
}
can_view_miners = user_has_permission(current_user, "miners.view")
can_view_locations = user_has_permission(current_user, "locations.view")
if (site_id or room_id) and not can_view_locations:
raise HTTPException(
status_code=403,
detail="Location viewing permission is required for location filters",
)
if model and not can_view_miners:
raise HTTPException(
status_code=403,
detail="Miner viewing permission is required for model filters",
)
filters = RepairCenterFilters(
query=query,
scope=scope,
status=status_filter,
model=model,
lifecycle_status=lifecycle_status,
site_id=site_id,
room_id=room_id,
limit=limit,
offset=offset,
)
access = RepairCenterAccess(
can_read_repairs=user_has_permission(current_user, "repairs.read"),
can_view_miners=can_view_miners,
can_view_locations=can_view_locations,
can_complete_repairs=user_has_permission(current_user, "repairs.complete"),
can_manage_parts=user_has_permission(current_user, "repairs.parts.manage"),
can_manage_legacy=user_has_permission(current_user, "repairs.manage"),
)
result = RepairCenterService(RepairCenterRepository()).list_repair_center(
filters, access
)
return result.model_dump(mode="json")
finally:
if conn is not None:
conn.close()
def minerops_repair_reject_modular_mutation(cur, miner_id: str) -> None:
cur.execute(
"""
SELECT 1
FROM repairs
WHERE miner_id = %s::uuid
AND status IN ('OPEN', 'IN_PROGRESS', 'WAITING_FOR_PARTS', 'READY_FOR_TEST')
LIMIT 1
""",
(miner_id,),
)
if cur.fetchone():
raise HTTPException(
status_code=409,
detail="This miner has an active modular repair; use the modular repair workflow",
)
@app.post("/api/repairs/{miner_id}/note")
def minerops_repair_add_note_api(
miner_id: str,
@@ -31874,7 +31831,8 @@ def minerops_repair_add_note_api(
SELECT id::text, current_mac
FROM miners
WHERE id = %s::uuid
LIMIT 1;
LIMIT 1
FOR UPDATE;
""",
(miner_id,),
)
@@ -31884,6 +31842,8 @@ def minerops_repair_add_note_api(
if not miner:
raise HTTPException(status_code=404, detail="Miner not found")
minerops_repair_reject_modular_mutation(cur, miner_id)
cur.execute(
"""
INSERT INTO miner_maintenance_events (
@@ -31966,7 +31926,8 @@ def minerops_repair_update_status_api(
SELECT id::text
FROM miners
WHERE id = %s::uuid
LIMIT 1;
LIMIT 1
FOR UPDATE;
""",
(miner_id,),
)
@@ -31976,6 +31937,8 @@ def minerops_repair_update_status_api(
if not miner:
raise HTTPException(status_code=404, detail="Miner not found")
minerops_repair_reject_modular_mutation(cur, miner_id)
cur.execute(
"""
SELECT id::text
@@ -32121,7 +32084,8 @@ def minerops_repair_update_state_api(
SELECT id::text, current_mac
FROM miners
WHERE id = %s::uuid
LIMIT 1;
LIMIT 1
FOR UPDATE;
""",
(miner_id,),
)
@@ -32131,6 +32095,8 @@ def minerops_repair_update_state_api(
if not miner:
raise HTTPException(status_code=404, detail="Miner not found")
minerops_repair_reject_modular_mutation(cur, miner_id)
cur.execute(
"""
UPDATE location_positions
@@ -32937,8 +32903,23 @@ MINEROPS_REPAIR_QUEUE_HTML = r"""
async function loadRepairs() {
try {
const data = await apiGet("/api/repairs/queue");
repairItems = data.items || [];
updateMetrics(data.summary || {});
repairItems = (data.items || []).filter(function (item) {
return !item.source || item.source === "legacy";
});
const legacySummary = {
total: repairItems.length,
status_counts: {},
model_counts: {}
};
for (const item of repairItems) {
const status = item.latest_event_status || item.asset_state || "unknown";
const model = item.model || "unknown";
legacySummary.status_counts[status] =
(legacySummary.status_counts[status] || 0) + 1;
legacySummary.model_counts[model] =
(legacySummary.model_counts[model] || 0) + 1;
}
updateMetrics(legacySummary);
buildModelFilter();
renderTable();
showStatus("ok", "Repair Queue loaded. " + repairItems.length + " miners in repair.");
+316
View File
@@ -0,0 +1,316 @@
"""Set-based PostgreSQL read model for the Repair Center."""
from collections.abc import Callable, Mapping
from typing import Any
import psycopg
from psycopg.rows import dict_row
from app.repairs.repository import default_connection_factory
ConnectionFactory = Callable[[], psycopg.Connection]
class RepairCenterRepository:
"""Read-only persistence boundary for consolidated repair operations."""
def __init__(self, connection_factory: ConnectionFactory | None = None):
self._connection_factory = connection_factory or default_connection_factory
@staticmethod
def _ctes(*, show_miners: bool, show_locations: bool) -> str:
miner_fields = (
"miner.asset_tag AS asset_id, COALESCE(miner.model, miner.miner_type) AS model, miner.miner_sn, miner.power_sn, "
"miner.current_mac, host(miner.current_ip) AS current_ip, "
"miner.status AS runtime_status"
if show_miners else
"NULL::text AS asset_id, NULL::text AS model, NULL::text AS miner_sn, NULL::text AS power_sn, "
"NULL::text AS current_mac, NULL::text AS current_ip, "
"NULL::text AS runtime_status"
)
location_fields = (
"placement.site_id, placement.site_name, placement.room_id, "
"placement.room_name, placement.rack_label, placement.shelf_label, "
"placement.position_id, placement.position_label, placement.position_index"
if show_locations else
"NULL::uuid AS site_id, NULL::text AS site_name, NULL::uuid AS room_id, "
"NULL::text AS room_name, NULL::text AS rack_label, "
"NULL::text AS shelf_label, NULL::uuid AS position_id, "
"NULL::text AS position_label, NULL::integer AS position_index"
)
return f"""
WITH latest_legacy AS (
SELECT DISTINCT ON (event.miner_id)
event.miner_id, event.event_status, event.event_title,
event.description, event.event_at, event.created_at
FROM miner_maintenance_events AS event
WHERE event.event_type = 'repair'
ORDER BY event.miner_id, event.event_at DESC,
event.created_at DESC, event.id DESC
),
legacy_first AS (
SELECT event.miner_id,
min(COALESCE(event.event_at, event.created_at)) AS opened_at
FROM miner_maintenance_events AS event
WHERE event.event_type = 'repair'
GROUP BY event.miner_id
),
legacy_counts AS (
SELECT event.miner_id,
count(*)::integer AS repair_event_count,
count(*) FILTER (WHERE event.event_status IN (
'open', 'diagnosing', 'waiting_parts', 'in_progress',
'testing', 'ready_to_shelf', 'needs_followup'
))::integer AS open_repair_event_count
FROM miner_maintenance_events AS event
WHERE event.event_type = 'repair'
GROUP BY event.miner_id
),
latest_modular AS (
SELECT DISTINCT ON (event.repair_id)
event.repair_id, event.payload, event.created_at
FROM repair_events AS event
ORDER BY event.repair_id, event.sequence DESC, event.id DESC
),
initial_note AS (
SELECT DISTINCT ON (event.repair_id)
event.repair_id, event.payload ->> 'note' AS note
FROM repair_events AS event
WHERE event.event_type = 'INITIAL_NOTE'
ORDER BY event.repair_id, event.sequence, event.id
),
part_rollup AS (
SELECT part.repair_id,
count(*) FILTER (WHERE part.voided_at IS NULL)::integer AS parts_count,
COALESCE((array_agg(
CASE WHEN part.consumable_description IS NOT NULL
THEN part.consumable_description
ELSE concat_ws(' ', part.component_type_snapshot,
part.component_asset_id_snapshot,
part.component_manufacturer_serial_snapshot)
END ORDER BY part.recorded_at, part.id
) FILTER (WHERE part.voided_at IS NULL))[1:3],
ARRAY[]::text[]) AS parts_summary
FROM repair_parts AS part GROUP BY part.repair_id
),
placement AS (
SELECT DISTINCT ON (position.miner_id)
position.miner_id, site.id AS site_id, site.name AS site_name,
room.id AS room_id, COALESCE(room.name, position.room_name) AS room_name,
position.rack_name AS rack_label, position.shelf_name AS shelf_label,
position.id AS position_id, position.position_label,
position.position_index
FROM location_positions AS position
LEFT JOIN rooms AS room ON room.id = position.room_id
LEFT JOIN sites AS site ON site.id = room.site_id
WHERE position.miner_id IS NOT NULL
ORDER BY position.miner_id, position.id
),
modular_rows AS (
SELECT repair.id AS repair_id, 'modular'::text AS source,
repair.status = ANY(ARRAY['OPEN', 'IN_PROGRESS',
'WAITING_FOR_PARTS', 'READY_FOR_TEST']) AS active,
repair.opened_at, repair.completed_at AS closed_at,
repair.status AS repair_status, repair.reason,
COALESCE(NULLIF(trim(miner.manual_details ->
'office_repair_import' ->> 'reported_error'), ''),
NULLIF(trim(initial_note.note), ''),
replace(repair.reason, '_', ' ')) AS reported_problem,
COALESCE(NULLIF(trim(latest_modular.payload ->> 'note'), ''),
NULLIF(trim(latest_modular.payload ->> 'notes'), ''),
NULLIF(trim(latest_modular.payload ->> 'completion_summary'), ''),
NULLIF(trim(initial_note.note), ''),
NULLIF(trim(latest_legacy.description), ''),
NULLIF(trim(location_state.state_notes), '')) AS latest_context,
COALESCE(latest_modular.created_at, latest_legacy.event_at,
latest_legacy.created_at, repair.opened_at) AS latest_context_at,
opened_actor.display_name AS opened_by_display_name,
miner.id AS miner_id, {miner_fields}, miner.lifecycle_status,
{location_fields},
COALESCE(part_rollup.parts_count, 0) AS parts_count,
COALESCE(part_rollup.parts_summary, ARRAY[]::text[]) AS parts_summary,
latest_legacy.event_status AS legacy_event_status,
location_state.asset_state AS legacy_asset_state,
latest_legacy.event_title AS latest_event_title,
COALESCE(legacy_counts.repair_event_count, 0) AS repair_event_count,
COALESCE(legacy_counts.open_repair_event_count, 0) AS open_repair_event_count,
miner.manual_details -> 'office_repair_import' ->> 'reported_hashrate_ths' AS reported_hashrate_ths,
miner.manual_details -> 'office_repair_import' ->> 'reported_voltage' AS reported_voltage,
miner.manual_details -> 'office_repair_import' ->> 'location_note' AS location_note,
miner.manual_details -> 'office_repair_import' ->> 'source' AS import_source,
location_state.updated_at AS state_updated_at
FROM repairs AS repair
JOIN miners AS miner ON miner.id = repair.miner_id
JOIN users AS opened_actor ON opened_actor.id = repair.opened_by
LEFT JOIN latest_modular ON latest_modular.repair_id = repair.id
LEFT JOIN initial_note ON initial_note.repair_id = repair.id
LEFT JOIN part_rollup ON part_rollup.repair_id = repair.id
LEFT JOIN latest_legacy ON latest_legacy.miner_id = miner.id
LEFT JOIN legacy_counts ON legacy_counts.miner_id = miner.id
LEFT JOIN miner_location_states AS location_state
ON location_state.miner_id = miner.id
LEFT JOIN placement ON placement.miner_id = miner.id
),
legacy_rows AS (
SELECT NULL::uuid AS repair_id, 'legacy'::text AS source,
true AS active, legacy_first.opened_at, NULL::timestamptz AS closed_at,
COALESCE(latest_legacy.event_status,
location_state.asset_state, 'unknown') AS repair_status,
NULL::text AS reason,
COALESCE(NULLIF(trim(miner.manual_details ->
'office_repair_import' ->> 'reported_error'), ''),
NULLIF(trim(latest_legacy.event_title), '')) AS reported_problem,
COALESCE(NULLIF(trim(latest_legacy.description), ''),
NULLIF(trim(location_state.state_notes), '')) AS latest_context,
COALESCE(latest_legacy.event_at, latest_legacy.created_at,
location_state.updated_at) AS latest_context_at,
NULL::text AS opened_by_display_name,
miner.id AS miner_id, {miner_fields}, miner.lifecycle_status,
{location_fields}, NULL::integer AS parts_count,
ARRAY[]::text[] AS parts_summary,
latest_legacy.event_status AS legacy_event_status,
location_state.asset_state AS legacy_asset_state,
latest_legacy.event_title AS latest_event_title,
COALESCE(legacy_counts.repair_event_count, 0) AS repair_event_count,
COALESCE(legacy_counts.open_repair_event_count, 0) AS open_repair_event_count,
miner.manual_details -> 'office_repair_import' ->> 'reported_hashrate_ths' AS reported_hashrate_ths,
miner.manual_details -> 'office_repair_import' ->> 'reported_voltage' AS reported_voltage,
miner.manual_details -> 'office_repair_import' ->> 'location_note' AS location_note,
miner.manual_details -> 'office_repair_import' ->> 'source' AS import_source,
location_state.updated_at AS state_updated_at
FROM miner_location_states AS location_state
JOIN miners AS miner ON miner.id = location_state.miner_id
LEFT JOIN latest_legacy ON latest_legacy.miner_id = miner.id
LEFT JOIN legacy_counts ON legacy_counts.miner_id = miner.id
LEFT JOIN legacy_first ON legacy_first.miner_id = miner.id
LEFT JOIN placement ON placement.miner_id = miner.id
WHERE location_state.asset_state = 'repair'
AND NOT EXISTS (
SELECT 1 FROM repairs AS active_repair
WHERE active_repair.miner_id = miner.id
AND active_repair.status = ANY(ARRAY['OPEN', 'IN_PROGRESS',
'WAITING_FOR_PARTS', 'READY_FOR_TEST'])
)
),
candidates AS (
SELECT * FROM modular_rows
UNION ALL
SELECT * FROM legacy_rows
)
"""
@staticmethod
def _where(filters: Mapping[str, Any]) -> tuple[str, list[Any]]:
conditions: list[str] = []
params: list[Any] = []
scope = filters.get("scope", "active")
if scope == "active":
conditions.append("candidate.active")
elif scope == "historical":
conditions.append("NOT candidate.active")
for key, column in (
("status", "repair_status"),
("lifecycle_status", "lifecycle_status"),
("site_id", "site_id"),
("room_id", "room_id"),
):
if filters.get(key):
conditions.append(f"candidate.{column} = %s")
params.append(filters[key])
if filters.get("model"):
conditions.append("lower(trim(candidate.model)) = lower(trim(%s))")
params.append(filters["model"])
if filters.get("query"):
conditions.append(
"concat_ws(' ', candidate.repair_id, candidate.miner_id, "
"candidate.asset_id, candidate.model, candidate.miner_sn, "
"candidate.current_mac, candidate.current_ip, "
"candidate.repair_status, candidate.reason, "
"candidate.reported_problem, candidate.latest_context) ILIKE %s"
)
params.append(f"%{filters['query']}%")
return " AND ".join(conditions) if conditions else "true", params
def list_repair_center(
self,
filters: Mapping[str, Any],
*,
include_miner_details: bool,
include_location_details: bool,
) -> dict[str, Any]:
ctes = self._ctes(
show_miners=include_miner_details,
show_locations=include_location_details,
)
where_sql, params = self._where(filters)
item_sql = f"""
{ctes}
SELECT candidate.* FROM candidates AS candidate
WHERE {where_sql}
ORDER BY candidate.active DESC,
CASE WHEN candidate.active THEN candidate.opened_at END ASC NULLS LAST,
candidate.opened_at DESC NULLS LAST,
candidate.repair_id NULLS LAST, candidate.miner_id
LIMIT %s OFFSET %s
"""
summary_sql = f"""
{ctes}, filtered AS (
SELECT * FROM candidates AS candidate WHERE {where_sql}
)
SELECT count(*)::integer AS total,
count(*) FILTER (WHERE active)::integer AS active,
count(*) FILTER (WHERE NOT active)::integer AS historical,
COALESCE((SELECT jsonb_object_agg(status_rows.repair_status,
status_rows.status_count) FROM (
SELECT repair_status, count(*)::integer AS status_count
FROM filtered GROUP BY repair_status
) AS status_rows), '{{}}'::jsonb) AS status_counts,
COALESCE((SELECT jsonb_object_agg(model_rows.model,
model_rows.model_count) FROM (
SELECT model, count(*)::integer AS model_count
FROM filtered WHERE model IS NOT NULL GROUP BY model
) AS model_rows), '{{}}'::jsonb) AS model_counts,
COALESCE((SELECT jsonb_object_agg(error_rows.error,
error_rows.error_count) FROM (
SELECT COALESCE(reported_problem, 'No reported error') AS error,
count(*)::integer AS error_count
FROM filtered
GROUP BY COALESCE(reported_problem, 'No reported error')
) AS error_rows), '{{}}'::jsonb) AS error_counts
FROM filtered
"""
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
item_sql,
[*params, int(filters.get("limit", 50)),
int(filters.get("offset", 0))],
)
items = [dict(row) for row in cur.fetchall()]
cur.execute(summary_sql, params)
summary = dict(cur.fetchone())
options = {"sites": [], "rooms": []}
if include_location_details:
cur.execute(
f"""
{ctes}
SELECT jsonb_build_object(
'sites', COALESCE((SELECT jsonb_agg(site_rows
ORDER BY lower(site_rows.name), site_rows.id)
FROM (SELECT DISTINCT site_id AS id, site_name AS name
FROM candidates WHERE site_id IS NOT NULL) site_rows),
'[]'::jsonb),
'rooms', COALESCE((SELECT jsonb_agg(room_rows
ORDER BY lower(room_rows.name), room_rows.id)
FROM (SELECT DISTINCT room_id AS id, site_id,
room_name AS name FROM candidates
WHERE room_id IS NOT NULL) room_rows), '[]'::jsonb)
) AS options
"""
)
options = cur.fetchone()["options"]
finally:
connection.close()
return {"items": items, "summary": summary, "location_options": options}
+96
View File
@@ -0,0 +1,96 @@
"""Application service for the consolidated Repair Center read model."""
from app.repairs.center_repository import RepairCenterRepository
from app.repairs.models import (
RepairCenterAccess,
RepairCenterCapabilities,
RepairCenterFilters,
RepairCenterItem,
RepairCenterList,
RepairCenterLocationOptions,
RepairCenterMeta,
RepairCenterResponse,
RepairCenterSummary,
)
class RepairCenterService:
"""Build typed, permission-aware Repair Center projections."""
def __init__(self, repository: RepairCenterRepository):
self.repository = repository
@staticmethod
def _capabilities(
item: RepairCenterItem,
access: RepairCenterAccess,
) -> RepairCenterCapabilities:
modular = item.source == "modular" and item.repair_id is not None
legacy = item.source == "legacy"
placed = bool(item.site_id and item.room_id and item.position_id)
return RepairCenterCapabilities(
can_open_miner=access.can_view_miners,
can_open_map=access.can_view_locations and placed,
can_read_modular_timeline=modular and access.can_read_repairs,
can_read_modular_parts=modular and access.can_read_repairs,
can_manage_modular_parts=(
modular and item.active and access.can_read_repairs
and access.can_manage_parts
),
can_complete_modular_repair=(
modular and item.active and access.can_complete_repairs
),
can_read_legacy_history=legacy and access.can_read_repairs,
can_manage_legacy=legacy and access.can_manage_legacy,
can_mark_legacy_repaired=legacy and access.can_manage_legacy,
can_decommission_legacy=legacy and access.can_manage_legacy,
)
def list_repair_center(
self,
filters: RepairCenterFilters,
access: RepairCenterAccess,
) -> RepairCenterResponse:
query = filters.query.strip() if filters.query else None
model = filters.model.strip() if filters.model else None
status = filters.status.strip() if filters.status else None
repository_filters = filters.model_dump()
repository_filters.update(query=query or None, model=model or None,
status=status or None)
projection = self.repository.list_repair_center(
repository_filters,
include_miner_details=access.can_view_miners,
include_location_details=access.can_view_locations,
)
items: list[RepairCenterItem] = []
for row in projection["items"]:
row = {
**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(
{**row, "capabilities": RepairCenterCapabilities()}
)
item.capabilities = self._capabilities(item, access)
items.append(item)
summary = RepairCenterSummary.model_validate(projection["summary"])
location_options = RepairCenterLocationOptions.model_validate(
projection.get("location_options") or {}
)
return RepairCenterResponse(
data=RepairCenterList(items=items, summary=summary),
meta=RepairCenterMeta(
limit=filters.limit,
offset=filters.offset,
returned=len(items),
total=summary.total,
location_options=location_options,
),
)
+6
View File
@@ -1,8 +1,14 @@
"""Dependency construction for the Repairs domain."""
from app.repairs.repository import RepairRepository
from app.repairs.center_repository import RepairCenterRepository
from app.repairs.center_service import RepairCenterService
from app.repairs.service import RepairService
def get_repair_service() -> RepairService:
return RepairService(RepairRepository())
def get_repair_center_service() -> RepairCenterService:
return RepairCenterService(RepairCenterRepository())
+136
View File
@@ -286,3 +286,139 @@ class RepairPartListResponse(BaseModel):
data: RepairPartList
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
RepairCenterSource = Literal["modular", "legacy"]
RepairCenterScope = Literal["active", "historical", "all"]
class RepairCenterAccess(BaseModel):
can_read_repairs: bool = False
can_view_miners: bool = False
can_view_locations: bool = False
can_complete_repairs: bool = False
can_manage_parts: bool = False
can_manage_legacy: bool = False
class RepairCenterFilters(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
query: str | None = Field(default=None, max_length=255)
scope: RepairCenterScope = "active"
status: str | None = Field(default=None, max_length=64)
model: str | None = Field(default=None, max_length=255)
lifecycle_status: RepairMinerLifecycle | None = None
site_id: UUID | None = None
room_id: UUID | None = None
limit: int = Field(default=50, ge=1, le=250)
offset: int = Field(default=0, ge=0)
class RepairCenterCapabilities(BaseModel):
can_open_miner: bool = False
can_open_map: bool = False
can_read_modular_timeline: bool = False
can_read_modular_parts: bool = False
can_manage_modular_parts: bool = False
can_complete_modular_repair: bool = False
can_read_legacy_history: bool = False
can_manage_legacy: bool = False
can_mark_legacy_repaired: bool = False
can_decommission_legacy: bool = False
class RepairCenterItem(BaseModel):
repair_id: UUID | None = None
source: RepairCenterSource
active: bool
opened_at: AwareDatetime | None = None
closed_at: AwareDatetime | None = None
repair_status: str
reason: str | None = None
reported_problem: str | None = None
latest_context: str | None = None
latest_context_at: AwareDatetime | None = None
opened_by_display_name: str | None = None
miner_id: UUID
asset_id: str | None = None
model: str | None = None
miner_sn: str | None = None
power_sn: str | None = None
current_mac: str | None = None
current_ip: str | None = None
lifecycle_status: RepairMinerLifecycle
runtime_status: str | None = None
miner_status: str | None = None
site_id: UUID | None = None
site_name: str | None = None
room_id: UUID | None = None
room_name: str | None = None
rack_label: str | None = None
shelf_label: str | None = None
position_id: UUID | None = None
position_label: str | None = None
position_index: int | None = None
parts_count: int | None = None
parts_summary: list[str] = Field(default_factory=list)
legacy_event_status: str | None = None
legacy_asset_state: str | None = None
latest_event_status: str | None = None
latest_event_title: str | None = None
latest_event_description: str | None = None
latest_event_at: AwareDatetime | None = None
asset_state: str | None = None
state_notes: str | None = None
repair_event_count: int = 0
open_repair_event_count: int = 0
state_updated_at: AwareDatetime | None = None
reported_error: str | None = None
reported_hashrate_ths: str | None = None
reported_voltage: str | None = None
location_note: str | None = None
import_source: str | None = None
capabilities: RepairCenterCapabilities
class RepairCenterSummary(BaseModel):
total: int
active: int
historical: int
status_counts: dict[str, int] = Field(default_factory=dict)
model_counts: dict[str, int] = Field(default_factory=dict)
error_counts: dict[str, int] = Field(default_factory=dict)
class RepairCenterLocationOption(BaseModel):
id: UUID
name: str
class RepairCenterRoomOption(RepairCenterLocationOption):
site_id: UUID
class RepairCenterLocationOptions(BaseModel):
sites: list[RepairCenterLocationOption] = Field(default_factory=list)
rooms: list[RepairCenterRoomOption] = Field(default_factory=list)
class RepairCenterList(BaseModel):
items: list[RepairCenterItem]
summary: RepairCenterSummary
class RepairCenterMeta(BaseModel):
limit: int
offset: int
returned: int
total: int
location_options: RepairCenterLocationOptions = Field(
default_factory=RepairCenterLocationOptions
)
class RepairCenterResponse(BaseModel):
data: RepairCenterList
meta: RepairCenterMeta
error: None = None
+17 -10
View File
@@ -209,8 +209,11 @@ class FrontendShellTests(unittest.TestCase):
self.assertIn("@media (max-width: 560px)", css)
self.assertIn(":focus-visible", css)
self.assertIn(".skip-link", css)
self.assertIn('response.status === 401', javascript)
self.assertIn('response.status === 403', javascript)
repair_center = (STATIC_ROOT / "repair-center.js").read_text(
encoding="utf-8"
)
self.assertIn('response.status === 401', repair_center)
self.assertIn('response.status === 403', repair_center)
self.assertIn('credentials: "same-origin"', javascript)
def test_static_assets_are_served_without_an_api_route(self):
@@ -401,14 +404,18 @@ class FrontendShellTests(unittest.TestCase):
def test_javascript_uses_safe_dom_and_strict_failure_states(self):
javascript = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")
for unsafe in ("innerHTML", "insertAdjacentHTML", "document.write"):
self.assertNotIn(unsafe, javascript)
self.assertIn("element.textContent = values.text", javascript)
self.assertIn("cell.textContent = text(content)", javascript)
self.assertIn("encodeURIComponent(repair.miner_id)", javascript)
self.assertIn("new AbortController()", javascript)
self.assertIn("invalid repair response", javascript)
self.assertIn("window.location.replace", javascript)
repair_center = (STATIC_ROOT / "repair-center.js").read_text(
encoding="utf-8"
)
for source in (javascript, repair_center):
for unsafe in ("innerHTML", "insertAdjacentHTML", "document.write"):
self.assertNotIn(unsafe, source)
self.assertIn("window.location.replace", source)
self.assertNotIn("/api/repairs/queue", javascript)
self.assertIn("new AbortController()", repair_center)
self.assertIn("cell.textContent = text(content)", repair_center)
self.assertIn("encodeURIComponent(item.miner_id)", repair_center)
self.assertIn("invalid Repair Center response", repair_center)
def test_fleet_page_is_semantic_responsive_and_permission_aware(self):
self.authenticate()
+430
View File
@@ -0,0 +1,430 @@
import inspect
import unittest
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from app.frontend.router import router as frontend_router
from app.identity import get_current_user, required_api_permission
from app.repairs.center_repository import RepairCenterRepository
from app.repairs.center_service import RepairCenterService
from app.repairs.models import RepairCenterAccess, RepairCenterFilters
NOW = datetime(2026, 8, 11, 12, 0, tzinfo=timezone.utc)
STATIC_ROOT = Path(__file__).parents[1] / "app" / "frontend" / "static"
class FakeCenterRepository:
def __init__(self):
self.arguments = None
self.rows = []
def list_repair_center(self, filters, **permissions):
self.arguments = (filters, permissions)
return {
"items": self.rows,
"summary": {"total": len(self.rows), "active": len(self.rows),
"historical": 0, "status_counts": {"OPEN": len(self.rows)}},
"location_options": {"sites": [], "rooms": []},
}
def center_row(source="modular", active=True):
repair_id = uuid4() if source == "modular" else None
return {
"repair_id": repair_id,
"source": source,
"active": active,
"opened_at": NOW,
"closed_at": None,
"repair_status": "OPEN" if source == "modular" else "testing",
"reason": "HARDWARE_FAILURE" if source == "modular" else None,
"reported_problem": "Hashboard not detected",
"latest_context": "Testing replacement board",
"latest_context_at": NOW,
"opened_by_display_name": "Repair Tech" if source == "modular" else None,
"miner_id": uuid4(),
"asset_id": "MINER-100",
"model": "M60S",
"miner_sn": "SN-100",
"power_sn": "PSU-100",
"current_mac": "00:11:22:33:44:55",
"current_ip": "192.0.2.10",
"lifecycle_status": "in_repair",
"runtime_status": "offline",
"site_id": uuid4(),
"site_name": "Site A",
"room_id": uuid4(),
"room_name": "Room 1",
"rack_label": "12",
"shelf_label": "3",
"position_id": uuid4(),
"position_label": "P04",
"position_index": 4,
"parts_count": 1 if source == "modular" else None,
"parts_summary": ["Fan"] if source == "modular" else [],
"legacy_event_status": "testing" if source == "legacy" else None,
"legacy_asset_state": "repair" if source == "legacy" else None,
}
class RepairCenterServiceTests(unittest.TestCase):
def setUp(self):
self.repository = FakeCenterRepository()
self.service = RepairCenterService(self.repository)
def test_modular_capabilities_use_repair_uuid_and_permissions(self):
self.repository.rows = [center_row()]
result = self.service.list_repair_center(
RepairCenterFilters(),
RepairCenterAccess(can_read_repairs=True, can_view_miners=True,
can_view_locations=True, can_complete_repairs=True,
can_manage_parts=True, can_manage_legacy=True),
)
item = result.data.items[0]
self.assertTrue(item.capabilities.can_read_modular_timeline)
self.assertTrue(item.capabilities.can_manage_modular_parts)
self.assertTrue(item.capabilities.can_complete_modular_repair)
self.assertFalse(item.capabilities.can_manage_legacy)
self.assertTrue(item.capabilities.can_open_map)
def test_part_management_requires_modular_read_access(self):
self.repository.rows = [center_row()]
result = self.service.list_repair_center(
RepairCenterFilters(),
RepairCenterAccess(can_manage_parts=True),
)
self.assertFalse(
result.data.items[0].capabilities.can_manage_modular_parts
)
def test_legacy_capabilities_never_enable_modular_mutations(self):
self.repository.rows = [center_row("legacy")]
result = self.service.list_repair_center(
RepairCenterFilters(),
RepairCenterAccess(can_read_repairs=True, can_view_miners=True,
can_view_locations=True, can_complete_repairs=True,
can_manage_parts=True, can_manage_legacy=True),
)
item = result.data.items[0]
self.assertTrue(item.capabilities.can_manage_legacy)
self.assertTrue(item.capabilities.can_mark_legacy_repaired)
self.assertFalse(item.capabilities.can_complete_modular_repair)
self.assertFalse(item.capabilities.can_manage_modular_parts)
def test_repository_receives_explicit_redaction_flags(self):
self.service.list_repair_center(
RepairCenterFilters(query=" fan "), RepairCenterAccess()
)
filters, permissions = self.repository.arguments
self.assertEqual(filters["query"], "fan")
self.assertFalse(permissions["include_miner_details"])
self.assertFalse(permissions["include_location_details"])
def test_pagination_and_exact_summary_are_preserved(self):
self.repository.rows = [center_row()]
result = self.service.list_repair_center(
RepairCenterFilters(limit=25, offset=50), RepairCenterAccess()
)
self.assertEqual(result.meta.limit, 25)
self.assertEqual(result.meta.offset, 50)
self.assertEqual(result.meta.returned, 1)
self.assertEqual(result.meta.total, 1)
self.assertEqual(result.data.summary.status_counts, {"OPEN": 1})
class RepairCenterDatabaseTests(unittest.TestCase):
def test_empty_database_projection_executes_as_typed_set_query(self):
result = RepairCenterRepository().list_repair_center(
{"scope": "active", "limit": 25, "offset": 0},
include_miner_details=True,
include_location_details=True,
)
self.assertEqual(result["items"], [])
self.assertEqual(result["summary"]["total"], 0)
self.assertEqual(result["location_options"], {"sites": [], "rooms": []})
def test_projection_consolidates_sources_filters_and_pages_exactly(self):
repository = RepairCenterRepository()
actor_id = uuid4()
site_id = uuid4()
room_id = uuid4()
modular_miner_id = uuid4()
legacy_miner_id = uuid4()
completed_miner_id = uuid4()
connection = repository._connection_factory()
try:
with connection:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO users (
id, username, display_name, password_hash, role_id
)
SELECT %s, %s, 'Repair Center Actor', 'not-used', id
FROM roles WHERE name = 'Admin'
""",
(actor_id, f"repair-center-{actor_id}"),
)
cur.execute(
"INSERT INTO sites (id, name) VALUES (%s, %s)",
(site_id, f"Repair Center Site {site_id}"),
)
cur.execute(
"""
INSERT INTO rooms (id, site_id, name)
VALUES (%s, %s, 'Repair Room')
""",
(room_id, site_id),
)
for miner_id, asset_id, lifecycle in (
(modular_miner_id, "RC-MODULAR", "in_repair"),
(legacy_miner_id, "RC-LEGACY", "in_repair"),
(completed_miner_id, "RC-COMPLETED", "active"),
):
cur.execute(
"""
INSERT INTO miners (
id, asset_tag, model, status, lifecycle_status,
first_seen_at, last_seen_at
)
VALUES (%s, %s, 'M60S', 'offline', %s, NULL, NULL)
""",
(miner_id, asset_id, lifecycle),
)
for index, miner_id in enumerate(
(modular_miner_id, legacy_miner_id), start=1
):
cur.execute(
"""
INSERT INTO location_positions (
room_id, room_name, rack_name, shelf_name,
position_index, position_label, miner_id
)
VALUES (%s, 'Repair Room', 'Rack 1', 'Shelf A',
%s, %s, %s)
""",
(room_id, index, f"P{index:02d}", miner_id),
)
cur.execute(
"""
INSERT INTO miner_location_states (
miner_id, asset_state, state_notes,
updated_by_user_id
)
VALUES (%s, 'repair', 'Legacy repair context', %s)
""",
(miner_id, actor_id),
)
cur.execute(
"""
INSERT INTO miner_maintenance_events (
miner_id, event_type, event_status, event_title,
description, created_by_user_id
)
VALUES (%s, 'repair', 'waiting_parts',
'Legacy board issue', 'Waiting for board', %s)
""",
(miner_id, actor_id),
)
cur.execute(
"""
INSERT INTO repairs (miner_id, reason, opened_by)
VALUES (%s, 'CONTROL_BOARD', %s)
""",
(modular_miner_id, actor_id),
)
cur.execute(
"""
INSERT INTO repairs (
miner_id, status, reason, opened_by, completed_at,
completed_by, resulting_lifecycle
)
VALUES (%s, 'COMPLETED', 'INSPECTION', %s, now(),
%s, 'active')
""",
(completed_miner_id, actor_id, actor_id),
)
finally:
connection.close()
from app.main import minerops_repair_reject_modular_mutation
boundary_connection = repository._connection_factory()
try:
with boundary_connection.cursor() as cur:
with self.assertRaises(HTTPException) as raised:
minerops_repair_reject_modular_mutation(
cur, str(modular_miner_id)
)
self.assertEqual(raised.exception.status_code, 409)
minerops_repair_reject_modular_mutation(
cur, str(legacy_miner_id)
)
finally:
boundary_connection.close()
active = repository.list_repair_center(
{"scope": "active", "limit": 1, "offset": 0},
include_miner_details=True,
include_location_details=True,
)
self.assertEqual(active["summary"]["total"], 2)
self.assertEqual(len(active["items"]), 1)
self.assertEqual(active["summary"]["active"], 2)
self.assertEqual(active["location_options"]["sites"][0]["id"], str(site_id))
all_active = repository.list_repair_center(
{"scope": "active", "limit": 25, "offset": 0},
include_miner_details=True,
include_location_details=True,
)
by_miner = {row["miner_id"]: row for row in all_active["items"]}
self.assertEqual(set(by_miner), {modular_miner_id, legacy_miner_id})
self.assertEqual(by_miner[modular_miner_id]["source"], "modular")
self.assertEqual(by_miner[legacy_miner_id]["source"], "legacy")
self.assertEqual(by_miner[legacy_miner_id]["repair_event_count"], 1)
self.assertEqual(
by_miner[legacy_miner_id]["latest_event_title"],
"Legacy board issue",
)
self.assertEqual(all_active["summary"]["model_counts"], {"M60S": 2})
self.assertEqual(
all_active["summary"]["error_counts"],
{"CONTROL BOARD": 1, "Legacy board issue": 1},
)
filtered = repository.list_repair_center(
{"scope": "all", "query": "RC-COMPLETED", "model": "m60s",
"limit": 25, "offset": 0},
include_miner_details=True,
include_location_details=True,
)
self.assertEqual(filtered["summary"]["total"], 1)
self.assertFalse(filtered["items"][0]["active"])
redacted = repository.list_repair_center(
{"scope": "active", "limit": 25, "offset": 0},
include_miner_details=False,
include_location_details=False,
)
self.assertEqual(redacted["summary"]["total"], 2)
self.assertTrue(all(row["asset_id"] is None for row in redacted["items"]))
self.assertTrue(all(row["site_id"] is None for row in redacted["items"]))
self.assertEqual(redacted["location_options"], {"sites": [], "rooms": []})
class RepairCenterRepositoryAuditTests(unittest.TestCase):
def test_projection_prevents_duplicate_active_legacy_rows(self):
source = inspect.getsource(RepairCenterRepository._ctes)
self.assertIn("NOT EXISTS", source)
self.assertIn("active_repair.miner_id = miner.id", source)
self.assertIn("UNION ALL", source)
self.assertIn("part_rollup", source)
self.assertIn("placement", source)
def test_projection_uses_constant_set_based_queries(self):
source = inspect.getsource(RepairCenterRepository.list_repair_center)
self.assertEqual(source.count("cur.execute("), 3)
self.assertNotIn("for item in", source)
self.assertIn("count(*) FILTER", source)
self.assertIn("LIMIT %s OFFSET %s", source)
def test_filter_sql_is_parameterized_and_complete_result_scoped(self):
where, params = RepairCenterRepository._where({
"scope": "historical", "status": "COMPLETED", "query": "fan",
"site_id": uuid4(), "room_id": uuid4(),
})
self.assertIn("NOT candidate.active", where)
self.assertIn("candidate.repair_status = %s", where)
self.assertIn("candidate.site_id = %s", where)
self.assertTrue(params[-1].startswith("%fan"))
class RepairCenterFrontendTests(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": "tech", "display_name": "Repair Tech",
"role": "Technician", "permissions": ["repairs.view", "repairs.read",
"repairs.manage", "repairs.complete", "repairs.parts.manage",
"miners.view", "locations.view"],
}
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def test_page_has_semantic_queue_filters_paging_and_inspector(self):
response = self.client.get("/operations/repairs")
self.assertEqual(response.status_code, 200)
for value in ('id="repairFilters"', 'id="repairRows"',
'id="repairInspector"', 'id="repairPrevious"', 'id="repairNext"',
'<th>Reported problem</th>', '/assets/minerops-repair-center.js'):
self.assertIn(value, response.text)
def test_location_filters_are_not_rendered_without_permission(self):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()), "role": "Custom", "permissions": ["repairs.view"]
}
text = self.client.get("/operations/repairs").text
self.assertNotIn('id="repairSite"', text)
self.assertNotIn('id="repairModel"', text)
self.assertNotIn('id="repairRoom"', text)
def test_restricted_filter_parameters_are_rejected(self):
from app.main import minerops_repair_queue_api
arguments = {
"query": None, "scope": "active", "status_filter": None,
"model": None, "lifecycle_status": None, "site_id": None,
"room_id": None, "limit": 50, "offset": 0,
"current_user": {"permissions": ["repairs.view"]},
}
for field, value in (("model", "M60S"), ("site_id", uuid4())):
with self.subTest(field=field):
with self.assertRaises(HTTPException) as raised:
minerops_repair_queue_api(**{**arguments, field: value})
self.assertEqual(raised.exception.status_code, 403)
def test_frontend_is_one_initial_read_without_polling_or_unsafe_html(self):
source = (STATIC_ROOT / "repair-center.js").read_text(encoding="utf-8")
self.assertEqual(source.count('api("/api/repairs/queue?"'), 1)
self.assertNotIn("setInterval", source)
for unsafe in ("innerHTML", "insertAdjacentHTML", "document.write"):
self.assertNotIn(unsafe, source)
self.assertIn("Promise.all", source)
self.assertIn("encodeURIComponent", source)
self.assertIn("URLSearchParams", source)
def test_mutations_are_source_specific_and_gets_are_read_only(self):
source = (STATIC_ROOT / "repair-center.js").read_text(encoding="utf-8")
self.assertIn('/complete"', source)
self.assertIn('/parts"', source)
self.assertIn('"/note" : "/status"', source)
self.assertIn('/state"', source)
self.assertNotIn('method: "GET"', source)
self.assertIn("Type DECOMMISSION", source)
def test_miner_and_map_links_use_durable_safe_identifiers(self):
source = (STATIC_ROOT / "repair-center.js").read_text(encoding="utf-8")
self.assertIn('encodeURIComponent(item.miner_id) + "/detail"', source)
self.assertIn("site_id: item.site_id", source)
self.assertIn("room_id: item.room_id", source)
self.assertIn("miner_id: item.miner_id", source)
def test_history_get_requires_repairs_read(self):
self.assertEqual(required_api_permission(
"GET", f"/api/miners/{uuid4()}/maintenance-events"), "repairs.read")
self.assertEqual(required_api_permission(
"PATCH", f"/api/miners/{uuid4()}/maintenance-events/{uuid4()}"),
"repairs.manage")
if __name__ == "__main__":
unittest.main()