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>
|
||||
|
||||
@@ -753,6 +753,14 @@ def component_request(path: str) -> bool:
|
||||
|
||||
|
||||
def required_api_permission(method: str, path: str) -> str | None:
|
||||
parts = path.split("/")
|
||||
if (
|
||||
method == "GET"
|
||||
and len(parts) == 5
|
||||
and parts[:3] == ["", "api", "miners"]
|
||||
and parts[4] == "maintenance-events"
|
||||
):
|
||||
return "repairs.read"
|
||||
if component_request(path):
|
||||
return "components.create" if method == "POST" else "components.read"
|
||||
if repair_parts_request(path):
|
||||
|
||||
+97
-116
@@ -7,7 +7,7 @@ import psycopg
|
||||
import redis
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
from fastapi import Request, Depends, FastAPI, Header, HTTPException, status
|
||||
from fastapi import Request, Depends, FastAPI, Header, HTTPException, Query, status
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError
|
||||
|
||||
from app.config import settings
|
||||
@@ -17,6 +17,7 @@ from app.identity import (
|
||||
get_current_user,
|
||||
install_identity_middleware,
|
||||
require_permission,
|
||||
user_has_permission,
|
||||
router as identity_router,
|
||||
)
|
||||
from app.frontend.router import router as frontend_router
|
||||
@@ -30,11 +31,17 @@ from app.repairs.exceptions import (
|
||||
RepairMinerNotFound,
|
||||
)
|
||||
from app.repairs.models import (
|
||||
RepairCenterAccess,
|
||||
RepairCenterFilters,
|
||||
RepairCenterScope,
|
||||
RepairMinerLifecycle,
|
||||
ControlBoardReplacementReason,
|
||||
ControlBoardReplacementRequest,
|
||||
RemovedBoardDisposition,
|
||||
)
|
||||
from app.repairs.repository import RepairRepository
|
||||
from app.repairs.center_repository import RepairCenterRepository
|
||||
from app.repairs.center_service import RepairCenterService
|
||||
from app.repairs.service import RepairService
|
||||
from app.repairs.router import (
|
||||
control_board_router,
|
||||
@@ -31732,122 +31739,72 @@ def minerops_repair_allowed_status(value):
|
||||
|
||||
@app.get("/api/repairs/queue")
|
||||
def minerops_repair_queue_api(
|
||||
query: str | None = Query(default=None, max_length=255),
|
||||
scope: RepairCenterScope = "active",
|
||||
status_filter: str | None = Query(default=None, alias="status", max_length=64),
|
||||
model: str | None = Query(default=None, max_length=255),
|
||||
lifecycle_status: RepairMinerLifecycle | None = None,
|
||||
site_id: UUID | None = None,
|
||||
room_id: UUID | None = None,
|
||||
limit: int = Query(default=250, ge=1, le=250),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
ensure_location_tables()
|
||||
ensure_maintenance_tables()
|
||||
|
||||
conn = None
|
||||
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
|
||||
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
WITH latest_repair_event AS (
|
||||
SELECT DISTINCT ON (miner_id)
|
||||
id::text AS event_id,
|
||||
miner_id,
|
||||
event_status,
|
||||
event_title,
|
||||
description,
|
||||
event_at,
|
||||
created_at
|
||||
FROM miner_maintenance_events
|
||||
WHERE event_type = 'repair'
|
||||
ORDER BY miner_id, event_at DESC, created_at DESC
|
||||
),
|
||||
event_counts AS (
|
||||
SELECT
|
||||
miner_id,
|
||||
COUNT(*) AS repair_event_count,
|
||||
COUNT(*) FILTER (
|
||||
WHERE event_status IN (
|
||||
'open',
|
||||
'diagnosing',
|
||||
'waiting_parts',
|
||||
'in_progress',
|
||||
'testing',
|
||||
'needs_followup'
|
||||
)
|
||||
) AS open_repair_event_count
|
||||
FROM miner_maintenance_events
|
||||
WHERE event_type = 'repair'
|
||||
GROUP BY miner_id
|
||||
)
|
||||
SELECT
|
||||
m.id::text AS miner_id,
|
||||
m.current_mac,
|
||||
host(m.current_ip) AS current_ip,
|
||||
COALESCE(m.miner_type, m.model) AS model,
|
||||
m.miner_sn,
|
||||
m.power_sn,
|
||||
m.status AS miner_status,
|
||||
m.created_at AS miner_created_at,
|
||||
m.updated_at AS miner_updated_at,
|
||||
m.manual_details,
|
||||
mls.asset_state,
|
||||
mls.state_notes,
|
||||
mls.updated_at AS state_updated_at,
|
||||
|
||||
lre.event_id AS latest_event_id,
|
||||
lre.event_status AS latest_event_status,
|
||||
lre.event_title AS latest_event_title,
|
||||
lre.description AS latest_event_description,
|
||||
lre.event_at AS latest_event_at,
|
||||
|
||||
COALESCE(ec.repair_event_count, 0) AS repair_event_count,
|
||||
COALESCE(ec.open_repair_event_count, 0) AS open_repair_event_count,
|
||||
|
||||
m.manual_details -> 'office_repair_import' ->> 'reported_error' AS reported_error,
|
||||
m.manual_details -> 'office_repair_import' ->> 'reported_hashrate_ths' AS reported_hashrate_ths,
|
||||
m.manual_details -> 'office_repair_import' ->> 'reported_voltage' AS reported_voltage,
|
||||
m.manual_details -> 'office_repair_import' ->> 'location_note' AS location_note,
|
||||
m.manual_details -> 'office_repair_import' ->> 'source' AS import_source
|
||||
FROM miner_location_states mls
|
||||
JOIN miners m ON m.id = mls.miner_id
|
||||
LEFT JOIN latest_repair_event lre ON lre.miner_id = m.id
|
||||
LEFT JOIN event_counts ec ON ec.miner_id = m.id
|
||||
WHERE mls.asset_state = 'repair'
|
||||
ORDER BY
|
||||
COALESCE(mls.updated_at, m.updated_at) DESC,
|
||||
m.current_mac;
|
||||
"""
|
||||
)
|
||||
|
||||
rows = [dict(row) for row in cur.fetchall()]
|
||||
|
||||
status_counts = {}
|
||||
model_counts = {}
|
||||
error_counts = {}
|
||||
|
||||
for row in rows:
|
||||
status = row.get("latest_event_status") or "unknown"
|
||||
model = row.get("model") or "unknown"
|
||||
error = row.get("reported_error") or "No reported error"
|
||||
|
||||
status_counts[status] = status_counts.get(status, 0) + 1
|
||||
model_counts[model] = model_counts.get(model, 0) + 1
|
||||
error_counts[error] = error_counts.get(error, 0) + 1
|
||||
|
||||
return api_response(
|
||||
data={
|
||||
"items": rows,
|
||||
"summary": {
|
||||
"total": len(rows),
|
||||
"status_counts": status_counts,
|
||||
"model_counts": model_counts,
|
||||
"error_counts": error_counts,
|
||||
},
|
||||
}
|
||||
can_view_miners = user_has_permission(current_user, "miners.view")
|
||||
can_view_locations = user_has_permission(current_user, "locations.view")
|
||||
if (site_id or room_id) and not can_view_locations:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Location viewing permission is required for location filters",
|
||||
)
|
||||
if model and not can_view_miners:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Miner viewing permission is required for model filters",
|
||||
)
|
||||
filters = RepairCenterFilters(
|
||||
query=query,
|
||||
scope=scope,
|
||||
status=status_filter,
|
||||
model=model,
|
||||
lifecycle_status=lifecycle_status,
|
||||
site_id=site_id,
|
||||
room_id=room_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
access = RepairCenterAccess(
|
||||
can_read_repairs=user_has_permission(current_user, "repairs.read"),
|
||||
can_view_miners=can_view_miners,
|
||||
can_view_locations=can_view_locations,
|
||||
can_complete_repairs=user_has_permission(current_user, "repairs.complete"),
|
||||
can_manage_parts=user_has_permission(current_user, "repairs.parts.manage"),
|
||||
can_manage_legacy=user_has_permission(current_user, "repairs.manage"),
|
||||
)
|
||||
result = RepairCenterService(RepairCenterRepository()).list_repair_center(
|
||||
filters, access
|
||||
)
|
||||
return result.model_dump(mode="json")
|
||||
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
def minerops_repair_reject_modular_mutation(cur, miner_id: str) -> None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM repairs
|
||||
WHERE miner_id = %s::uuid
|
||||
AND status IN ('OPEN', 'IN_PROGRESS', 'WAITING_FOR_PARTS', 'READY_FOR_TEST')
|
||||
LIMIT 1
|
||||
""",
|
||||
(miner_id,),
|
||||
)
|
||||
if cur.fetchone():
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="This miner has an active modular repair; use the modular repair workflow",
|
||||
)
|
||||
|
||||
@app.post("/api/repairs/{miner_id}/note")
|
||||
def minerops_repair_add_note_api(
|
||||
miner_id: str,
|
||||
@@ -31874,7 +31831,8 @@ def minerops_repair_add_note_api(
|
||||
SELECT id::text, current_mac
|
||||
FROM miners
|
||||
WHERE id = %s::uuid
|
||||
LIMIT 1;
|
||||
LIMIT 1
|
||||
FOR UPDATE;
|
||||
""",
|
||||
(miner_id,),
|
||||
)
|
||||
@@ -31884,6 +31842,8 @@ def minerops_repair_add_note_api(
|
||||
if not miner:
|
||||
raise HTTPException(status_code=404, detail="Miner not found")
|
||||
|
||||
minerops_repair_reject_modular_mutation(cur, miner_id)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO miner_maintenance_events (
|
||||
@@ -31966,7 +31926,8 @@ def minerops_repair_update_status_api(
|
||||
SELECT id::text
|
||||
FROM miners
|
||||
WHERE id = %s::uuid
|
||||
LIMIT 1;
|
||||
LIMIT 1
|
||||
FOR UPDATE;
|
||||
""",
|
||||
(miner_id,),
|
||||
)
|
||||
@@ -31976,6 +31937,8 @@ def minerops_repair_update_status_api(
|
||||
if not miner:
|
||||
raise HTTPException(status_code=404, detail="Miner not found")
|
||||
|
||||
minerops_repair_reject_modular_mutation(cur, miner_id)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id::text
|
||||
@@ -32121,7 +32084,8 @@ def minerops_repair_update_state_api(
|
||||
SELECT id::text, current_mac
|
||||
FROM miners
|
||||
WHERE id = %s::uuid
|
||||
LIMIT 1;
|
||||
LIMIT 1
|
||||
FOR UPDATE;
|
||||
""",
|
||||
(miner_id,),
|
||||
)
|
||||
@@ -32131,6 +32095,8 @@ def minerops_repair_update_state_api(
|
||||
if not miner:
|
||||
raise HTTPException(status_code=404, detail="Miner not found")
|
||||
|
||||
minerops_repair_reject_modular_mutation(cur, miner_id)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE location_positions
|
||||
@@ -32937,8 +32903,23 @@ MINEROPS_REPAIR_QUEUE_HTML = r"""
|
||||
async function loadRepairs() {
|
||||
try {
|
||||
const data = await apiGet("/api/repairs/queue");
|
||||
repairItems = data.items || [];
|
||||
updateMetrics(data.summary || {});
|
||||
repairItems = (data.items || []).filter(function (item) {
|
||||
return !item.source || item.source === "legacy";
|
||||
});
|
||||
const legacySummary = {
|
||||
total: repairItems.length,
|
||||
status_counts: {},
|
||||
model_counts: {}
|
||||
};
|
||||
for (const item of repairItems) {
|
||||
const status = item.latest_event_status || item.asset_state || "unknown";
|
||||
const model = item.model || "unknown";
|
||||
legacySummary.status_counts[status] =
|
||||
(legacySummary.status_counts[status] || 0) + 1;
|
||||
legacySummary.model_counts[model] =
|
||||
(legacySummary.model_counts[model] || 0) + 1;
|
||||
}
|
||||
updateMetrics(legacySummary);
|
||||
buildModelFilter();
|
||||
renderTable();
|
||||
showStatus("ok", "Repair Queue loaded. " + repairItems.length + " miners in repair.");
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Set-based PostgreSQL read model for the Repair Center."""
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.repairs.repository import default_connection_factory
|
||||
|
||||
|
||||
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||
|
||||
|
||||
class RepairCenterRepository:
|
||||
"""Read-only persistence boundary for consolidated repair operations."""
|
||||
|
||||
def __init__(self, connection_factory: ConnectionFactory | None = None):
|
||||
self._connection_factory = connection_factory or default_connection_factory
|
||||
|
||||
@staticmethod
|
||||
def _ctes(*, show_miners: bool, show_locations: bool) -> str:
|
||||
miner_fields = (
|
||||
"miner.asset_tag AS asset_id, COALESCE(miner.model, miner.miner_type) AS model, miner.miner_sn, miner.power_sn, "
|
||||
"miner.current_mac, host(miner.current_ip) AS current_ip, "
|
||||
"miner.status AS runtime_status"
|
||||
if show_miners else
|
||||
"NULL::text AS asset_id, NULL::text AS model, NULL::text AS miner_sn, NULL::text AS power_sn, "
|
||||
"NULL::text AS current_mac, NULL::text AS current_ip, "
|
||||
"NULL::text AS runtime_status"
|
||||
)
|
||||
location_fields = (
|
||||
"placement.site_id, placement.site_name, placement.room_id, "
|
||||
"placement.room_name, placement.rack_label, placement.shelf_label, "
|
||||
"placement.position_id, placement.position_label, placement.position_index"
|
||||
if show_locations else
|
||||
"NULL::uuid AS site_id, NULL::text AS site_name, NULL::uuid AS room_id, "
|
||||
"NULL::text AS room_name, NULL::text AS rack_label, "
|
||||
"NULL::text AS shelf_label, NULL::uuid AS position_id, "
|
||||
"NULL::text AS position_label, NULL::integer AS position_index"
|
||||
)
|
||||
return f"""
|
||||
WITH latest_legacy AS (
|
||||
SELECT DISTINCT ON (event.miner_id)
|
||||
event.miner_id, event.event_status, event.event_title,
|
||||
event.description, event.event_at, event.created_at
|
||||
FROM miner_maintenance_events AS event
|
||||
WHERE event.event_type = 'repair'
|
||||
ORDER BY event.miner_id, event.event_at DESC,
|
||||
event.created_at DESC, event.id DESC
|
||||
),
|
||||
legacy_first AS (
|
||||
SELECT event.miner_id,
|
||||
min(COALESCE(event.event_at, event.created_at)) AS opened_at
|
||||
FROM miner_maintenance_events AS event
|
||||
WHERE event.event_type = 'repair'
|
||||
GROUP BY event.miner_id
|
||||
),
|
||||
legacy_counts AS (
|
||||
SELECT event.miner_id,
|
||||
count(*)::integer AS repair_event_count,
|
||||
count(*) FILTER (WHERE event.event_status IN (
|
||||
'open', 'diagnosing', 'waiting_parts', 'in_progress',
|
||||
'testing', 'ready_to_shelf', 'needs_followup'
|
||||
))::integer AS open_repair_event_count
|
||||
FROM miner_maintenance_events AS event
|
||||
WHERE event.event_type = 'repair'
|
||||
GROUP BY event.miner_id
|
||||
),
|
||||
latest_modular AS (
|
||||
SELECT DISTINCT ON (event.repair_id)
|
||||
event.repair_id, event.payload, event.created_at
|
||||
FROM repair_events AS event
|
||||
ORDER BY event.repair_id, event.sequence DESC, event.id DESC
|
||||
),
|
||||
initial_note AS (
|
||||
SELECT DISTINCT ON (event.repair_id)
|
||||
event.repair_id, event.payload ->> 'note' AS note
|
||||
FROM repair_events AS event
|
||||
WHERE event.event_type = 'INITIAL_NOTE'
|
||||
ORDER BY event.repair_id, event.sequence, event.id
|
||||
),
|
||||
part_rollup AS (
|
||||
SELECT part.repair_id,
|
||||
count(*) FILTER (WHERE part.voided_at IS NULL)::integer AS parts_count,
|
||||
COALESCE((array_agg(
|
||||
CASE WHEN part.consumable_description IS NOT NULL
|
||||
THEN part.consumable_description
|
||||
ELSE concat_ws(' ', part.component_type_snapshot,
|
||||
part.component_asset_id_snapshot,
|
||||
part.component_manufacturer_serial_snapshot)
|
||||
END ORDER BY part.recorded_at, part.id
|
||||
) FILTER (WHERE part.voided_at IS NULL))[1:3],
|
||||
ARRAY[]::text[]) AS parts_summary
|
||||
FROM repair_parts AS part GROUP BY part.repair_id
|
||||
),
|
||||
placement AS (
|
||||
SELECT DISTINCT ON (position.miner_id)
|
||||
position.miner_id, site.id AS site_id, site.name AS site_name,
|
||||
room.id AS room_id, COALESCE(room.name, position.room_name) AS room_name,
|
||||
position.rack_name AS rack_label, position.shelf_name AS shelf_label,
|
||||
position.id AS position_id, position.position_label,
|
||||
position.position_index
|
||||
FROM location_positions AS position
|
||||
LEFT JOIN rooms AS room ON room.id = position.room_id
|
||||
LEFT JOIN sites AS site ON site.id = room.site_id
|
||||
WHERE position.miner_id IS NOT NULL
|
||||
ORDER BY position.miner_id, position.id
|
||||
),
|
||||
modular_rows AS (
|
||||
SELECT repair.id AS repair_id, 'modular'::text AS source,
|
||||
repair.status = ANY(ARRAY['OPEN', 'IN_PROGRESS',
|
||||
'WAITING_FOR_PARTS', 'READY_FOR_TEST']) AS active,
|
||||
repair.opened_at, repair.completed_at AS closed_at,
|
||||
repair.status AS repair_status, repair.reason,
|
||||
COALESCE(NULLIF(trim(miner.manual_details ->
|
||||
'office_repair_import' ->> 'reported_error'), ''),
|
||||
NULLIF(trim(initial_note.note), ''),
|
||||
replace(repair.reason, '_', ' ')) AS reported_problem,
|
||||
COALESCE(NULLIF(trim(latest_modular.payload ->> 'note'), ''),
|
||||
NULLIF(trim(latest_modular.payload ->> 'notes'), ''),
|
||||
NULLIF(trim(latest_modular.payload ->> 'completion_summary'), ''),
|
||||
NULLIF(trim(initial_note.note), ''),
|
||||
NULLIF(trim(latest_legacy.description), ''),
|
||||
NULLIF(trim(location_state.state_notes), '')) AS latest_context,
|
||||
COALESCE(latest_modular.created_at, latest_legacy.event_at,
|
||||
latest_legacy.created_at, repair.opened_at) AS latest_context_at,
|
||||
opened_actor.display_name AS opened_by_display_name,
|
||||
miner.id AS miner_id, {miner_fields}, miner.lifecycle_status,
|
||||
{location_fields},
|
||||
COALESCE(part_rollup.parts_count, 0) AS parts_count,
|
||||
COALESCE(part_rollup.parts_summary, ARRAY[]::text[]) AS parts_summary,
|
||||
latest_legacy.event_status AS legacy_event_status,
|
||||
location_state.asset_state AS legacy_asset_state,
|
||||
latest_legacy.event_title AS latest_event_title,
|
||||
COALESCE(legacy_counts.repair_event_count, 0) AS repair_event_count,
|
||||
COALESCE(legacy_counts.open_repair_event_count, 0) AS open_repair_event_count,
|
||||
miner.manual_details -> 'office_repair_import' ->> 'reported_hashrate_ths' AS reported_hashrate_ths,
|
||||
miner.manual_details -> 'office_repair_import' ->> 'reported_voltage' AS reported_voltage,
|
||||
miner.manual_details -> 'office_repair_import' ->> 'location_note' AS location_note,
|
||||
miner.manual_details -> 'office_repair_import' ->> 'source' AS import_source,
|
||||
location_state.updated_at AS state_updated_at
|
||||
FROM repairs AS repair
|
||||
JOIN miners AS miner ON miner.id = repair.miner_id
|
||||
JOIN users AS opened_actor ON opened_actor.id = repair.opened_by
|
||||
LEFT JOIN latest_modular ON latest_modular.repair_id = repair.id
|
||||
LEFT JOIN initial_note ON initial_note.repair_id = repair.id
|
||||
LEFT JOIN part_rollup ON part_rollup.repair_id = repair.id
|
||||
LEFT JOIN latest_legacy ON latest_legacy.miner_id = miner.id
|
||||
LEFT JOIN legacy_counts ON legacy_counts.miner_id = miner.id
|
||||
LEFT JOIN miner_location_states AS location_state
|
||||
ON location_state.miner_id = miner.id
|
||||
LEFT JOIN placement ON placement.miner_id = miner.id
|
||||
),
|
||||
legacy_rows AS (
|
||||
SELECT NULL::uuid AS repair_id, 'legacy'::text AS source,
|
||||
true AS active, legacy_first.opened_at, NULL::timestamptz AS closed_at,
|
||||
COALESCE(latest_legacy.event_status,
|
||||
location_state.asset_state, 'unknown') AS repair_status,
|
||||
NULL::text AS reason,
|
||||
COALESCE(NULLIF(trim(miner.manual_details ->
|
||||
'office_repair_import' ->> 'reported_error'), ''),
|
||||
NULLIF(trim(latest_legacy.event_title), '')) AS reported_problem,
|
||||
COALESCE(NULLIF(trim(latest_legacy.description), ''),
|
||||
NULLIF(trim(location_state.state_notes), '')) AS latest_context,
|
||||
COALESCE(latest_legacy.event_at, latest_legacy.created_at,
|
||||
location_state.updated_at) AS latest_context_at,
|
||||
NULL::text AS opened_by_display_name,
|
||||
miner.id AS miner_id, {miner_fields}, miner.lifecycle_status,
|
||||
{location_fields}, NULL::integer AS parts_count,
|
||||
ARRAY[]::text[] AS parts_summary,
|
||||
latest_legacy.event_status AS legacy_event_status,
|
||||
location_state.asset_state AS legacy_asset_state,
|
||||
latest_legacy.event_title AS latest_event_title,
|
||||
COALESCE(legacy_counts.repair_event_count, 0) AS repair_event_count,
|
||||
COALESCE(legacy_counts.open_repair_event_count, 0) AS open_repair_event_count,
|
||||
miner.manual_details -> 'office_repair_import' ->> 'reported_hashrate_ths' AS reported_hashrate_ths,
|
||||
miner.manual_details -> 'office_repair_import' ->> 'reported_voltage' AS reported_voltage,
|
||||
miner.manual_details -> 'office_repair_import' ->> 'location_note' AS location_note,
|
||||
miner.manual_details -> 'office_repair_import' ->> 'source' AS import_source,
|
||||
location_state.updated_at AS state_updated_at
|
||||
FROM miner_location_states AS location_state
|
||||
JOIN miners AS miner ON miner.id = location_state.miner_id
|
||||
LEFT JOIN latest_legacy ON latest_legacy.miner_id = miner.id
|
||||
LEFT JOIN legacy_counts ON legacy_counts.miner_id = miner.id
|
||||
LEFT JOIN legacy_first ON legacy_first.miner_id = miner.id
|
||||
LEFT JOIN placement ON placement.miner_id = miner.id
|
||||
WHERE location_state.asset_state = 'repair'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM repairs AS active_repair
|
||||
WHERE active_repair.miner_id = miner.id
|
||||
AND active_repair.status = ANY(ARRAY['OPEN', 'IN_PROGRESS',
|
||||
'WAITING_FOR_PARTS', 'READY_FOR_TEST'])
|
||||
)
|
||||
),
|
||||
candidates AS (
|
||||
SELECT * FROM modular_rows
|
||||
UNION ALL
|
||||
SELECT * FROM legacy_rows
|
||||
)
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _where(filters: Mapping[str, Any]) -> tuple[str, list[Any]]:
|
||||
conditions: list[str] = []
|
||||
params: list[Any] = []
|
||||
scope = filters.get("scope", "active")
|
||||
if scope == "active":
|
||||
conditions.append("candidate.active")
|
||||
elif scope == "historical":
|
||||
conditions.append("NOT candidate.active")
|
||||
for key, column in (
|
||||
("status", "repair_status"),
|
||||
("lifecycle_status", "lifecycle_status"),
|
||||
("site_id", "site_id"),
|
||||
("room_id", "room_id"),
|
||||
):
|
||||
if filters.get(key):
|
||||
conditions.append(f"candidate.{column} = %s")
|
||||
params.append(filters[key])
|
||||
if filters.get("model"):
|
||||
conditions.append("lower(trim(candidate.model)) = lower(trim(%s))")
|
||||
params.append(filters["model"])
|
||||
if filters.get("query"):
|
||||
conditions.append(
|
||||
"concat_ws(' ', candidate.repair_id, candidate.miner_id, "
|
||||
"candidate.asset_id, candidate.model, candidate.miner_sn, "
|
||||
"candidate.current_mac, candidate.current_ip, "
|
||||
"candidate.repair_status, candidate.reason, "
|
||||
"candidate.reported_problem, candidate.latest_context) ILIKE %s"
|
||||
)
|
||||
params.append(f"%{filters['query']}%")
|
||||
return " AND ".join(conditions) if conditions else "true", params
|
||||
|
||||
def list_repair_center(
|
||||
self,
|
||||
filters: Mapping[str, Any],
|
||||
*,
|
||||
include_miner_details: bool,
|
||||
include_location_details: bool,
|
||||
) -> dict[str, Any]:
|
||||
ctes = self._ctes(
|
||||
show_miners=include_miner_details,
|
||||
show_locations=include_location_details,
|
||||
)
|
||||
where_sql, params = self._where(filters)
|
||||
item_sql = f"""
|
||||
{ctes}
|
||||
SELECT candidate.* FROM candidates AS candidate
|
||||
WHERE {where_sql}
|
||||
ORDER BY candidate.active DESC,
|
||||
CASE WHEN candidate.active THEN candidate.opened_at END ASC NULLS LAST,
|
||||
candidate.opened_at DESC NULLS LAST,
|
||||
candidate.repair_id NULLS LAST, candidate.miner_id
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
summary_sql = f"""
|
||||
{ctes}, filtered AS (
|
||||
SELECT * FROM candidates AS candidate WHERE {where_sql}
|
||||
)
|
||||
SELECT count(*)::integer AS total,
|
||||
count(*) FILTER (WHERE active)::integer AS active,
|
||||
count(*) FILTER (WHERE NOT active)::integer AS historical,
|
||||
COALESCE((SELECT jsonb_object_agg(status_rows.repair_status,
|
||||
status_rows.status_count) FROM (
|
||||
SELECT repair_status, count(*)::integer AS status_count
|
||||
FROM filtered GROUP BY repair_status
|
||||
) AS status_rows), '{{}}'::jsonb) AS status_counts,
|
||||
COALESCE((SELECT jsonb_object_agg(model_rows.model,
|
||||
model_rows.model_count) FROM (
|
||||
SELECT model, count(*)::integer AS model_count
|
||||
FROM filtered WHERE model IS NOT NULL GROUP BY model
|
||||
) AS model_rows), '{{}}'::jsonb) AS model_counts,
|
||||
COALESCE((SELECT jsonb_object_agg(error_rows.error,
|
||||
error_rows.error_count) FROM (
|
||||
SELECT COALESCE(reported_problem, 'No reported error') AS error,
|
||||
count(*)::integer AS error_count
|
||||
FROM filtered
|
||||
GROUP BY COALESCE(reported_problem, 'No reported error')
|
||||
) AS error_rows), '{{}}'::jsonb) AS error_counts
|
||||
FROM filtered
|
||||
"""
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
item_sql,
|
||||
[*params, int(filters.get("limit", 50)),
|
||||
int(filters.get("offset", 0))],
|
||||
)
|
||||
items = [dict(row) for row in cur.fetchall()]
|
||||
cur.execute(summary_sql, params)
|
||||
summary = dict(cur.fetchone())
|
||||
options = {"sites": [], "rooms": []}
|
||||
if include_location_details:
|
||||
cur.execute(
|
||||
f"""
|
||||
{ctes}
|
||||
SELECT jsonb_build_object(
|
||||
'sites', COALESCE((SELECT jsonb_agg(site_rows
|
||||
ORDER BY lower(site_rows.name), site_rows.id)
|
||||
FROM (SELECT DISTINCT site_id AS id, site_name AS name
|
||||
FROM candidates WHERE site_id IS NOT NULL) site_rows),
|
||||
'[]'::jsonb),
|
||||
'rooms', COALESCE((SELECT jsonb_agg(room_rows
|
||||
ORDER BY lower(room_rows.name), room_rows.id)
|
||||
FROM (SELECT DISTINCT room_id AS id, site_id,
|
||||
room_name AS name FROM candidates
|
||||
WHERE room_id IS NOT NULL) room_rows), '[]'::jsonb)
|
||||
) AS options
|
||||
"""
|
||||
)
|
||||
options = cur.fetchone()["options"]
|
||||
finally:
|
||||
connection.close()
|
||||
return {"items": items, "summary": summary, "location_options": options}
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Application service for the consolidated Repair Center read model."""
|
||||
|
||||
from app.repairs.center_repository import RepairCenterRepository
|
||||
from app.repairs.models import (
|
||||
RepairCenterAccess,
|
||||
RepairCenterCapabilities,
|
||||
RepairCenterFilters,
|
||||
RepairCenterItem,
|
||||
RepairCenterList,
|
||||
RepairCenterLocationOptions,
|
||||
RepairCenterMeta,
|
||||
RepairCenterResponse,
|
||||
RepairCenterSummary,
|
||||
)
|
||||
|
||||
|
||||
class RepairCenterService:
|
||||
"""Build typed, permission-aware Repair Center projections."""
|
||||
|
||||
def __init__(self, repository: RepairCenterRepository):
|
||||
self.repository = repository
|
||||
|
||||
@staticmethod
|
||||
def _capabilities(
|
||||
item: RepairCenterItem,
|
||||
access: RepairCenterAccess,
|
||||
) -> RepairCenterCapabilities:
|
||||
modular = item.source == "modular" and item.repair_id is not None
|
||||
legacy = item.source == "legacy"
|
||||
placed = bool(item.site_id and item.room_id and item.position_id)
|
||||
return RepairCenterCapabilities(
|
||||
can_open_miner=access.can_view_miners,
|
||||
can_open_map=access.can_view_locations and placed,
|
||||
can_read_modular_timeline=modular and access.can_read_repairs,
|
||||
can_read_modular_parts=modular and access.can_read_repairs,
|
||||
can_manage_modular_parts=(
|
||||
modular and item.active and access.can_read_repairs
|
||||
and access.can_manage_parts
|
||||
),
|
||||
can_complete_modular_repair=(
|
||||
modular and item.active and access.can_complete_repairs
|
||||
),
|
||||
can_read_legacy_history=legacy and access.can_read_repairs,
|
||||
can_manage_legacy=legacy and access.can_manage_legacy,
|
||||
can_mark_legacy_repaired=legacy and access.can_manage_legacy,
|
||||
can_decommission_legacy=legacy and access.can_manage_legacy,
|
||||
)
|
||||
|
||||
def list_repair_center(
|
||||
self,
|
||||
filters: RepairCenterFilters,
|
||||
access: RepairCenterAccess,
|
||||
) -> RepairCenterResponse:
|
||||
query = filters.query.strip() if filters.query else None
|
||||
model = filters.model.strip() if filters.model else None
|
||||
status = filters.status.strip() if filters.status else None
|
||||
repository_filters = filters.model_dump()
|
||||
repository_filters.update(query=query or None, model=model or None,
|
||||
status=status or None)
|
||||
projection = self.repository.list_repair_center(
|
||||
repository_filters,
|
||||
include_miner_details=access.can_view_miners,
|
||||
include_location_details=access.can_view_locations,
|
||||
)
|
||||
items: list[RepairCenterItem] = []
|
||||
for row in projection["items"]:
|
||||
row = {
|
||||
**row,
|
||||
"miner_status": row.get("runtime_status"),
|
||||
"latest_event_status": row.get("legacy_event_status"),
|
||||
"latest_event_description": row.get("latest_context"),
|
||||
"latest_event_at": row.get("latest_context_at"),
|
||||
"asset_state": row.get("legacy_asset_state"),
|
||||
"state_notes": row.get("latest_context"),
|
||||
"reported_error": row.get("reported_problem"),
|
||||
}
|
||||
item = RepairCenterItem.model_validate(
|
||||
{**row, "capabilities": RepairCenterCapabilities()}
|
||||
)
|
||||
item.capabilities = self._capabilities(item, access)
|
||||
items.append(item)
|
||||
|
||||
summary = RepairCenterSummary.model_validate(projection["summary"])
|
||||
location_options = RepairCenterLocationOptions.model_validate(
|
||||
projection.get("location_options") or {}
|
||||
)
|
||||
return RepairCenterResponse(
|
||||
data=RepairCenterList(items=items, summary=summary),
|
||||
meta=RepairCenterMeta(
|
||||
limit=filters.limit,
|
||||
offset=filters.offset,
|
||||
returned=len(items),
|
||||
total=summary.total,
|
||||
location_options=location_options,
|
||||
),
|
||||
)
|
||||
@@ -1,8 +1,14 @@
|
||||
"""Dependency construction for the Repairs domain."""
|
||||
|
||||
from app.repairs.repository import RepairRepository
|
||||
from app.repairs.center_repository import RepairCenterRepository
|
||||
from app.repairs.center_service import RepairCenterService
|
||||
from app.repairs.service import RepairService
|
||||
|
||||
|
||||
def get_repair_service() -> RepairService:
|
||||
return RepairService(RepairRepository())
|
||||
|
||||
|
||||
def get_repair_center_service() -> RepairCenterService:
|
||||
return RepairCenterService(RepairCenterRepository())
|
||||
@@ -286,3 +286,139 @@ class RepairPartListResponse(BaseModel):
|
||||
data: RepairPartList
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
|
||||
|
||||
RepairCenterSource = Literal["modular", "legacy"]
|
||||
RepairCenterScope = Literal["active", "historical", "all"]
|
||||
|
||||
|
||||
class RepairCenterAccess(BaseModel):
|
||||
can_read_repairs: bool = False
|
||||
can_view_miners: bool = False
|
||||
can_view_locations: bool = False
|
||||
can_complete_repairs: bool = False
|
||||
can_manage_parts: bool = False
|
||||
can_manage_legacy: bool = False
|
||||
|
||||
|
||||
class RepairCenterFilters(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||
|
||||
query: str | None = Field(default=None, max_length=255)
|
||||
scope: RepairCenterScope = "active"
|
||||
status: str | None = Field(default=None, max_length=64)
|
||||
model: str | None = Field(default=None, max_length=255)
|
||||
lifecycle_status: RepairMinerLifecycle | None = None
|
||||
site_id: UUID | None = None
|
||||
room_id: UUID | None = None
|
||||
limit: int = Field(default=50, ge=1, le=250)
|
||||
offset: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class RepairCenterCapabilities(BaseModel):
|
||||
can_open_miner: bool = False
|
||||
can_open_map: bool = False
|
||||
can_read_modular_timeline: bool = False
|
||||
can_read_modular_parts: bool = False
|
||||
can_manage_modular_parts: bool = False
|
||||
can_complete_modular_repair: bool = False
|
||||
can_read_legacy_history: bool = False
|
||||
can_manage_legacy: bool = False
|
||||
can_mark_legacy_repaired: bool = False
|
||||
can_decommission_legacy: bool = False
|
||||
|
||||
|
||||
class RepairCenterItem(BaseModel):
|
||||
repair_id: UUID | None = None
|
||||
source: RepairCenterSource
|
||||
active: bool
|
||||
opened_at: AwareDatetime | None = None
|
||||
closed_at: AwareDatetime | None = None
|
||||
repair_status: str
|
||||
reason: str | None = None
|
||||
reported_problem: str | None = None
|
||||
latest_context: str | None = None
|
||||
latest_context_at: AwareDatetime | None = None
|
||||
opened_by_display_name: str | None = None
|
||||
miner_id: UUID
|
||||
asset_id: str | None = None
|
||||
model: str | None = None
|
||||
miner_sn: str | None = None
|
||||
power_sn: str | None = None
|
||||
current_mac: str | None = None
|
||||
current_ip: str | None = None
|
||||
lifecycle_status: RepairMinerLifecycle
|
||||
runtime_status: str | None = None
|
||||
miner_status: str | None = None
|
||||
site_id: UUID | None = None
|
||||
site_name: str | None = None
|
||||
room_id: UUID | None = None
|
||||
room_name: str | None = None
|
||||
rack_label: str | None = None
|
||||
shelf_label: str | None = None
|
||||
position_id: UUID | None = None
|
||||
position_label: str | None = None
|
||||
position_index: int | None = None
|
||||
parts_count: int | None = None
|
||||
parts_summary: list[str] = Field(default_factory=list)
|
||||
legacy_event_status: str | None = None
|
||||
legacy_asset_state: str | None = None
|
||||
latest_event_status: str | None = None
|
||||
latest_event_title: str | None = None
|
||||
latest_event_description: str | None = None
|
||||
latest_event_at: AwareDatetime | None = None
|
||||
asset_state: str | None = None
|
||||
state_notes: str | None = None
|
||||
repair_event_count: int = 0
|
||||
open_repair_event_count: int = 0
|
||||
state_updated_at: AwareDatetime | None = None
|
||||
reported_error: str | None = None
|
||||
reported_hashrate_ths: str | None = None
|
||||
reported_voltage: str | None = None
|
||||
location_note: str | None = None
|
||||
import_source: str | None = None
|
||||
capabilities: RepairCenterCapabilities
|
||||
|
||||
|
||||
class RepairCenterSummary(BaseModel):
|
||||
total: int
|
||||
active: int
|
||||
historical: int
|
||||
status_counts: dict[str, int] = Field(default_factory=dict)
|
||||
model_counts: dict[str, int] = Field(default_factory=dict)
|
||||
error_counts: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RepairCenterLocationOption(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
|
||||
|
||||
class RepairCenterRoomOption(RepairCenterLocationOption):
|
||||
site_id: UUID
|
||||
|
||||
|
||||
class RepairCenterLocationOptions(BaseModel):
|
||||
sites: list[RepairCenterLocationOption] = Field(default_factory=list)
|
||||
rooms: list[RepairCenterRoomOption] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RepairCenterList(BaseModel):
|
||||
items: list[RepairCenterItem]
|
||||
summary: RepairCenterSummary
|
||||
|
||||
|
||||
class RepairCenterMeta(BaseModel):
|
||||
limit: int
|
||||
offset: int
|
||||
returned: int
|
||||
total: int
|
||||
location_options: RepairCenterLocationOptions = Field(
|
||||
default_factory=RepairCenterLocationOptions
|
||||
)
|
||||
|
||||
|
||||
class RepairCenterResponse(BaseModel):
|
||||
data: RepairCenterList
|
||||
meta: RepairCenterMeta
|
||||
error: None = None
|
||||
Reference in new issue
Block a user