diff --git a/backend/app/frontend/router.py b/backend/app/frontend/router.py index 03dcd6f..74599ab 100644 --- a/backend/app/frontend/router.py +++ b/backend/app/frontend/router.py @@ -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) diff --git a/backend/app/frontend/static/app.css b/backend/app/frontend/static/app.css index a7ddbaa..d486f5e 100644 --- a/backend/app/frontend/static/app.css +++ b/backend/app/frontend/static/app.css @@ -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); } +} diff --git a/backend/app/frontend/static/app.js b/backend/app/frontend/static/app.js index 3ce7594..e4acbfc 100644 --- a/backend/app/frontend/static/app.js +++ b/backend/app/frontend/static/app.js @@ -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(); })(); diff --git a/backend/app/frontend/static/repair-center.js b/backend/app/frontend/static/repair-center.js new file mode 100644 index 0000000..b713956 --- /dev/null +++ b/backend/app/frontend/static/repair-center.js @@ -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(); +})(); diff --git a/backend/app/frontend/views.py b/backend/app/frontend/views.py index 0913af3..6ac084a 100644 --- a/backend/app/frontend/views.py +++ b/backend/app/frontend/views.py @@ -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 = """ +
+
+
+
+ """ 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 = """ +
+
+ """ if can_view_miners else "" return f""" -
+
-
-

Current repair queue

-

Real records from the existing repair queue - API. Open a miner to continue in its current detail view.

+

Repair Center

+

Active work and historical modular repairs, with legacy queue records clearly identified.

+ Loading repair work +
+
+
Total results-
+
Active-
+
Historical-
+
+
+ {_state_messages("repair records")} -
+ """ @@ -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 '' if view == "miners" + else '' + if view == "repairs" else "" ) return f""" diff --git a/backend/app/identity.py b/backend/app/identity.py index c888b19..dab91b8 100644 --- a/backend/app/identity.py +++ b/backend/app/identity.py @@ -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): diff --git a/backend/app/main.py b/backend/app/main.py index 5be2b39..63e144d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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."); diff --git a/backend/app/repairs/center_repository.py b/backend/app/repairs/center_repository.py new file mode 100644 index 0000000..fb090bc --- /dev/null +++ b/backend/app/repairs/center_repository.py @@ -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} diff --git a/backend/app/repairs/center_service.py b/backend/app/repairs/center_service.py new file mode 100644 index 0000000..00ad594 --- /dev/null +++ b/backend/app/repairs/center_service.py @@ -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, + ), + ) diff --git a/backend/app/repairs/dependencies.py b/backend/app/repairs/dependencies.py index 8fafe37..586a989 100644 --- a/backend/app/repairs/dependencies.py +++ b/backend/app/repairs/dependencies.py @@ -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()) diff --git a/backend/app/repairs/models.py b/backend/app/repairs/models.py index 55e8f8c..c370c9f 100644 --- a/backend/app/repairs/models.py +++ b/backend/app/repairs/models.py @@ -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 diff --git a/backend/tests/test_frontend_shell.py b/backend/tests/test_frontend_shell.py index 2cb81fe..8e40376 100644 --- a/backend/tests/test_frontend_shell.py +++ b/backend/tests/test_frontend_shell.py @@ -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() diff --git a/backend/tests/test_repair_center.py b/backend/tests/test_repair_center.py new file mode 100644 index 0000000..812f90a --- /dev/null +++ b/backend/tests/test_repair_center.py @@ -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"', + 'Reported problem', '/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() diff --git a/scripts/test-repair-center.sh b/scripts/test-repair-center.sh new file mode 100755 index 0000000..4fda8fa --- /dev/null +++ b/scripts/test-repair-center.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" +project_name="${MINEROPS_REPAIR_CENTER_TEST_PROJECT:-minerops-repair-center-$$}" +compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml) +cleanup() { + status=$? + "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + exit "${status}" +} +trap cleanup EXIT + +[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; } +"${compose[@]}" up --detach postgres +for attempt in $(seq 1 60); do + if "${compose[@]}" exec -T postgres sh -ec \ + 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1; then + break + fi + (( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; } + sleep 1 +done +"${compose[@]}" build backend +"${compose[@]}" run --rm --no-deps backend alembic upgrade head +"${compose[@]}" run --rm --no-deps \ + -v "${repo_root}/backend/tests:/app/tests:ro" \ + backend python -m unittest -v \ + tests.test_repair_center \ + tests.test_repair_timeline \ + tests.test_return_to_service \ + tests.test_repair_parts_tracking