Files
minerops/backend/app/frontend/static/repair-center.js
T

611 lines
29 KiB
JavaScript

(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();
})();