Unscoped legacy positions
+Stored positions without a durable room relationship. They are not assigned to the selected site or room.
Unplaced and operational areas
+Miner records without a configured rack position.
diff --git a/backend/app/frontend/router.py b/backend/app/frontend/router.py index 070d797..cc4b054 100644 --- a/backend/app/frontend/router.py +++ b/backend/app/frontend/router.py @@ -45,6 +45,15 @@ def frontend_script() -> Response: ) +@router.get("/assets/minerops-mine-map.js", include_in_schema=False) +def mine_map_script() -> Response: + return Response( + (STATIC_ROOT / "mine-map.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) diff --git a/backend/app/frontend/static/app.css b/backend/app/frontend/static/app.css index 88b10d6..2facea7 100644 --- a/backend/app/frontend/static/app.css +++ b/backend/app/frontend/static/app.css @@ -753,3 +753,371 @@ tbody tr:last-child td { padding: 14px; } } + +/* Mine Map v1 */ +.map-context { + padding: 15px; + border: 1px solid var(--line); + background: var(--surface); +} + +.map-context-head, +.map-status-band, +.inspector-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.map-context h2, +.map-inspector-v1 h2, +.unplaced-section h2 { + margin: 0; + font-size: 17px; +} + +.map-control-grid { + display: grid; + grid-template-columns: minmax(150px, .8fr) minmax(150px, .8fr) + minmax(170px, .9fr) minmax(220px, 1.4fr); + gap: 10px; + margin-top: 14px; +} + +.map-filters { + margin-top: 12px; + border-top: 1px solid #e3e8ed; + padding-top: 10px; +} + +.map-filters summary { + min-height: 36px; + cursor: pointer; + color: #334252; + font-size: 13px; + font-weight: 750; +} + +.map-filter-grid { + display: grid; + grid-template-columns: repeat(4, minmax(135px, 1fr)) auto; + align-items: end; + gap: 10px; +} + +.map-filter-grid .quiet-button, +.icon-button { + color: var(--ink); + border-color: #8e9ba8; + background: #fff; +} + +.map-status-band { + align-items: flex-start; + margin: 12px 0; +} + +.map-summary, +.map-legend { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.summary-fact { + display: inline-flex; + align-items: baseline; + gap: 5px; + padding-right: 9px; + border-right: 1px solid var(--line); + color: var(--muted); + font-size: 12px; +} + +.summary-fact strong { + color: var(--ink); + font-size: 15px; +} + +.mine-map-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + border: 1px solid var(--line); + background: var(--surface); +} + +.mine-map-workspace[hidden], +.map-inspector-v1[hidden], +.unplaced-section[hidden], +.map-notice[hidden] { + display: none; +} + +.map-main-column { + min-width: 0; +} + +.map-legend { + min-height: 50px; + padding: 10px 14px; + border-bottom: 1px solid var(--line); + background: #f7f8fa; + font-size: 12px; +} + +.legend-item { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.legend-marker, +.status-marker { + display: inline-grid; + place-items: center; + width: 24px; + height: 24px; + border: 1px solid #758493; + border-radius: 4px; + color: #263442; + background: #eef1f4; + font-size: 10px; + font-weight: 850; +} + +.map-notice { + margin: 14px; + padding: 13px; + border-left: 4px solid var(--warn); + background: #fff8e8; + color: #694100; + font-size: 13px; +} + +.map-viewport-v1 { + display: grid; + gap: 14px; + min-height: 380px; + max-height: calc(100vh - 330px); + overflow: auto; + padding: 14px; + background: #e9edf0; +} + +.map-rack { + min-width: 0; + border: 1px solid #aeb8c2; + background: #fff; +} + +.rack-title { + margin: 0; + padding: 9px 12px; + color: #fff; + background: #263442; + font-size: 14px; +} + +.map-shelf { + display: grid; + grid-template-columns: 112px minmax(0, 1fr); + border-top: 1px solid var(--line); +} + +.map-shelf:first-of-type { + border-top: 0; +} + +.shelf-title { + margin: 0; + padding: 12px; + color: #40505f; + background: #f3f5f7; + font-size: 12px; +} + +.position-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(142px, 1fr)); + gap: 7px; + padding: 8px; +} + +.map-position { + display: grid; + grid-template-rows: auto auto auto auto; + gap: 5px; + min-width: 0; + min-height: 118px; + padding: 9px; + border: 1px solid #8e9ba8; + border-left: 5px solid #758493; + border-radius: 4px; + color: var(--ink); + background: #fff; + text-align: left; +} + +button.map-position { + cursor: pointer; +} + +button.map-position:hover { + border-color: #40505f; + background: #f8fafb; +} + +.map-position[data-selected="true"] { + box-shadow: 0 0 0 3px var(--focus); +} + +.map-position[data-filtered="true"] { + opacity: .34; + filter: grayscale(1); +} + +.map-position[data-status="online"] { border-left-color: #177245; } +.map-position[data-status="offline"] { border-left-color: #b42318; background: #fff5f4; } +.map-position[data-status="unstable"] { border-left-color: #9a5b00; background: #fff9ed; } +.map-position[data-status="repair"] { border-left-color: #6b4ca5; background: #f7f3fc; } +.map-position[data-status="spare"] { border-left-color: #1677b8; background: #f1f8fc; } +.map-position[data-status="retired"] { border-left-color: #4d5660; background: #eef0f2; } +.map-position[data-status="empty"] { border-style: dashed; border-left-color: #8e9ba8; background: #f8fafb; } +.map-position[data-status="expected"] { border-left-color: #996600; background: #fff9e8; } +.map-position[data-status="conflict"] { border-left-color: #b42318; background: #fff0ee; } +.map-position[data-status="blocked"] { border-left-color: #4d5660; background: #e5e8eb; } +.map-position[data-status="switch"] { border-left-color: #28706d; background: #eff8f7; } +.map-position[data-status="restricted"] { border-left-color: #596675; background: #f3f5f7; } + +.position-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 6px; +} + +.position-label, +.position-miner, +.position-value, +.position-freshness { + overflow-wrap: anywhere; +} + +.position-label { font-size: 12px; } +.position-miner { font-size: 13px; font-weight: 750; } +.position-value { color: #334252; font-size: 12px; } +.position-freshness { color: var(--muted); font-size: 10px; } + +.map-inspector-v1 { + position: sticky; + top: 12px; + align-self: start; + min-width: 0; + max-height: calc(100vh - 90px); + overflow-y: auto; + padding: 16px; + border-left: 1px solid var(--line); + background: #fff; +} + +.icon-button { + width: 42px; + height: 42px; + border-radius: 4px; + cursor: pointer; + font-weight: 800; +} + +.status-callout { + padding: 10px; + border-left: 4px solid #758493; + background: #f3f5f7; + font-size: 13px; + font-weight: 750; +} + +.status-callout[data-status="online"] { border-color: var(--good); } +.status-callout[data-status="offline"] { border-color: var(--danger); } +.status-callout[data-status="repair"] { border-color: #6b4ca5; } + +.inspector-details { + grid-template-columns: minmax(92px, auto) minmax(0, 1fr); + margin-bottom: 18px; +} + +.inspector-details dd { + overflow-wrap: anywhere; +} + +.command-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 42px; + padding: 8px 15px; + border: 1px solid #1b5f89; + border-radius: 5px; + color: #fff; + background: #176b9a; + text-decoration: none; + font-weight: 750; +} + +.unplaced-section { + padding: 14px; + border-top: 1px solid var(--line); +} + +#mapUnplaced, +.legacy-position-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 8px; +} + +.legacy-position-entry { + display: grid; + gap: 7px; + padding: 10px; + border: 1px solid var(--line); + background: #fff; +} + +.legacy-position-path { + color: var(--muted); + font-size: 11px; +} + +.unplaced-miner { + display: grid; + gap: 4px; + padding: 10px; + border-left: 4px solid #758493; + background: #f3f5f7; + font-size: 12px; +} + +@media (max-width: 1180px) { + .map-control-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .map-filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .mine-map-workspace { grid-template-columns: 1fr; } + .map-inspector-v1 { position: static; max-height: none; border-top: 1px solid var(--line); border-left: 0; } +} + +@media (max-width: 680px) { + .map-context-head, + .map-status-band { align-items: flex-start; flex-direction: column; } + .map-control-grid, + .map-filter-grid { grid-template-columns: 1fr; } + .map-viewport-v1 { max-height: none; padding: 9px; } + .map-shelf { grid-template-columns: 1fr; } + .shelf-title { position: sticky; left: 0; z-index: 1; } + .position-grid { grid-template-columns: repeat(2, minmax(130px, 1fr)); overflow-x: auto; } + .map-inspector-v1 { position: fixed; inset: 0; z-index: 80; max-height: none; overflow-y: auto; } +} + +@media (max-width: 420px) { + .position-grid { grid-template-columns: minmax(150px, 1fr); } +} diff --git a/backend/app/frontend/static/mine-map.js b/backend/app/frontend/static/mine-map.js new file mode 100644 index 0000000..95eca2d --- /dev/null +++ b/backend/app/frontend/static/mine-map.js @@ -0,0 +1,629 @@ +(function () { + "use strict"; + + if (document.body.dataset.view !== "map") return; + + const ids = [ + "mapSite", "mapRoom", "mapLayer", "mapSearch", "mapLifecycle", + "mapModel", "mapRepair", "mapStatus", "mapClearFilters", + "mapFilterCount", "mapScope", "mapSummary", "mapFreshness", + "mapCriticalState", "mapCriticalTitle", "mapCriticalDetail", + "mapRetry", "mapLoading", "mapWorkspace", "mapLegend", "mapNotice", + "mapPermissionNote", "mapViewport", "mapLegacySection", + "mapLegacyPositions", "mapUnplacedSection", "mapUnplaced", + "mapInspector", "mapInspectorTitle", "mapInspectorStatus", + "mapInspectorDetails", "mapInspectorClose", "mapOpenMiner" + ]; + const el = {}; + for (const id of ids) el[id] = document.getElementById(id); + + const state = { + data: null, + siteId: "", + roomId: "", + layer: "status", + query: "", + lifecycle: "", + model: "", + repair: "", + status: "", + minerId: "", + minerDetailsIncluded: false + }; + + 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 || String(value).trim() === "") { + return fallback || "Unavailable"; + } + return String(value); + } + + function words(value) { + return display(value, "Unknown").replaceAll("_", " ").toLowerCase(); + } + + function dateTime(value) { + if (!value) return "Unavailable"; + const parsed = new Date(value); + return Number.isNaN(parsed.valueOf()) ? "Unavailable" : parsed.toLocaleString(); + } + + function metric(value, suffix) { + return typeof value === "number" && Number.isFinite(value) + ? value + suffix : "Unavailable"; + } + + function age(value) { + if (!value) return "Never observed"; + const time = new Date(value).valueOf(); + if (Number.isNaN(time)) return "Observation time unavailable"; + const seconds = Math.max(0, Math.floor((Date.now() - time) / 1000)); + if (seconds < 60) return seconds + "s ago"; + if (seconds < 3600) return Math.floor(seconds / 60) + "m ago"; + if (seconds < 86400) return Math.floor(seconds / 3600) + "h ago"; + return Math.floor(seconds / 86400) + "d ago"; + } + + function operational(position) { + const kind = words(position.position_kind); + const status = words(position.position_status); + if (position.conflict_reason || status === "conflict") { + return { key: "conflict", label: "Location conflict", mark: "!" }; + } + if (kind === "blocked" || status === "blocked") { + return { key: "blocked", label: "Blocked position", mark: "x" }; + } + if (kind === "switch" || status === "switch") { + return { key: "switch", label: "Switch / equipment", mark: "S" }; + } + if (!position.occupied) { + if (position.expected_mac) { + return { key: "expected", label: "Expected miner not resolved", mark: "?" }; + } + if (kind === "miner slot" && status === "available") { + return { key: "empty", label: "Empty position", mark: "+" }; + } + return { key: "unknown", label: "Unknown position state", mark: "?" }; + } + if (!state.minerDetailsIncluded) { + return { key: "restricted", label: "Occupied; miner details restricted", mark: "L" }; + } + if (position.active_repair || position.lifecycle_status === "in_repair") { + return { key: "repair", label: "In repair", mark: "R" }; + } + if (position.lifecycle_status === "retired" || position.asset_state === "decommissioned") { + return { key: "retired", label: "Retired / decommissioned", mark: "X" }; + } + if (position.lifecycle_status === "spare") { + return { key: "spare", label: "Spare inventory", mark: "P" }; + } + const runtime = words(position.miner_status); + if (runtime === "offline") { + return { key: "offline", label: "Offline", mark: "O" }; + } + if (position.hash_stable === false) { + return { key: "unstable", label: "Hash unstable", mark: "H" }; + } + if (runtime === "online") { + return { key: "online", label: "Online", mark: "ON" }; + } + return { key: "unknown", label: "Operational state unknown", mark: "?" }; + } + + function layerValue(position, condition) { + if (!position.occupied) return condition.label; + if (!state.minerDetailsIncluded) return condition.label; + if (state.layer === "temperature") { + return metric(position.chip_temp_max_c, " C max"); + } + if (state.layer === "hashrate") { + return metric(position.hashrate_ths, " TH/s"); + } + if (state.layer === "power") { + return metric(position.power_w, " W"); + } + if (state.layer === "repair") { + if (position.active_repair) return words(position.active_repair.status); + if (position.lifecycle_status === "in_repair") return "In-repair lifecycle"; + if (position.asset_state === "repair") return "Legacy repair location"; + return "No active repair"; + } + return condition.label; + } + + function identity(position) { + if (!state.minerDetailsIncluded) return "Miner details restricted"; + return display(position.asset_id, display(position.current_mac, "Unidentified miner")); + } + + function matches(position) { + if (!position.occupied) return true; + if (!state.minerDetailsIncluded) return true; + const condition = operational(position); + const haystack = [position.asset_id, position.miner_sn, position.current_mac, + position.current_ip, position.model].map(function (value) { + return display(value, "").toLowerCase(); + }).join(" "); + return (!state.query || haystack.includes(state.query.toLowerCase())) && + (!state.lifecycle || position.lifecycle_status === state.lifecycle) && + (!state.model || position.model === state.model) && + (!state.status || condition.key === state.status) && + (!state.repair || + (state.repair === "active" && Boolean(position.active_repair)) || + (state.repair === "none" && !position.active_repair) || + (state.repair === "legacy" && position.asset_state === "repair" && !position.active_repair)); + } + + function option(value, label) { + const node = create("option", "", label); + node.value = value; + return node; + } + + function unique(values) { + return Array.from(new Set(values.filter(Boolean))).sort(function (a, b) { + return String(a).localeCompare(String(b), undefined, { numeric: true }); + }); + } + + function syncSelectors() { + const sites = state.data.sites; + if (!sites.some(function (site) { return String(site.id) === state.siteId; })) { + state.siteId = sites.length ? String(sites[0].id) : ""; + } + el.mapSite.replaceChildren(); + for (const site of sites) el.mapSite.appendChild(option(String(site.id), site.name)); + el.mapSite.value = state.siteId; + + const rooms = state.data.rooms.filter(function (room) { + return String(room.site_id) === state.siteId; + }); + if (!rooms.some(function (room) { return String(room.id) === state.roomId; })) { + state.roomId = rooms.length ? String(rooms[0].id) : ""; + } + el.mapRoom.replaceChildren(); + if (!rooms.length) el.mapRoom.appendChild(option("", "No rooms at this site")); + for (const room of rooms) { + el.mapRoom.appendChild(option(String(room.id), room.name)); + } + el.mapRoom.value = state.roomId; + el.mapRoom.disabled = !rooms.length; + + const site = sites.find(function (item) { return String(item.id) === state.siteId; }); + const room = rooms.find(function (item) { return String(item.id) === state.roomId; }); + el.mapScope.textContent = site && room + ? site.name + " / " + room.name + : site ? site.name + " / No configured rooms" : "No configured sites"; + } + + function syncPermissionControls() { + const allowed = state.minerDetailsIncluded; + if (!allowed) { + state.layer = "status"; + state.query = state.lifecycle = state.model = state.repair = state.status = ""; + el.mapSearch.value = ""; + el.mapRepair.value = ""; + } + for (const item of el.mapLayer.options) { + item.disabled = !allowed && item.value !== "status"; + } + el.mapLayer.value = state.layer; + el.mapSearch.disabled = !allowed; + el.mapLifecycle.disabled = !allowed; + el.mapModel.disabled = !allowed; + el.mapRepair.disabled = !allowed; + el.mapStatus.disabled = !allowed; + el.mapClearFilters.disabled = !allowed; + el.mapSearch.closest(".field").hidden = !allowed; + document.getElementById("mapFilters").hidden = !allowed; + el.mapPermissionNote.hidden = allowed; + } + + function syncFilterOptions() { + const positions = state.data.positions.filter(function (position) { + return String(position.room_id || "") === state.roomId && position.occupied; + }); + const lifecycle = unique(positions.map(function (item) { return item.lifecycle_status; })); + const models = unique(positions.map(function (item) { return item.model; })); + const statuses = unique(positions.map(function (item) { return operational(item).key; })); + const selectedLifecycle = state.lifecycle; + const selectedModel = state.model; + const selectedStatus = state.status; + el.mapLifecycle.replaceChildren(option("", "All lifecycle states")); + lifecycle.forEach(function (value) { el.mapLifecycle.appendChild(option(value, words(value))); }); + el.mapModel.replaceChildren(option("", "All models")); + models.forEach(function (value) { el.mapModel.appendChild(option(value, value)); }); + el.mapStatus.replaceChildren(option("", "All operational states")); + statuses.forEach(function (value) { el.mapStatus.appendChild(option(value, words(value))); }); + if (lifecycle.includes(selectedLifecycle)) el.mapLifecycle.value = selectedLifecycle; + else state.lifecycle = ""; + if (models.includes(selectedModel)) el.mapModel.value = selectedModel; + else state.model = ""; + if (statuses.includes(selectedStatus)) el.mapStatus.value = selectedStatus; + else state.status = ""; + } + + function stateUrl() { + const params = new URLSearchParams(); + const values = { + site_id: state.siteId, room_id: state.roomId, + layer: state.layer === "status" ? "" : state.layer, + q: state.query, lifecycle: state.lifecycle, model: state.model, + repair: state.repair, status: state.status, miner_id: state.minerId + }; + for (const pair of Object.entries(values)) { + if (pair[1]) params.set(pair[0], pair[1]); + } + return window.location.pathname + + (params.size ? "?" + params.toString() : ""); + } + + function setUrl(mode) { + window.history[mode === "push" ? "pushState" : "replaceState"]( + {}, "", stateUrl() + ); + } + + function readUrl() { + const params = new URLSearchParams(window.location.search); + state.siteId = params.get("site_id") || ""; + state.roomId = params.get("room_id") || ""; + state.layer = ["status", "temperature", "hashrate", "power", "repair"] + .includes(params.get("layer")) ? params.get("layer") : "status"; + state.query = (params.get("q") || "").slice(0, 160); + state.lifecycle = params.get("lifecycle") || ""; + state.model = params.get("model") || ""; + state.repair = ["active", "none", "legacy"].includes(params.get("repair")) + ? params.get("repair") : ""; + state.status = params.get("status") || ""; + state.minerId = params.get("miner_id") || ""; + syncPermissionControls(); + el.mapLayer.value = state.layer; + el.mapSearch.value = state.query; + el.mapRepair.value = state.repair; + } + + function legend() { + const entries = !state.minerDetailsIncluded + ? [["restricted", "Miner details restricted"], ["empty", "Empty"], + ["conflict", "Conflict"], ["blocked", "Blocked"], ["switch", "Equipment"]] + : state.layer === "status" + ? [["online", "Online"], ["offline", "Offline"], ["unstable", "Hash unstable"], + ["repair", "In repair"], ["spare", "Spare"], ["retired", "Retired"], + ["unknown", "Unknown"], ["restricted", "Miner details restricted"], + ["empty", "Empty"], ["conflict", "Conflict"]] + : [["reading", "Current numeric reading"], ["unknown", "Unavailable"], + ["repair", "Repair marker remains visible"], ["empty", "Empty"]]; + el.mapLegend.replaceChildren(); + el.mapLegend.appendChild(create("strong", "", el.mapLayer.options[el.mapLayer.selectedIndex].text)); + for (const entry of entries) { + const item = create("span", "legend-item"); + const marker = create("span", "legend-marker", entry[0] === "online" ? "ON" : entry[0].slice(0, 1).toUpperCase()); + marker.dataset.status = entry[0]; + item.append(marker, document.createTextNode(entry[1])); + el.mapLegend.appendChild(item); + } + } + + function positionTile(position) { + const condition = operational(position); + const tile = create(position.occupied && position.miner_id ? "button" : "div", "map-position"); + tile.dataset.status = condition.key; + tile.dataset.positionId = position.location_position_id; + if (position.miner_id) tile.dataset.minerId = position.miner_id; + if (position.occupied && !matches(position)) tile.dataset.filtered = "true"; + if (String(position.miner_id) === state.minerId) tile.dataset.selected = "true"; + if (tile.tagName === "BUTTON") { + tile.type = "button"; + tile.tabIndex = -1; + tile.setAttribute("aria-pressed", String(String(position.miner_id) === state.minerId)); + tile.setAttribute("aria-label", display(position.position_label, "Position " + position.position_index) + + ", " + identity(position) + ", " + condition.label + ", " + age(position.observed_at)); + } else { + tile.setAttribute("role", "group"); + tile.setAttribute("aria-label", display(position.position_label, "Position " + position.position_index) + + ", " + condition.label); + } + const head = create("span", "position-head"); + head.append(create("strong", "position-label", display(position.position_label, "Position " + position.position_index)), + create("span", "status-marker", condition.mark)); + const name = create("span", "position-miner", position.occupied ? identity(position) : condition.label); + const value = create("span", "position-value", layerValue(position, condition)); + const freshness = create("span", "position-freshness", position.occupied + ? state.minerDetailsIncluded ? age(position.observed_at) : "Miner details restricted" + : "Physical slot"); + tile.append(head, name, value, freshness); + return tile; + } + + function renderMap() { + syncSelectors(); + const positions = state.data.positions.filter(function (position) { + return String(position.room_id || "") === state.roomId; + }); + if (state.minerId && !state.data.positions.some(function (position) { + const visible = String(position.room_id || "") === state.roomId || !position.room_id; + return visible && position.miner_id && String(position.miner_id) === state.minerId; + })) state.minerId = ""; + syncPermissionControls(); + syncFilterOptions(); + legend(); + el.mapViewport.replaceChildren(); + if (!state.data.sites.length) { + el.mapNotice.hidden = false; + el.mapNotice.textContent = "No sites are configured. Use location management to create the physical hierarchy."; + } else if (!state.roomId) { + el.mapNotice.hidden = false; + el.mapNotice.textContent = "This site has no configured rooms."; + } else if (!positions.length) { + el.mapNotice.hidden = false; + el.mapNotice.textContent = "No positions are configured for this room."; + } else { + el.mapNotice.hidden = true; + } + const racks = new Map(); + for (const position of positions) { + const rack = display(position.rack_name, "Unlabelled rack"); + const shelf = display(position.shelf_name, "Unlabelled shelf"); + if (!racks.has(rack)) racks.set(rack, new Map()); + if (!racks.get(rack).has(shelf)) racks.get(rack).set(shelf, []); + racks.get(rack).get(shelf).push(position); + } + const fragment = document.createDocumentFragment(); + for (const rackEntry of racks) { + const rack = create("section", "map-rack"); + rack.setAttribute("aria-label", "Rack " + rackEntry[0]); + rack.appendChild(create("h2", "rack-title", "Rack " + rackEntry[0])); + for (const shelfEntry of rackEntry[1]) { + const shelf = create("section", "map-shelf"); + shelf.setAttribute("aria-label", "Shelf " + shelfEntry[0]); + shelf.appendChild(create("h3", "shelf-title", "Shelf " + shelfEntry[0])); + const grid = create("div", "position-grid"); + for (const position of shelfEntry[1]) grid.appendChild(positionTile(position)); + shelf.appendChild(grid); + rack.appendChild(shelf); + } + fragment.appendChild(rack); + } + el.mapViewport.appendChild(fragment); + renderSummary(positions); + renderLegacyPositions(); + renderUnplaced(); + renderInspector(); + syncTileTabStops(); + const count = [state.query, state.lifecycle, state.model, state.repair, state.status].filter(Boolean).length; + el.mapFilterCount.textContent = count ? "(" + count + " active)" : ""; + el.mapFreshness.textContent = "Map generated " + dateTime(state.meta.generated_at) + + (state.minerDetailsIncluded ? ". Telemetry age is shown per miner." + : ". Miner details are restricted for this account."); + } + + function renderSummary(positions) { + const facts = { occupied: 0, empty: 0, conflict: 0, offline: 0, + repair: 0, unknown: 0, restricted: 0 }; + for (const position of positions) { + const condition = operational(position); + if (position.occupied) facts.occupied += 1; + if (condition.key in facts) facts[condition.key] += 1; + } + el.mapSummary.replaceChildren(); + const pairs = [["Positions", positions.length], ["Occupied", facts.occupied], + ["Empty", facts.empty], ["Conflicts", facts.conflict]]; + if (state.minerDetailsIncluded) pairs.splice(3, 0, + ["Offline", facts.offline], ["In repair", facts.repair], ["Unknown", facts.unknown]); + else pairs.splice(3, 0, ["Restricted", facts.restricted]); + for (const pair of pairs) { + const item = create("span", "summary-fact"); + item.append(create("strong", "", String(pair[1])), document.createTextNode(pair[0])); + el.mapSummary.appendChild(item); + } + } + + function renderLegacyPositions() { + const positions = state.data.positions.filter(function (position) { + return !position.room_id; + }); + el.mapLegacySection.hidden = !positions.length; + el.mapLegacyPositions.replaceChildren(); + for (const position of positions) { + const item = create("article", "legacy-position-entry"); + item.append(create("strong", "", display(position.position_label, + "Position " + position.position_index)), + create("span", "legacy-position-path", "Unscoped / Room label " + + display(position.room_name, "Unavailable") + " / Rack " + + display(position.rack_name, "Unavailable") + " / Shelf " + + display(position.shelf_name, "Unavailable")), + positionTile(position)); + el.mapLegacyPositions.appendChild(item); + } + } + + function renderUnplaced() { + const miners = state.data.virtual_miners.filter(function (miner) { + return (!state.siteId || String(miner.site_id || "") === state.siteId) && + (!state.roomId || !miner.room_id || String(miner.room_id) === state.roomId); + }); + el.mapUnplacedSection.hidden = !miners.length; + el.mapUnplaced.replaceChildren(); + for (const miner of miners) { + const item = create("div", "unplaced-miner"); + item.append(create("strong", "", display(miner.asset_id, display(miner.current_mac, "Unidentified miner"))), + create("span", "", words(miner.asset_state) + " / " + words(miner.lifecycle_status)), + create("span", "", miner.active_repair + ? "Active repair: " + words(miner.active_repair.status) + : "No active repair record")); + el.mapUnplaced.appendChild(item); + } + } + + function detailRow(term, value) { + el.mapInspectorDetails.append(create("dt", "", term), create("dd", "", display(value))); + } + + function renderInspector() { + const position = state.data.positions.find(function (item) { + return item.miner_id && String(item.miner_id) === state.minerId; + }); + if (!position) { + el.mapInspector.hidden = true; + return; + } + const condition = operational(position); + el.mapInspector.hidden = false; + el.mapInspectorTitle.textContent = identity(position); + el.mapInspectorStatus.textContent = condition.label + " / " + age(position.observed_at); + el.mapInspectorStatus.dataset.status = condition.key; + el.mapInspectorDetails.replaceChildren(); + detailRow("Asset ID", position.asset_id); + detailRow("Model", position.model || position.miner_type); + detailRow("Serial", position.miner_sn); + detailRow("Current MAC", position.current_mac); + detailRow("Current IP", position.current_ip); + const location = position.room_id + ? [position.site_name, position.room_name, "Rack " + position.rack_name, + "Shelf " + position.shelf_name, display(position.position_label, + "Position " + position.position_index)].join(" / ") + : ["Unscoped", "Room label " + display(position.room_name), + "Rack " + display(position.rack_name), "Shelf " + display(position.shelf_name), + display(position.position_label, "Position " + position.position_index)].join(" / "); + detailRow("Location", location); + detailRow("Lifecycle", words(position.lifecycle_status)); + detailRow("Runtime", words(position.miner_status)); + detailRow("Repair", position.active_repair + ? words(position.active_repair.status) + " / " + words(position.active_repair.reason) + : position.asset_state === "repair" ? "Legacy repair location state" : "No active repair"); + detailRow("Hashrate", metric(position.hashrate_ths, " TH/s")); + detailRow("Power", metric(position.power_w, " W")); + detailRow("Chip temperature", metric(position.chip_temp_max_c, " C max")); + detailRow("Observed", dateTime(position.observed_at)); + const returnTo = stateUrl(); + el.mapOpenMiner.href = "/miners/" + encodeURIComponent(position.miner_id) + + "/detail?return_to=" + encodeURIComponent(returnTo); + } + + function mapTiles() { + return Array.from(el.mapWorkspace.querySelectorAll("button.map-position[data-miner-id]")); + } + + function syncTileTabStops() { + const tiles = mapTiles(); + const current = tiles.find(function (tile) { + return tile.dataset.minerId === state.minerId; + }); + for (const tile of tiles) tile.tabIndex = tile === current ? 0 : -1; + if (!current && tiles.length) tiles[0].tabIndex = 0; + } + + function bind() { + const controls = [el.mapLayer, el.mapSearch, el.mapLifecycle, el.mapModel, el.mapRepair, el.mapStatus]; + el.mapSite.addEventListener("change", function () { + state.siteId = el.mapSite.value; state.roomId = ""; state.minerId = ""; + renderMap(); setUrl("push"); + }); + el.mapRoom.addEventListener("change", function () { + state.roomId = el.mapRoom.value; state.minerId = ""; renderMap(); setUrl("push"); + }); + for (const control of controls) { + control.addEventListener(control === el.mapSearch ? "input" : "change", function () { + state.layer = el.mapLayer.value; state.query = el.mapSearch.value.trim(); + state.lifecycle = el.mapLifecycle.value; state.model = el.mapModel.value; + state.repair = el.mapRepair.value; state.status = el.mapStatus.value; + renderMap(); setUrl("replace"); + }); + } + el.mapClearFilters.addEventListener("click", function () { + state.query = state.lifecycle = state.model = state.repair = state.status = ""; + el.mapSearch.value = ""; el.mapRepair.value = ""; renderMap(); setUrl("replace"); + }); + el.mapWorkspace.addEventListener("click", function (event) { + const tile = event.target.closest("button[data-miner-id]"); + if (!tile || !el.mapWorkspace.contains(tile)) return; + state.minerId = tile.dataset.minerId; + renderMap(); setUrl("push"); + el.mapInspectorClose.focus(); + }); + el.mapWorkspace.addEventListener("keydown", function (event) { + if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return; + const tile = event.target.closest("button.map-position[data-miner-id]"); + if (!tile) return; + const tiles = mapTiles(); + const current = tiles.indexOf(tile); + const delta = ["ArrowLeft", "ArrowUp"].includes(event.key) ? -1 : 1; + const next = tiles[(current + delta + tiles.length) % tiles.length]; + event.preventDefault(); + for (const item of tiles) item.tabIndex = item === next ? 0 : -1; + next.focus(); + }); + el.mapInspectorClose.addEventListener("click", function () { + const restoreId = state.minerId; state.minerId = ""; renderMap(); setUrl("push"); + const restore = mapTiles().find(function (tile) { + return tile.dataset.minerId === restoreId; + }); + if (restore) { restore.tabIndex = 0; restore.focus(); } + }); + el.mapRetry.addEventListener("click", load); + window.addEventListener("popstate", function () { + readUrl(); renderMap(); setUrl("replace"); + }); + } + + function validate(envelope) { + return envelope && envelope.data && envelope.meta && + Array.isArray(envelope.data.sites) && Array.isArray(envelope.data.rooms) && + Array.isArray(envelope.data.positions) && Array.isArray(envelope.data.virtual_miners) && + envelope.data.positions.every(function (item) { + return item && typeof item.location_position_id === "string" && + typeof item.position_index === "number"; + }); + } + + async function load() { + el.mapLoading.hidden = false; + el.mapCriticalState.hidden = true; + el.mapWorkspace.hidden = true; + const controller = new AbortController(); + const timer = window.setTimeout(function () { controller.abort(); }, 15000); + try { + const response = await fetch("/api/location/map", { + credentials: "same-origin", headers: { Accept: "application/json" }, + signal: controller.signal + }); + if (response.status === 401) { + window.location.replace("/login?next=" + encodeURIComponent(window.location.pathname + window.location.search)); + return; + } + if (response.status === 403) { + el.mapCriticalTitle.textContent = "Map access unavailable"; + el.mapCriticalDetail.textContent = "Your account cannot view location data."; + el.mapCriticalState.hidden = false; + return; + } + if (!response.ok) throw new Error("The map service returned " + response.status + "."); + const envelope = await response.json(); + if (!validate(envelope)) throw new Error("The map response was incomplete."); + state.data = envelope.data; state.meta = envelope.meta; + state.minerDetailsIncluded = envelope.meta.miner_details_included === true; + readUrl(); syncSelectors(); + el.mapWorkspace.hidden = false; + renderMap(); setUrl("replace"); + } catch (error) { + el.mapCriticalTitle.textContent = "Map unavailable"; + el.mapCriticalDetail.textContent = error && error.name === "AbortError" + ? "MinerOps took too long to respond. Retry in a moment." + : display(error && error.message, "The map could not be loaded."); + el.mapCriticalState.hidden = false; + } finally { + window.clearTimeout(timer); el.mapLoading.hidden = true; + } + } + + bind(); + load(); +}()); diff --git a/backend/app/frontend/views.py b/backend/app/frontend/views.py index 42ef0a6..6eaab9b 100644 --- a/backend/app/frontend/views.py +++ b/backend/app/frontend/views.py @@ -79,48 +79,93 @@ def _dashboard_content() -> str: """ -def _map_content() -> str: - return """ -