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 = """ +
Real records from the existing repair queue - API. Open a miner to continue in its current detail view.
+Active work and historical modular repairs, with legacy queue records clearly identified.
| Miner | Model | Repair state | -Reported issue | Last update |
|---|
| Miner | Repair state | Reported problem | +Latest context | Location | Opened / age | +Parts | Actions |
|---|