feat: add Repair Center operations view
This commit is contained in:
1 parent
786a1ab910
commit
4a1959ecd6
14 files changed
+1963
-338
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -1,93 +1,6 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const view = document.body.dataset.view || "";
|
||||
const canViewMiners =
|
||||
document.body.dataset.canViewMiners === "true";
|
||||
|
||||
function text(value, fallback) {
|
||||
if (value === null || value === undefined || String(value).trim() === "") {
|
||||
return fallback || "Not available";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function statusName(value) {
|
||||
return text(value, "unknown").toLowerCase().replaceAll(" ", "_");
|
||||
}
|
||||
|
||||
function create(tag, options) {
|
||||
const element = document.createElement(tag);
|
||||
const values = options || {};
|
||||
if (values.className) element.className = values.className;
|
||||
if (values.text !== undefined) element.textContent = values.text;
|
||||
if (values.href) element.href = values.href;
|
||||
return element;
|
||||
}
|
||||
|
||||
function showState(name, message) {
|
||||
for (const state of document.querySelectorAll("[data-view-state]")) {
|
||||
state.hidden = state.dataset.viewState !== name;
|
||||
}
|
||||
const active = document.querySelector(
|
||||
'[data-view-state="' + name + '"]'
|
||||
);
|
||||
const detail = active && active.querySelector("[data-state-detail]");
|
||||
if (detail && message) detail.textContent = message;
|
||||
}
|
||||
|
||||
async function api(path) {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(function () {
|
||||
controller.abort();
|
||||
}, 15000);
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal
|
||||
});
|
||||
} catch (error) {
|
||||
if (error && error.name === "AbortError") {
|
||||
throw new Error(
|
||||
"MinerOps took too long to respond. Retry in a moment."
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
"MinerOps could not reach the backend. Check the connection and retry."
|
||||
);
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
if (response.status === 401) {
|
||||
window.location.replace(
|
||||
"/login?next=" +
|
||||
encodeURIComponent(window.location.pathname + window.location.search)
|
||||
);
|
||||
throw new Error("Your session expired. Sign in again to continue.");
|
||||
}
|
||||
if (response.status === 403) {
|
||||
const denied = new Error(
|
||||
"Your account does not have permission to view this information."
|
||||
);
|
||||
denied.permissionDenied = true;
|
||||
throw denied;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
"MinerOps could not load this information. Retry in a moment."
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"MinerOps returned an invalid response. Retry in a moment."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function setupShell() {
|
||||
const menu = document.getElementById("menuButton");
|
||||
const nav = document.getElementById("primaryNav");
|
||||
@@ -128,118 +41,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function addCell(row, label, content) {
|
||||
const cell = create("td");
|
||||
cell.dataset.label = label;
|
||||
if (content instanceof Node) cell.appendChild(content);
|
||||
else cell.textContent = text(content);
|
||||
row.appendChild(cell);
|
||||
}
|
||||
|
||||
function status(value) {
|
||||
const label = create("span", {
|
||||
className: "status-text",
|
||||
text: text(value, "Unknown").replaceAll("_", " ")
|
||||
});
|
||||
label.dataset.status = statusName(value);
|
||||
return label;
|
||||
}
|
||||
|
||||
function dateTime(value) {
|
||||
if (!value) return "Not available";
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.valueOf())
|
||||
? String(value)
|
||||
: parsed.toLocaleString();
|
||||
}
|
||||
|
||||
async function loadRepairs() {
|
||||
const rows = document.getElementById("repairRows");
|
||||
if (!rows) return;
|
||||
showState("loading");
|
||||
try {
|
||||
const envelope = await api("/api/repairs/queue");
|
||||
if (
|
||||
!envelope ||
|
||||
!envelope.data ||
|
||||
typeof envelope.data !== "object" ||
|
||||
!Array.isArray(envelope.data.items)
|
||||
) {
|
||||
throw new Error(
|
||||
"MinerOps returned an invalid repair response."
|
||||
);
|
||||
}
|
||||
const items = envelope.data.items;
|
||||
if (!items.every(function (item) {
|
||||
return item &&
|
||||
typeof item === "object" &&
|
||||
typeof item.miner_id === "string" &&
|
||||
item.miner_id.trim() !== "";
|
||||
})) {
|
||||
throw new Error(
|
||||
"MinerOps returned an incomplete repair response."
|
||||
);
|
||||
}
|
||||
rows.replaceChildren();
|
||||
if (!items.length) {
|
||||
showState("empty");
|
||||
return;
|
||||
}
|
||||
for (const repair of items) {
|
||||
const row = create("tr");
|
||||
const identity = create("div");
|
||||
const minerLabel = text(
|
||||
repair.current_mac,
|
||||
text(repair.miner_id, "Unknown miner")
|
||||
);
|
||||
if (canViewMiners) {
|
||||
identity.appendChild(create("a", {
|
||||
className: "object-link",
|
||||
text: minerLabel,
|
||||
href: "/miners/" +
|
||||
encodeURIComponent(repair.miner_id) +
|
||||
"/detail"
|
||||
}));
|
||||
} else {
|
||||
identity.appendChild(create("strong", {
|
||||
text: minerLabel
|
||||
}));
|
||||
}
|
||||
identity.appendChild(create("span", {
|
||||
className: "secondary",
|
||||
text: text(repair.current_ip, "No current IP")
|
||||
}));
|
||||
addCell(row, "Miner", identity);
|
||||
addCell(row, "Model", repair.model);
|
||||
addCell(
|
||||
row,
|
||||
"Repair state",
|
||||
status(repair.latest_event_status || repair.asset_state)
|
||||
);
|
||||
addCell(
|
||||
row,
|
||||
"Reported issue",
|
||||
repair.reported_error ||
|
||||
repair.latest_event_title ||
|
||||
"No reported issue"
|
||||
);
|
||||
addCell(
|
||||
row,
|
||||
"Last update",
|
||||
dateTime(repair.latest_event_at || repair.state_updated_at)
|
||||
);
|
||||
rows.appendChild(row);
|
||||
}
|
||||
showState("ready");
|
||||
} catch (error) {
|
||||
showState(
|
||||
error.permissionDenied ? "denied" : "error",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setupShell();
|
||||
if (view === "repairs") loadRepairs();
|
||||
})();
|
||||
@@ -0,0 +1,610 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const optionalFieldIds = ["repairModel", "repairSite", "repairRoom"];
|
||||
const ids = [
|
||||
"repairFilters", "repairQuery", "repairScope", "repairStatus",
|
||||
"repairLifecycle", "repairReset", "repairRows",
|
||||
"repairTotal", "repairActive", "repairHistorical", "repairFreshness",
|
||||
"repairResultSummary", "repairPrevious", "repairNext", "repairLimit",
|
||||
"repairInspector", "repairInspectorTitle", "repairInspectorBody",
|
||||
"repairInspectorClose"
|
||||
];
|
||||
const el = {};
|
||||
for (const id of ids) el[id] = document.getElementById(id);
|
||||
for (const id of optionalFieldIds) el[id] = document.getElementById(id);
|
||||
if (!el.repairFilters || !el.repairRows) return;
|
||||
|
||||
const STATUS_LABELS = {
|
||||
OPEN: "Open", IN_PROGRESS: "In progress",
|
||||
WAITING_FOR_PARTS: "Waiting for parts", READY_FOR_TEST: "Ready for test",
|
||||
COMPLETED: "Completed", CANCELLED: "Cancelled",
|
||||
open: "Open", diagnosing: "Diagnosing", waiting_parts: "Waiting parts",
|
||||
in_progress: "In progress", testing: "Testing",
|
||||
ready_to_shelf: "Ready to shelf", needs_followup: "Needs follow-up",
|
||||
completed: "Completed", failed: "Failed", cancelled: "Cancelled",
|
||||
repair: "In repair", unknown: "Unknown"
|
||||
};
|
||||
const state = { offset: 0, limit: 50, items: new Map(), rooms: [], opener: null };
|
||||
let controller = null;
|
||||
|
||||
function text(value, fallback) {
|
||||
if (value === null || value === undefined || String(value).trim() === "") {
|
||||
return fallback || "Not available";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function create(tag, className, value) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (value !== undefined) node.textContent = value;
|
||||
return node;
|
||||
}
|
||||
|
||||
function button(label, action, className) {
|
||||
const node = create("button", className || "quiet-button", label);
|
||||
node.type = "button";
|
||||
node.dataset.action = action;
|
||||
return node;
|
||||
}
|
||||
|
||||
function showState(name, message) {
|
||||
for (const node of document.querySelectorAll("[data-view-state]")) {
|
||||
node.hidden = node.dataset.viewState !== name;
|
||||
}
|
||||
const active = document.querySelector('[data-view-state="' + name + '"]');
|
||||
const detail = active && active.querySelector("[data-state-detail]");
|
||||
if (detail && message) detail.textContent = message;
|
||||
}
|
||||
|
||||
function friendlyStatus(value) {
|
||||
return STATUS_LABELS[value] || text(value, "Unknown").replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function dateTime(value) {
|
||||
if (!value) return "Not available";
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.valueOf()) ? String(value) : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function age(value) {
|
||||
if (!value) return "Age unavailable";
|
||||
const milliseconds = Date.now() - new Date(value).valueOf();
|
||||
if (!Number.isFinite(milliseconds) || milliseconds < 0) return "Age unavailable";
|
||||
const hours = Math.floor(milliseconds / 3600000);
|
||||
if (hours < 24) return hours + "h open";
|
||||
return Math.floor(hours / 24) + "d open";
|
||||
}
|
||||
|
||||
function labelPart(prefix, value) {
|
||||
if (!value) return null;
|
||||
const clean = String(value).trim();
|
||||
return clean.toLowerCase().startsWith(prefix.toLowerCase() + " ")
|
||||
? clean : prefix + " " + clean;
|
||||
}
|
||||
|
||||
function locationText(item) {
|
||||
const parts = [item.site_name, item.room_name,
|
||||
labelPart("Rack", item.rack_label), labelPart("Shelf", item.shelf_label),
|
||||
item.position_label || (item.position_index !== null ? "Position " + item.position_index : null)];
|
||||
return parts.filter(Boolean).join(" / ") || "Unplaced";
|
||||
}
|
||||
|
||||
async function api(path, options) {
|
||||
const response = await fetch(path, Object.assign({
|
||||
credentials: "same-origin", headers: { Accept: "application/json" }
|
||||
}, options || {}));
|
||||
if (response.status === 401) {
|
||||
window.location.replace("/login?next=" + encodeURIComponent(
|
||||
window.location.pathname + window.location.search));
|
||||
throw new Error("Your session expired.");
|
||||
}
|
||||
const payload = await response.json().catch(function () { return null; });
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload && payload.detail
|
||||
? String(payload.detail) : "The repair request failed.");
|
||||
error.permissionDenied = response.status === 403;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function jsonOptions(method, body) {
|
||||
return { method: method, headers: { Accept: "application/json",
|
||||
"Content-Type": "application/json" }, body: JSON.stringify(body) };
|
||||
}
|
||||
|
||||
function readUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
el.repairQuery.value = params.get("query") || "";
|
||||
el.repairScope.value = params.get("scope") || "active";
|
||||
el.repairStatus.value = params.get("status") || "";
|
||||
if (el.repairModel) el.repairModel.value = params.get("model") || "";
|
||||
el.repairLifecycle.value = params.get("lifecycle_status") || "";
|
||||
if (el.repairSite) el.repairSite.value = params.get("site_id") || "";
|
||||
state.pendingRoom = params.get("room_id") || "";
|
||||
state.offset = Math.max(0, Number(params.get("offset")) || 0);
|
||||
state.limit = [25, 50, 100, 250].includes(Number(params.get("limit")))
|
||||
? Number(params.get("limit")) : 50;
|
||||
el.repairLimit.value = String(state.limit);
|
||||
}
|
||||
|
||||
function currentParams(includePaging) {
|
||||
const params = new URLSearchParams();
|
||||
const values = {
|
||||
query: el.repairQuery.value.trim(), scope: el.repairScope.value,
|
||||
status: el.repairStatus.value,
|
||||
model: el.repairModel ? el.repairModel.value.trim() : "",
|
||||
lifecycle_status: el.repairLifecycle.value,
|
||||
site_id: el.repairSite ? el.repairSite.value : "",
|
||||
room_id: el.repairRoom ? el.repairRoom.value : ""
|
||||
};
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value && !(key === "scope" && value === "active")) params.set(key, value);
|
||||
}
|
||||
if (includePaging) {
|
||||
params.set("limit", String(state.limit));
|
||||
if (state.offset) params.set("offset", String(state.offset));
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function syncUrl(mode) {
|
||||
const query = currentParams(true).toString();
|
||||
const target = window.location.pathname + (query ? "?" + query : "");
|
||||
window.history[mode === "replace" ? "replaceState" : "pushState"]({}, "", target);
|
||||
}
|
||||
|
||||
function syncLocationOptions(options) {
|
||||
if (!el.repairSite || !el.repairRoom) return;
|
||||
const selectedSite = el.repairSite.value;
|
||||
const selectedRoom = state.pendingRoom || el.repairRoom.value;
|
||||
el.repairSite.replaceChildren(new Option("All sites", ""));
|
||||
for (const site of options.sites || []) {
|
||||
el.repairSite.appendChild(new Option(site.name, site.id));
|
||||
}
|
||||
if ([...el.repairSite.options].some(function (option) {
|
||||
return option.value === selectedSite;
|
||||
})) el.repairSite.value = selectedSite;
|
||||
state.rooms = options.rooms || [];
|
||||
el.repairRoom.replaceChildren(new Option("All rooms", ""));
|
||||
for (const room of state.rooms) {
|
||||
if (!el.repairSite.value || String(room.site_id) === el.repairSite.value) {
|
||||
el.repairRoom.appendChild(new Option(room.name, room.id));
|
||||
}
|
||||
}
|
||||
if ([...el.repairRoom.options].some(function (option) {
|
||||
return option.value === selectedRoom;
|
||||
})) el.repairRoom.value = selectedRoom;
|
||||
state.pendingRoom = "";
|
||||
}
|
||||
|
||||
function addCell(row, label, content) {
|
||||
const cell = create("td");
|
||||
cell.dataset.label = label;
|
||||
if (content instanceof Node) cell.appendChild(content);
|
||||
else cell.textContent = text(content);
|
||||
row.appendChild(cell);
|
||||
}
|
||||
|
||||
function identityCell(item) {
|
||||
const wrap = create("div", "repair-miner");
|
||||
const label = item.asset_id || item.current_mac || "Miner " + item.miner_id.slice(0, 8);
|
||||
if (item.capabilities.can_open_miner) {
|
||||
const link = create("a", "object-link", label);
|
||||
link.href = "/miners/" + encodeURIComponent(item.miner_id) + "/detail";
|
||||
wrap.appendChild(link);
|
||||
} else wrap.appendChild(create("strong", "", label));
|
||||
const details = [item.model, item.miner_sn, item.current_mac, item.current_ip]
|
||||
.filter(Boolean).join(" / ");
|
||||
if (details) wrap.appendChild(create("span", "secondary", details));
|
||||
wrap.appendChild(create("span", "source-label", item.source === "modular"
|
||||
? "Repair domain" : "Legacy compatibility"));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function actionCell(item) {
|
||||
const wrap = create("div", "repair-row-actions");
|
||||
if (item.capabilities.can_open_map) {
|
||||
const params = new URLSearchParams({ site_id: item.site_id,
|
||||
room_id: item.room_id, miner_id: item.miner_id });
|
||||
const link = create("a", "quiet-link", "Mine Map");
|
||||
link.href = "/monitoring/map?" + params.toString();
|
||||
wrap.appendChild(link);
|
||||
}
|
||||
if (Object.values(item.capabilities).some(Boolean)) {
|
||||
const details = button("Details", "details");
|
||||
details.dataset.key = item.source + ":" + (item.repair_id || item.miner_id);
|
||||
wrap.appendChild(details);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderRows(items) {
|
||||
el.repairRows.replaceChildren();
|
||||
state.items.clear();
|
||||
for (const item of items) {
|
||||
const key = item.source + ":" + (item.repair_id || item.miner_id);
|
||||
state.items.set(key, item);
|
||||
const row = create("tr", "repair-row");
|
||||
row.dataset.source = item.source;
|
||||
row.dataset.active = String(item.active);
|
||||
addCell(row, "Miner", identityCell(item));
|
||||
const status = create("span", "repair-status", friendlyStatus(item.repair_status));
|
||||
status.dataset.status = String(item.repair_status).toLowerCase();
|
||||
const stateWrap = create("div");
|
||||
stateWrap.append(status, create("span", "secondary", text(item.lifecycle_status)));
|
||||
addCell(row, "Repair state", stateWrap);
|
||||
addCell(row, "Reported problem", item.reported_problem || item.reason);
|
||||
addCell(row, "Latest context", item.latest_context);
|
||||
addCell(row, "Location", locationText(item));
|
||||
const opened = create("div");
|
||||
opened.append(create("span", "", dateTime(item.opened_at)),
|
||||
create("span", "secondary", item.active ? age(item.opened_at) : "Historical"));
|
||||
addCell(row, "Opened / age", opened);
|
||||
addCell(row, "Parts", item.parts_count === null
|
||||
? "Legacy record" : item.parts_count + " recorded");
|
||||
addCell(row, "Actions", actionCell(item));
|
||||
el.repairRows.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function section(title) {
|
||||
const node = create("section", "repair-detail-section");
|
||||
node.appendChild(create("h3", "", title));
|
||||
return node;
|
||||
}
|
||||
|
||||
function renderTimeline(events, legacy) {
|
||||
const node = section(legacy ? "Legacy repair history" : "Repair timeline");
|
||||
if (!events.length) {
|
||||
node.appendChild(create("p", "secondary", "No history entries recorded."));
|
||||
return node;
|
||||
}
|
||||
const list = create("ol", "repair-timeline");
|
||||
for (const event of events) {
|
||||
const entry = create("li");
|
||||
const title = legacy
|
||||
? text(event.event_title, event.event_type)
|
||||
: friendlyStatus(event.event_type);
|
||||
entry.append(create("strong", "", title),
|
||||
create("span", "secondary", dateTime(event.occurred_at || event.event_at || event.created_at)));
|
||||
const payload = legacy ? event.description : Object.entries(event.payload || {})
|
||||
.map(function (pair) { return pair[0].replaceAll("_", " ") + ": " + pair[1]; })
|
||||
.join(" / ");
|
||||
if (payload) entry.appendChild(create("p", "", String(payload)));
|
||||
list.appendChild(entry);
|
||||
}
|
||||
node.appendChild(list);
|
||||
return node;
|
||||
}
|
||||
|
||||
function partLabel(part) {
|
||||
if (part.consumable) {
|
||||
return part.consumable.quantity + " " + part.consumable.unit + " " +
|
||||
part.consumable.description;
|
||||
}
|
||||
const snapshot = part.component_snapshot || {};
|
||||
return [snapshot.component_type, snapshot.asset_id,
|
||||
snapshot.manufacturer_serial].filter(Boolean).join(" / ") || "Tracked component";
|
||||
}
|
||||
|
||||
function renderParts(item, parts) {
|
||||
const node = section("Parts used");
|
||||
if (!parts.length) node.appendChild(create("p", "secondary", "No parts recorded."));
|
||||
const list = create("ul", "repair-parts-list");
|
||||
for (const part of parts) {
|
||||
const entry = create("li");
|
||||
entry.appendChild(create("span", part.voided_at ? "voided-part" : "", partLabel(part)));
|
||||
if (part.notes) entry.appendChild(create("span", "secondary", part.notes));
|
||||
if (part.voided_at) {
|
||||
entry.appendChild(create("span", "secondary", "Voided: " + text(part.void_reason)));
|
||||
} else if (item.capabilities.can_manage_modular_parts) {
|
||||
const voidButton = button("Void entry", "void-part", "danger-button");
|
||||
voidButton.dataset.partId = part.repair_part_id;
|
||||
entry.appendChild(voidButton);
|
||||
}
|
||||
list.appendChild(entry);
|
||||
}
|
||||
node.appendChild(list);
|
||||
if (item.capabilities.can_manage_modular_parts) {
|
||||
const form = create("form", "repair-action-form");
|
||||
form.dataset.action = "record-part";
|
||||
const kind = create("select");
|
||||
kind.name = "kind";
|
||||
kind.append(new Option("Consumable", "consumable"),
|
||||
new Option("Tracked component", "tracked_component"));
|
||||
const description = create("input");
|
||||
description.name = "description"; description.placeholder = "Consumable description";
|
||||
const quantity = create("input");
|
||||
quantity.name = "quantity"; quantity.type = "number";
|
||||
quantity.min = "0.001"; quantity.step = "0.001"; quantity.value = "1";
|
||||
const unit = create("select"); unit.name = "unit";
|
||||
for (const value of ["each", "gram", "millilitre", "metre", "centimetre"]) {
|
||||
unit.appendChild(new Option(value, value));
|
||||
}
|
||||
const component = create("input");
|
||||
component.name = "component"; component.placeholder = "Tracked component UUID";
|
||||
const notes = create("input"); notes.name = "notes"; notes.placeholder = "Optional notes";
|
||||
form.append(kind, description, quantity, unit, component, notes,
|
||||
create("button", "command-button", "Record part"));
|
||||
form.lastChild.type = "submit";
|
||||
node.appendChild(form);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function modularActions(item) {
|
||||
const node = section("Next action");
|
||||
if (item.capabilities.can_complete_modular_repair) {
|
||||
const form = create("form", "repair-action-form");
|
||||
form.dataset.action = "complete-repair";
|
||||
const target = create("select"); target.name = "target";
|
||||
target.append(new Option("Return active", "active"), new Option("Return as spare", "spare"));
|
||||
const summary = create("textarea"); summary.name = "summary";
|
||||
summary.placeholder = "Optional completion summary";
|
||||
const submit = create("button", "command-button", "Complete repair");
|
||||
submit.type = "submit";
|
||||
form.append(target, summary, submit);
|
||||
node.appendChild(form);
|
||||
} else node.appendChild(create("p", "secondary", "No repair action is available."));
|
||||
return node;
|
||||
}
|
||||
|
||||
function legacyActions(item) {
|
||||
const node = section("Legacy actions");
|
||||
if (!item.capabilities.can_manage_legacy) {
|
||||
node.appendChild(create("p", "secondary", "Legacy repair actions are read-only."));
|
||||
return node;
|
||||
}
|
||||
const form = create("form", "repair-action-form");
|
||||
form.dataset.action = "legacy-update";
|
||||
const status = create("select"); status.name = "status";
|
||||
for (const value of ["open", "diagnosing", "waiting_parts", "in_progress",
|
||||
"testing", "ready_to_shelf", "needs_followup", "completed", "failed", "cancelled"]) {
|
||||
const option = new Option(friendlyStatus(value), value);
|
||||
option.selected = value === item.repair_status;
|
||||
status.appendChild(option);
|
||||
}
|
||||
const note = create("textarea"); note.name = "note"; note.placeholder = "Repair note";
|
||||
const noteButton = create("button", "quiet-button", "Add note");
|
||||
noteButton.type = "submit"; noteButton.value = "note"; noteButton.name = "intent";
|
||||
const statusButton = create("button", "quiet-button", "Save status");
|
||||
statusButton.type = "submit"; statusButton.value = "status"; statusButton.name = "intent";
|
||||
form.append(status, note, noteButton, statusButton);
|
||||
node.append(form, button("Mark repaired", "legacy-repaired", "command-button"),
|
||||
button("Decommission", "legacy-decommission", "danger-button"));
|
||||
return node;
|
||||
}
|
||||
|
||||
function closeInspector() {
|
||||
el.repairInspector.hidden = true;
|
||||
el.repairInspectorBody.replaceChildren();
|
||||
if (state.opener) state.opener.focus();
|
||||
}
|
||||
|
||||
async function openInspector(item, opener) {
|
||||
state.currentItem = item;
|
||||
state.opener = opener;
|
||||
el.repairInspector.hidden = false;
|
||||
el.repairInspectorTitle.textContent = item.asset_id || item.current_mac || "Repair details";
|
||||
el.repairInspectorBody.replaceChildren();
|
||||
const overview = section("Repair overview");
|
||||
overview.append(
|
||||
create("p", "", friendlyStatus(item.repair_status) + " / " + text(item.lifecycle_status)),
|
||||
create("p", "", text(item.reported_problem, "No reported problem")),
|
||||
create("p", "secondary", locationText(item)),
|
||||
create("p", "secondary", "Opened " + dateTime(item.opened_at))
|
||||
);
|
||||
el.repairInspectorBody.appendChild(overview);
|
||||
el.repairInspectorClose.focus();
|
||||
|
||||
try {
|
||||
if (item.source === "modular") {
|
||||
const canRead = item.capabilities.can_read_modular_timeline;
|
||||
const requests = canRead ? [
|
||||
api("/api/repairs/" + encodeURIComponent(item.repair_id) + "/events"),
|
||||
api("/api/repairs/" + encodeURIComponent(item.repair_id) + "/parts")
|
||||
] : [];
|
||||
const results = await Promise.all(requests);
|
||||
if (canRead) {
|
||||
el.repairInspectorBody.append(
|
||||
renderTimeline(results[0].data.events || [], false),
|
||||
renderParts(item, results[1].data.entries || [])
|
||||
);
|
||||
}
|
||||
el.repairInspectorBody.appendChild(modularActions(item));
|
||||
} else {
|
||||
if (item.capabilities.can_read_legacy_history) {
|
||||
const history = await api("/api/miners/" +
|
||||
encodeURIComponent(item.miner_id) + "/maintenance-events");
|
||||
const events = (history.data || []).filter(function (event) {
|
||||
return String(event.event_type || "").toLowerCase() === "repair";
|
||||
});
|
||||
el.repairInspectorBody.appendChild(renderTimeline(events, true));
|
||||
}
|
||||
el.repairInspectorBody.appendChild(legacyActions(item));
|
||||
}
|
||||
} catch (error) {
|
||||
el.repairInspectorBody.appendChild(create("p", "state-inline-error", error.message));
|
||||
}
|
||||
}
|
||||
|
||||
function selectedItem(target) {
|
||||
const keyNode = target.closest("[data-key]");
|
||||
return keyNode ? state.items.get(keyNode.dataset.key) : null;
|
||||
}
|
||||
|
||||
async function reloadAfterMutation(message) {
|
||||
closeInspector();
|
||||
await load();
|
||||
el.repairResultSummary.textContent = message + " " + el.repairResultSummary.textContent;
|
||||
}
|
||||
|
||||
async function handleInspectorSubmit(event) {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const item = state.currentItem;
|
||||
if (!item) return;
|
||||
const data = new FormData(form);
|
||||
try {
|
||||
if (form.dataset.action === "complete-repair") {
|
||||
if (!window.confirm("Complete this repair and change the miner lifecycle?")) return;
|
||||
await api("/api/repairs/" + encodeURIComponent(item.repair_id) + "/complete",
|
||||
jsonOptions("POST", { target_lifecycle: data.get("target"),
|
||||
completion_summary: String(data.get("summary") || "").trim() || null }));
|
||||
await reloadAfterMutation("Repair completed.");
|
||||
} else if (form.dataset.action === "record-part") {
|
||||
const kind = data.get("kind");
|
||||
const body = { notes: String(data.get("notes") || "").trim() || null };
|
||||
if (kind === "consumable") {
|
||||
body.consumable = { description: String(data.get("description") || "").trim(),
|
||||
quantity: String(data.get("quantity") || ""), unit: data.get("unit") };
|
||||
} else body.tracked_component_uuid = String(data.get("component") || "").trim();
|
||||
await api("/api/repairs/" + encodeURIComponent(item.repair_id) + "/parts",
|
||||
jsonOptions("POST", body));
|
||||
await openInspector(item, state.opener);
|
||||
} else if (form.dataset.action === "legacy-update") {
|
||||
const intent = event.submitter && event.submitter.value;
|
||||
const note = String(data.get("note") || "").trim();
|
||||
const path = "/api/repairs/" + encodeURIComponent(item.miner_id) +
|
||||
(intent === "note" ? "/note" : "/status");
|
||||
const body = intent === "note"
|
||||
? { note: note, event_status: data.get("status") }
|
||||
: { event_status: data.get("status"), note: note || null };
|
||||
await api(path, jsonOptions("POST", body));
|
||||
await reloadAfterMutation(intent === "note" ? "Repair note added." : "Repair status updated.");
|
||||
}
|
||||
} catch (error) {
|
||||
form.prepend(create("p", "state-inline-error", error.message));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInspectorClick(event) {
|
||||
const action = event.target.closest("[data-action]");
|
||||
const item = state.currentItem;
|
||||
if (!action || !item) return;
|
||||
try {
|
||||
if (action.dataset.action === "void-part") {
|
||||
const reason = window.prompt("Reason for voiding this repair-part entry:", "");
|
||||
if (!reason || !reason.trim() || !window.confirm("Void this repair-part entry? Its history will remain recorded.")) return;
|
||||
await api("/api/repairs/" + encodeURIComponent(item.repair_id) + "/parts/" +
|
||||
encodeURIComponent(action.dataset.partId) + "/void",
|
||||
jsonOptions("POST", { reason: reason.trim() }));
|
||||
await openInspector(item, state.opener);
|
||||
} else if (action.dataset.action === "legacy-repaired") {
|
||||
if (!window.confirm("Mark this miner repaired and move it to Waiting Placement?")) return;
|
||||
await api("/api/repairs/" + encodeURIComponent(item.miner_id) + "/state",
|
||||
jsonOptions("POST", { asset_state: "waiting_placement",
|
||||
note: "Repair marked complete. Miner is waiting for placement." }));
|
||||
await reloadAfterMutation("Miner moved to Waiting Placement.");
|
||||
} else if (action.dataset.action === "legacy-decommission") {
|
||||
const typed = window.prompt("Type DECOMMISSION to confirm:", "");
|
||||
if (typed !== "DECOMMISSION") return;
|
||||
await api("/api/repairs/" + encodeURIComponent(item.miner_id) + "/state",
|
||||
jsonOptions("POST", { asset_state: "decommissioned",
|
||||
note: "Miner decommissioned from Repair Center." }));
|
||||
await reloadAfterMutation("Miner decommissioned.");
|
||||
}
|
||||
} catch (error) {
|
||||
el.repairInspectorBody.prepend(create("p", "state-inline-error", error.message));
|
||||
}
|
||||
}
|
||||
|
||||
function populateStatusOptions(counts) {
|
||||
const selected = el.repairStatus.value;
|
||||
const values = Object.keys(counts || {}).sort(function (left, right) {
|
||||
return friendlyStatus(left).localeCompare(friendlyStatus(right));
|
||||
});
|
||||
el.repairStatus.replaceChildren(new Option("All statuses", ""));
|
||||
for (const value of values) {
|
||||
el.repairStatus.appendChild(new Option(
|
||||
friendlyStatus(value) + " (" + counts[value] + ")", value));
|
||||
}
|
||||
if ([...el.repairStatus.options].some(function (option) {
|
||||
return option.value === selected;
|
||||
})) el.repairStatus.value = selected;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (controller) controller.abort();
|
||||
controller = new AbortController();
|
||||
showState("loading");
|
||||
el.repairFreshness.textContent = "Loading repair work";
|
||||
try {
|
||||
const params = currentParams(true);
|
||||
const envelope = await api("/api/repairs/queue?" + params.toString(),
|
||||
{ signal: controller.signal });
|
||||
if (!envelope || !envelope.data || !Array.isArray(envelope.data.items) ||
|
||||
!envelope.data.summary || !envelope.meta) {
|
||||
throw new Error("MinerOps returned an invalid Repair Center response.");
|
||||
}
|
||||
const items = envelope.data.items;
|
||||
if (!items.every(function (item) {
|
||||
return item && item.miner_id && item.source && item.capabilities;
|
||||
})) throw new Error("MinerOps returned incomplete repair records.");
|
||||
const summary = envelope.data.summary;
|
||||
el.repairTotal.textContent = String(summary.total);
|
||||
el.repairActive.textContent = String(summary.active);
|
||||
el.repairHistorical.textContent = String(summary.historical);
|
||||
populateStatusOptions(summary.status_counts);
|
||||
syncLocationOptions(envelope.meta.location_options || {});
|
||||
renderRows(items);
|
||||
const start = summary.total ? state.offset + 1 : 0;
|
||||
const end = state.offset + items.length;
|
||||
el.repairResultSummary.textContent = "Showing " + start + "-" + end +
|
||||
" of " + summary.total + " matching repairs.";
|
||||
el.repairPrevious.disabled = state.offset === 0;
|
||||
el.repairNext.disabled = end >= summary.total;
|
||||
el.repairFreshness.textContent = "Loaded " + new Date().toLocaleTimeString();
|
||||
showState(items.length ? "ready" : "empty");
|
||||
} catch (error) {
|
||||
if (error.name === "AbortError") return;
|
||||
showState(error.permissionDenied ? "denied" : "error", error.message);
|
||||
el.repairFreshness.textContent = "Repair data unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
el.repairRows.addEventListener("click", function (event) {
|
||||
const action = event.target.closest('[data-action="details"]');
|
||||
const item = selectedItem(event.target);
|
||||
if (!action || !item) return;
|
||||
state.currentItem = item;
|
||||
openInspector(item, action);
|
||||
});
|
||||
el.repairInspectorClose.addEventListener("click", closeInspector);
|
||||
el.repairInspector.addEventListener("submit", handleInspectorSubmit);
|
||||
el.repairInspector.addEventListener("click", handleInspectorClick);
|
||||
document.addEventListener("keydown", function (event) {
|
||||
if (event.key === "Escape" && !el.repairInspector.hidden) closeInspector();
|
||||
});
|
||||
el.repairFilters.addEventListener("submit", function (event) {
|
||||
event.preventDefault(); state.offset = 0; syncUrl("push"); load();
|
||||
});
|
||||
el.repairReset.addEventListener("click", function () {
|
||||
el.repairFilters.reset(); state.offset = 0; state.limit = 50;
|
||||
state.pendingRoom = ""; el.repairLimit.value = "50";
|
||||
syncUrl("push"); load();
|
||||
});
|
||||
if (el.repairSite) el.repairSite.addEventListener("change", function () {
|
||||
state.pendingRoom = "";
|
||||
syncLocationOptions({ sites: [...el.repairSite.options].slice(1).map(function (option) {
|
||||
return { id: option.value, name: option.textContent };
|
||||
}), rooms: state.rooms });
|
||||
});
|
||||
el.repairPrevious.addEventListener("click", function () {
|
||||
state.offset = Math.max(0, state.offset - state.limit); syncUrl("push"); load();
|
||||
});
|
||||
el.repairNext.addEventListener("click", function () {
|
||||
state.offset += state.limit; syncUrl("push"); load();
|
||||
});
|
||||
el.repairLimit.addEventListener("change", function () {
|
||||
state.limit = Number(el.repairLimit.value); state.offset = 0; syncUrl("push"); load();
|
||||
});
|
||||
window.addEventListener("popstate", function () { readUrl(); load(); });
|
||||
|
||||
readUrl();
|
||||
syncUrl("replace");
|
||||
load();
|
||||
})();
|
||||
@@ -199,26 +199,79 @@ def _state_messages(noun: str) -> str:
|
||||
"""
|
||||
|
||||
|
||||
def _repairs_content() -> str:
|
||||
def _repairs_content(can_view_locations: bool, can_view_miners: bool) -> str:
|
||||
location_filters = """
|
||||
<div class="field"><label for="repairSite">Site</label>
|
||||
<select id="repairSite"><option value="">All sites</option></select></div>
|
||||
<div class="field"><label for="repairRoom">Room</label>
|
||||
<select id="repairRoom"><option value="">All rooms</option></select></div>
|
||||
""" if can_view_locations else ""
|
||||
search_placeholder = (
|
||||
"Miner, model, status, reason, or context"
|
||||
if can_view_miners else "Repair status, reason, or context"
|
||||
)
|
||||
model_filter = """
|
||||
<div class="field"><label for="repairModel">Model</label>
|
||||
<input id="repairModel" type="text" autocomplete="off" placeholder="Any model"></div>
|
||||
""" if can_view_miners else ""
|
||||
return f"""
|
||||
<section class="panel" aria-labelledby="repairQueueTitle">
|
||||
<section class="panel repair-center" aria-labelledby="repairQueueTitle">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2 id="repairQueueTitle">Current repair queue</h2>
|
||||
<p class="panel-copy">Real records from the existing repair queue
|
||||
API. Open a miner to continue in its current detail view.</p>
|
||||
<div><h2 id="repairQueueTitle">Repair Center</h2>
|
||||
<p class="panel-copy">Active work and historical modular repairs, with legacy queue records clearly identified.</p></div>
|
||||
<span class="freshness" id="repairFreshness">Loading repair work</span>
|
||||
</div>
|
||||
<div class="repair-summary" aria-label="Repair summary">
|
||||
<div><span>Total results</span><strong id="repairTotal">-</strong></div>
|
||||
<div><span>Active</span><strong id="repairActive">-</strong></div>
|
||||
<div><span>Historical</span><strong id="repairHistorical">-</strong></div>
|
||||
</div>
|
||||
<form class="repair-filters" id="repairFilters" role="search">
|
||||
<div class="field repair-search"><label for="repairQuery">Search repairs</label>
|
||||
<input id="repairQuery" type="search" autocomplete="off"
|
||||
placeholder="{search_placeholder}"></div>
|
||||
<div class="field"><label for="repairScope">Scope</label>
|
||||
<select id="repairScope"><option value="active">Active</option>
|
||||
<option value="historical">Historical</option><option value="all">All</option></select></div>
|
||||
<div class="field"><label for="repairStatus">Repair status</label>
|
||||
<select id="repairStatus"><option value="">All statuses</option></select></div>
|
||||
{model_filter}
|
||||
<div class="field"><label for="repairLifecycle">Miner lifecycle</label>
|
||||
<select id="repairLifecycle"><option value="">All lifecycle states</option>
|
||||
<option value="active">Active</option><option value="spare">Spare</option>
|
||||
<option value="in_repair">In repair</option><option value="retired">Retired</option></select></div>
|
||||
{location_filters}
|
||||
<div class="repair-filter-actions">
|
||||
<button class="command-button" type="submit">Apply</button>
|
||||
<button class="quiet-button" id="repairReset" type="button">Reset</button>
|
||||
</div>
|
||||
<span class="freshness">Loaded on request</span>
|
||||
</div>
|
||||
</form>
|
||||
{_state_messages("repair records")}
|
||||
<div class="table-wrap" data-view-state="ready" hidden>
|
||||
<table>
|
||||
<thead><tr><th>Miner</th><th>Model</th><th>Repair state</th>
|
||||
<th>Reported issue</th><th>Last update</th></tr></thead>
|
||||
<tbody id="repairRows"></tbody>
|
||||
</table>
|
||||
<div data-view-state="ready" hidden>
|
||||
<p class="repair-result-summary" id="repairResultSummary" aria-live="polite"></p>
|
||||
<div class="table-wrap repair-table-wrap">
|
||||
<table class="repair-table">
|
||||
<thead><tr><th>Miner</th><th>Repair state</th><th>Reported problem</th>
|
||||
<th>Latest context</th><th>Location</th><th>Opened / age</th>
|
||||
<th>Parts</th><th>Actions</th></tr></thead>
|
||||
<tbody id="repairRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="repair-pagination" aria-label="Repair result pages">
|
||||
<button class="quiet-button" id="repairPrevious" type="button" disabled>Previous</button>
|
||||
<label for="repairLimit">Rows per page</label>
|
||||
<select id="repairLimit"><option value="25">25</option><option value="50">50</option>
|
||||
<option value="100">100</option><option value="250">250</option></select>
|
||||
<button class="quiet-button" id="repairNext" type="button" disabled>Next</button>
|
||||
</nav>
|
||||
</section>
|
||||
<aside class="repair-inspector" id="repairInspector" aria-labelledby="repairInspectorTitle" hidden>
|
||||
<div class="inspector-head"><div><span class="eyebrow">Repair context</span>
|
||||
<h2 id="repairInspectorTitle">Repair details</h2></div>
|
||||
<button class="icon-button" id="repairInspectorClose" type="button" aria-label="Close repair details">X</button></div>
|
||||
<div id="repairInspectorBody"></div>
|
||||
</aside>
|
||||
"""
|
||||
|
||||
|
||||
@@ -338,6 +391,10 @@ def render_app_page(
|
||||
if view == "map"
|
||||
else _miners_content(has_permission("locations.view"))
|
||||
if view == "miners"
|
||||
else _repairs_content(
|
||||
has_permission("locations.view"), has_permission("miners.view")
|
||||
)
|
||||
if view == "repairs"
|
||||
else CONTENT[view]()
|
||||
)
|
||||
page_script = (
|
||||
@@ -345,6 +402,8 @@ def render_app_page(
|
||||
if view == "map"
|
||||
else '<script src="/assets/minerops-fleet.js" defer></script>'
|
||||
if view == "miners"
|
||||
else '<script src="/assets/minerops-repair-center.js" defer></script>'
|
||||
if view == "repairs"
|
||||
else ""
|
||||
)
|
||||
return f"""<!doctype html>
|
||||
|
||||
Reference in new issue
Block a user