(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); const returnTo = window.location.pathname + window.location.search; const path = "/miners/" + encodeURIComponent(item.miner_id) + "/detail"; link.href = path + "?return_to=" + encodeURIComponent(returnTo); wrap.appendChild(link); } else wrap.appendChild(create("strong", "", label)); const details = [item.model, item.miner_sn, item.current_mac, item.current_ip] .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(); })();