(function () { "use strict"; if (document.body.dataset.view !== "miner_detail") return; const minerId = document.body.dataset.minerId; const returnTo = document.body.dataset.returnTo || ""; const el = { loading: document.getElementById("minerDetailLoading"), error: document.getElementById("minerDetailError"), errorText: document.getElementById("minerDetailErrorText"), content: document.getElementById("minerDetailContent"), retry: document.getElementById("minerDetailRetry"), refresh: document.getElementById("minerDetailRefresh"), title: document.getElementById("minerIdentityTitle"), model: document.getElementById("minerDetailModel"), uuid: document.getElementById("minerDetailUuid"), operational: document.getElementById("minerDetailOperational"), lifecycle: document.getElementById("minerDetailLifecycle"), locationSummary: document.getElementById("minerDetailLocationSummary"), returnLink: document.getElementById("minerDetailReturn"), mapLink: document.getElementById("minerDetailMap"), trendsLink: document.getElementById("minerDetailTrends"), repairsLink: document.getElementById("minerDetailRepairs"), identity: document.getElementById("minerIdentityDetails"), tags: document.getElementById("minerTags"), telemetry: document.getElementById("minerTelemetryDetails"), telemetryAge: document.getElementById("minerTelemetryAge"), location: document.getElementById("minerLocationBody"), repair: document.getElementById("minerRepairBody"), episodes: document.getElementById("minerRepairEpisodes"), identityHistory: document.getElementById("minerIdentityHistory"), identityHistoryBody: document.getElementById("minerIdentityHistoryBody"), observations: document.getElementById("minerObservations"), observationsBody: document.getElementById("minerObservationsBody") }; function create(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; } function display(value, fallback) { if (value === null || value === undefined || value === "") { return fallback === undefined ? "Unavailable" : fallback; } return String(value); } function words(value) { const text = display(value, "unknown").replaceAll("_", " ").toLowerCase(); return text.charAt(0).toUpperCase() + text.slice(1); } function dateTime(value) { if (!value) return "Unavailable"; const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? "Unavailable" : parsed.toLocaleString(); } function age(value) { if (!value) return "No observation available"; const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) return "Observation time unavailable"; const seconds = Math.max(0, Math.floor((Date.now() - parsed.getTime()) / 1000)); if (seconds < 60) return seconds + " seconds ago"; if (seconds < 3600) return Math.floor(seconds / 60) + " minutes ago"; if (seconds < 86400) return Math.floor(seconds / 3600) + " hours ago"; return Math.floor(seconds / 86400) + " days ago"; } function metric(value, suffix) { if (value === null || value === undefined || value === "") return "Unavailable"; const number = Number(value); if (!Number.isFinite(number)) return "Unavailable"; return String(number) + suffix; } function addDefinition(list, label, value) { const row = create("div"); row.append(create("dt", "", label), create("dd", "", display(value))); list.appendChild(row); } function prefixed(value, prefix) { const label = display(value, ""); if (!label) return ""; return new RegExp("^" + prefix + "\\b", "i").test(label) ? label : prefix + " " + label; } function locationText(location) { if (!location) return "Not mapped"; const parts = [location.site_name, location.room_name, prefixed(location.rack_label, "Rack"), prefixed(location.shelf_label, "Shelf"), location.position_label || (location.position_index === null ? "Position unavailable" : "Position " + location.position_index)]; return parts.filter(Boolean).join(" / "); } async function api(path) { const response = await fetch(path, { credentials: "same-origin", headers: { Accept: "application/json" } }); if (response.status === 401) { window.location.assign("/login?next=" + encodeURIComponent( window.location.pathname + window.location.search )); throw new Error("Login required"); } let payload = null; try { payload = await response.json(); } catch (_error) { payload = null; } if (!response.ok) { throw new Error(payload && payload.detail ? String(payload.detail) : "Request failed (" + response.status + ")"); } return payload ? payload.data : null; } function table(headers, rows) { const wrap = create("div", "table-wrap compact-history-table"); const tableNode = create("table"); const head = create("thead"); const headRow = create("tr"); headers.forEach(function (header) { headRow.appendChild(create("th", "", header)); }); head.appendChild(headRow); const body = create("tbody"); rows.forEach(function (values) { const row = create("tr"); values.forEach(function (value) { row.appendChild(create("td", "", display(value))); }); body.appendChild(row); }); tableNode.append(head, body); wrap.appendChild(tableNode); return wrap; } function renderIdentity(miner) { const identity = miner.asset_id || miner.current_mac || "Miner " + miner.id.slice(0, 8); el.title.textContent = identity; el.model.textContent = display(miner.model, "Model unavailable"); el.uuid.textContent = "Miner UUID: " + miner.id; const pageTitle = document.querySelector(".page-head h1"); if (pageTitle) pageTitle.textContent = identity; document.title = identity + " | MinerOps"; el.identity.replaceChildren(); [ ["Asset ID", miner.asset_id], ["Model", miner.model], ["Miner serial", miner.miner_sn], ["Control-board serial", miner.control_board_sn], ["PSU serial", miner.psu_serial], ["Current MAC", miner.current_mac], ["Current IP", miner.current_ip], ["Hostname", miner.hostname], ["Firmware", miner.firmware_version], ["Created", dateTime(miner.created_at)], ["First seen", dateTime(miner.first_seen_at)], ["Last seen", dateTime(miner.last_seen_at)] ].forEach(function (item) { addDefinition(el.identity, item[0], item[1]); }); el.tags.replaceChildren(); (miner.tags || []).forEach(function (tag) { el.tags.appendChild(create("span", "tag", tag.name)); }); } function renderTelemetry(miner) { el.telemetry.replaceChildren(); [ ["Runtime", words(miner.runtime_status)], ["Observed", dateTime(miner.observed_at)], ["Hashrate", metric(miner.hashrate_ths, " TH/s")], ["Power", metric(miner.power_w, " W")], ["Max chip temperature", metric(miner.chip_temp_max_c, " C")], ["Hash stability", miner.hash_stable === null || miner.hash_stable === undefined ? "Unavailable" : miner.hash_stable ? "Stable" : "Unstable"] ].forEach(function (item) { addDefinition(el.telemetry, item[0], item[1]); }); el.telemetryAge.textContent = age(miner.observed_at); } function renderLocation(section, minerIdValue, canViewMap) { el.location.replaceChildren(); if (section.restricted) { el.location.appendChild(create("p", "permission-note", "Location information restricted.")); el.locationSummary.textContent = "Location restricted"; return; } if (!section.location) { el.location.appendChild(create("p", "empty-copy", "Not mapped")); el.locationSummary.textContent = "Not mapped"; return; } const location = section.location; const list = create("dl", "definition-list"); [["Site", location.site_name], ["Room", location.room_name], ["Rack", prefixed(location.rack_label, "Rack")], ["Shelf", prefixed(location.shelf_label, "Shelf")], ["Position", location.position_label || (location.position_index === null ? null : "Position " + location.position_index)] ].forEach(function (item) { addDefinition(list, item[0], item[1]); }); el.location.appendChild(list); const summary = locationText(location); el.locationSummary.textContent = summary; if (canViewMap) { const params = new URLSearchParams({ miner_id: minerIdValue }); params.set("site_id", location.site_id); params.set("room_id", location.room_id); el.mapLink.href = "/monitoring/map?" + params.toString(); el.mapLink.hidden = false; } } function eventPayload(payload) { const list = create("dl", "definition-list event-payload"); Object.entries(payload || {}).forEach(function (entry) { const value = typeof entry[1] === "object" ? JSON.stringify(entry[1]) : entry[1]; addDefinition(list, words(entry[0]), value); }); return list; } function renderRepairDetails(target, timeline, parts, canReadComponents) { target.replaceChildren(); const events = timeline && timeline.events ? timeline.events : []; const heading = create("h4", "", "Timeline"); const timelineList = create("ol", "repair-timeline-list"); events.forEach(function (event) { const item = create("li"); item.append(create("strong", "", words(event.event_type)), create("span", "secondary", dateTime(event.occurred_at) + " / " + display(event.actor && event.actor.display_name, "Unknown actor")), eventPayload(event.payload)); timelineList.appendChild(item); }); if (!events.length) timelineList.appendChild(create("li", "empty-copy", "No timeline events recorded.")); target.append(heading, timelineList, create("h4", "", "Parts and components")); const entries = parts && parts.entries ? parts.entries : []; if (!entries.length) { target.appendChild(create("p", "empty-copy", "No parts recorded for this repair.")); return; } const rows = entries.map(function (entry) { let description = "Tracked component"; if (entry.consumable) { description = entry.consumable.description + " / " + entry.consumable.quantity + " " + entry.consumable.unit; } else if (entry.component_snapshot) { const snapshot = entry.component_snapshot; description = [words(snapshot.component_type), snapshot.asset_id, snapshot.manufacturer, snapshot.model, snapshot.manufacturer_serial] .filter(Boolean).join(" / "); if (canReadComponents && entry.component_id) { description += " / Component UUID " + entry.component_id; } } return [description, entry.notes, dateTime(entry.recorded_at), entry.voided_at ? "Voided: " + display(entry.void_reason) : "Recorded"]; }); target.appendChild(table(["Part or component", "Notes", "Recorded", "State"], rows)); } function repairEpisode(episode, canReadComponents) { const details = create("details"); const summary = create("summary", "repair-episode-summary"); summary.append(create("strong", "", (episode.source === "legacy" ? "Legacy compatibility" : words(episode.status))), create("span", "secondary", [words(episode.reason), dateTime(episode.opened_at)] .filter(function (value) { return value !== "Unavailable"; }).join(" / "))); details.appendChild(summary); const overview = create("dl", "definition-list"); [["Status", words(episode.status)], ["Reason", words(episode.reason)], ["Opened", dateTime(episode.opened_at)], ["Closed", dateTime(episode.closed_at)], ["Latest context", episode.latest_context], ["Parts", episode.parts_count] ].forEach(function (item) { addDefinition(overview, item[0], item[1]); }); details.appendChild(overview); if (episode.source === "modular" && episode.repair_id) { const lazy = create("div", "lazy-content", "Open to load timeline and parts."); details.appendChild(lazy); details.addEventListener("toggle", async function () { if (!details.open || details.dataset.loaded || details.dataset.loading) return; details.dataset.loading = "true"; lazy.textContent = "Loading repair timeline and parts..."; try { const results = await Promise.all([ api("/api/repairs/" + encodeURIComponent(episode.repair_id) + "/events"), api("/api/repairs/" + encodeURIComponent(episode.repair_id) + "/parts") ]); renderRepairDetails(lazy, results[0], results[1], canReadComponents); details.dataset.loaded = "true"; } catch (error) { lazy.textContent = "Repair history unavailable: " + error.message; } finally { delete details.dataset.loading; } }); } else { details.appendChild(create("p", "permission-note", "Legacy compatibility context has no modular repair timeline.")); } return details; } function renderRepair(section, capabilities) { el.repair.replaceChildren(); el.episodes.replaceChildren(); if (section.restricted) { el.repair.appendChild(create("p", "permission-note", "Repair information restricted.")); return; } if (section.active_repair) { const active = section.active_repair; const list = create("dl", "definition-list"); [["Status", words(active.status)], ["Reason", words(active.reason)], ["Opened", dateTime(active.opened_at)], ["Source", "Modular repair workflow"], ["Latest context", active.latest_context], ["Parts", active.parts_count] ].forEach(function (item) { addDefinition(list, item[0], item[1]); }); el.repair.appendChild(list); } else if ((section.episodes || []).some(function (item) { return item.source === "legacy" && item.active; })) { el.repair.appendChild(create("p", "permission-note", "Legacy compatibility repair context is recorded. No modular repair UUID exists.")); } else { el.repair.appendChild(create("p", "empty-copy", "No active repair")); } (section.episodes || []).forEach(function (episode) { el.episodes.appendChild(repairEpisode(episode, capabilities.can_read_components)); }); } async function loadIdentityHistory() { if (el.identityHistory.dataset.loaded || el.identityHistory.dataset.loading) return; el.identityHistory.dataset.loading = "true"; el.identityHistoryBody.textContent = "Loading identity history..."; try { const results = await Promise.all([ api("/api/inventory/miners/" + encodeURIComponent(minerId) + "/asset-id-history"), api("/api/miners/" + encodeURIComponent(minerId) + "/network-identities") ]); el.identityHistoryBody.replaceChildren(create("h3", "", "Asset ID history")); const assetRows = (results[0] || []).map(function (entry) { return [entry.old_asset_id, entry.new_asset_id, dateTime(entry.changed_at), entry.changed_by_display_name]; }); el.identityHistoryBody.appendChild(assetRows.length ? table(["Previous", "New", "Changed", "Actor"], assetRows) : create("p", "empty-copy", "No Asset ID changes recorded.")); el.identityHistoryBody.appendChild(create("h3", "", "Network identity history")); const networkRows = (results[1] || []).map(function (entry) { return [entry.mac_address, entry.ip_address, entry.hostname, entry.is_current ? "Current" : "Historical", dateTime(entry.last_seen_at)]; }); el.identityHistoryBody.appendChild(networkRows.length ? table(["MAC", "IP", "Hostname", "State", "Last seen"], networkRows) : create("p", "empty-copy", "No network identity history recorded.")); el.identityHistory.dataset.loaded = "true"; } catch (error) { el.identityHistoryBody.textContent = "Identity history unavailable: " + error.message; } finally { delete el.identityHistory.dataset.loading; } } async function loadObservations() { if (el.observations.dataset.loaded || el.observations.dataset.loading) return; el.observations.dataset.loading = "true"; el.observationsBody.textContent = "Loading recent observations..."; try { const observations = await api("/api/miners/" + encodeURIComponent(minerId) + "/status-snapshots"); const rows = (observations || []).map(function (item) { return [dateTime(item.observed_at), metric(item.mhs_av === null ? null : Number(item.mhs_av) / 1000000, " TH/s"), metric(item.power_w, " W"), metric(item.chip_temp_max_c, " C"), item.hash_stable === null || item.hash_stable === undefined ? "Unavailable" : item.hash_stable ? "Stable" : "Unstable"]; }); el.observationsBody.replaceChildren(rows.length ? table(["Observed", "Hashrate", "Power", "Max chip temp", "Hash stability"], rows) : create("p", "empty-copy", "No observations recorded.")); el.observations.dataset.loaded = "true"; } catch (error) { el.observationsBody.textContent = "Recent observations unavailable: " + error.message; } finally { delete el.observations.dataset.loading; } } function renderOverview(data) { renderIdentity(data.miner); renderTelemetry(data.miner); el.operational.textContent = words(data.operational_state); el.operational.dataset.status = data.operational_state; el.lifecycle.textContent = "Lifecycle: " + words(data.miner.lifecycle_status); renderLocation(data.location, data.miner.id, data.capabilities.can_view_map); renderRepair(data.repair, data.capabilities); el.trendsLink.href = "/monitoring/analytics?" + new URLSearchParams({ scope: "miner", scope_id: data.miner.id, range: "24h" }).toString(); if (data.capabilities.can_open_repair_center) { const params = new URLSearchParams({ scope: "all", q: data.miner.id }); el.repairsLink.href = "/operations/repairs?" + params.toString(); el.repairsLink.hidden = false; } if (returnTo) { el.returnLink.href = returnTo; el.returnLink.textContent = returnTo.startsWith("/monitoring/map") ? "Return to Mine Map" : "Return to previous view"; el.returnLink.hidden = false; } } async function loadOverview() { el.loading.hidden = false; el.error.hidden = true; el.content.hidden = true; try { const data = await api("/api/miners/" + encodeURIComponent(minerId) + "/overview"); renderOverview(data); el.loading.hidden = true; el.content.hidden = false; } catch (error) { el.loading.hidden = true; el.error.hidden = false; el.errorText.textContent = error.message; } } el.retry.addEventListener("click", loadOverview); el.refresh.addEventListener("click", loadOverview); el.identityHistory.addEventListener("toggle", function () { if (el.identityHistory.open) loadIdentityHistory(); }); el.observations.addEventListener("toggle", function () { if (el.observations.open) loadObservations(); }); loadOverview(); })();