From 60a831950cce0374d8effdd0003cde4cdbaf3cbe Mon Sep 17 00:00:00 2001 From: JC Date: Mon, 10 Aug 2026 19:55:20 +0000 Subject: [PATCH] feat: add Mine Map v1 --- backend/app/frontend/router.py | 9 + backend/app/frontend/static/app.css | 368 ++++++++++++++ backend/app/frontend/static/mine-map.js | 629 ++++++++++++++++++++++++ backend/app/frontend/views.py | 137 ++++-- backend/app/locations/__init__.py | 7 + backend/app/locations/dependencies.py | 8 + backend/app/locations/models.py | 118 +++++ backend/app/locations/repository.py | 203 ++++++++ backend/app/locations/router.py | 30 ++ backend/app/locations/service.py | 87 ++++ backend/app/main.py | 11 +- backend/tests/test_frontend_shell.py | 24 +- backend/tests/test_mine_map.py | 356 ++++++++++++++ docs/design/MINE_MAP.md | 174 +++---- scripts/test-dev-baseline.sh | 5 + scripts/test-mine-map.sh | 24 + 16 files changed, 2027 insertions(+), 163 deletions(-) create mode 100644 backend/app/frontend/static/mine-map.js create mode 100644 backend/app/locations/__init__.py create mode 100644 backend/app/locations/dependencies.py create mode 100644 backend/app/locations/models.py create mode 100644 backend/app/locations/repository.py create mode 100644 backend/app/locations/router.py create mode 100644 backend/app/locations/service.py create mode 100644 backend/tests/test_mine_map.py create mode 100755 scripts/test-mine-map.sh 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 """ -
Live layout and telemetry data are not -connected to this Internal Alpha canvas. No production state is represented -below.
-
- -
-
- Operational canvas - Site, room, rack, miner, and status overlays will render here - when authoritative layout and telemetry sources are connected. +def _map_content(can_manage_locations: bool = False) -> str: + manage_link = ( + 'Manage locations' + if can_manage_locations + else "" + ) + return f""" +
+
+
+

Map context

+

Select a site and room.

+ {manage_link}
-
+
+
+
Waiting for map data
+
+ +
+ Loading Mine Map + Requesting the current physical layout and operational observations. +
+ """ @@ -242,7 +287,16 @@ def render_app_page( if can_view_miners else '' ) - content = CONTENT[view]() + content = ( + _map_content(has_permission("locations.manage")) + if view == "map" + else CONTENT[view]() + ) + map_script = ( + '' + if view == "map" + else "" + ) return f""" @@ -297,5 +351,6 @@ def render_app_page(
+ {map_script} """ diff --git a/backend/app/locations/__init__.py b/backend/app/locations/__init__.py new file mode 100644 index 0000000..4ebae0d --- /dev/null +++ b/backend/app/locations/__init__.py @@ -0,0 +1,7 @@ +"""Mine Map location read domain.""" + +from app.locations.models import MineMapResponse +from app.locations.repository import LocationMapRepository +from app.locations.service import LocationMapService + +__all__ = ["LocationMapRepository", "LocationMapService", "MineMapResponse"] diff --git a/backend/app/locations/dependencies.py b/backend/app/locations/dependencies.py new file mode 100644 index 0000000..7d85b5a --- /dev/null +++ b/backend/app/locations/dependencies.py @@ -0,0 +1,8 @@ +"""Dependency construction for the location read domain.""" + +from app.locations.repository import LocationMapRepository +from app.locations.service import LocationMapService + + +def get_location_map_service() -> LocationMapService: + return LocationMapService(LocationMapRepository()) diff --git a/backend/app/locations/models.py b/backend/app/locations/models.py new file mode 100644 index 0000000..934b55e --- /dev/null +++ b/backend/app/locations/models.py @@ -0,0 +1,118 @@ +"""Typed contracts for the read-only Mine Map.""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + + +class MapSite(BaseModel): + id: UUID + name: str + + +class MapRoom(BaseModel): + id: UUID + site_id: UUID + site_name: str + name: str + sort_order: int + + +class MapActiveRepair(BaseModel): + id: UUID + status: str + reason: str + + +class MapPosition(BaseModel): + location_position_id: UUID + site_id: UUID | None = None + site_name: str | None = None + room_id: UUID | None = None + room_name: str + rack_name: str + shelf_name: str + shelf_index: int | None = None + position_index: int + position_label: str | None = None + position_kind: str + position_status: str + occupied: bool + expected_mac: str | None = None + equipment_label: str | None = None + notes: str | None = None + source_file: str | None = None + source_cell: str | None = None + conflict_reason: str | None = None + miner_id: UUID | None = None + asset_id: str | None = None + current_mac: str | None = None + current_ip: str | None = None + miner_status: str | None = None + hostname: str | None = None + miner_sn: str | None = None + power_sn: str | None = None + model: str | None = None + miner_type: str | None = None + firmware_version: str | None = None + lifecycle_status: str | None = None + last_seen_at: datetime | None = None + asset_state: str | None = None + observed_at: datetime | None = None + hashrate_ths: float | None = None + power_w: float | None = None + chip_temp_max_c: float | None = None + chip_temp_avg_c: float | None = None + hash_stable: bool | None = None + pool_rejected_percent: float | None = None + active_repair: MapActiveRepair | None = None + + +class MapVirtualMiner(BaseModel): + miner_id: UUID | None = None + site_id: UUID | None = None + room_id: UUID | None = None + asset_id: str | None = None + asset_state: str + current_mac: str | None = None + current_ip: str | None = None + miner_status: str | None = None + status: str | None = None + hostname: str | None = None + miner_sn: str | None = None + power_sn: str | None = None + model: str | None = None + miner_type: str | None = None + firmware_version: str | None = None + lifecycle_status: str | None = None + last_seen_at: datetime | None = None + observed_at: datetime | None = None + hashrate_ths: float | None = None + power_w: float | None = None + chip_temp_max_c: float | None = None + chip_temp_avg_c: float | None = None + hash_stable: bool | None = None + active_repair: MapActiveRepair | None = None + + +class MineMapData(BaseModel): + sites: list[MapSite] + rooms: list[MapRoom] + positions: list[MapPosition] + virtual_miners: list[MapVirtualMiner] + + +class MineMapMeta(BaseModel): + generated_at: datetime + site_id: UUID | None = None + room_id: UUID | None = None + position_count: int + virtual_miner_count: int + miner_details_included: bool + + +class MineMapResponse(BaseModel): + data: MineMapData + meta: MineMapMeta + error: None = None diff --git a/backend/app/locations/repository.py b/backend/app/locations/repository.py new file mode 100644 index 0000000..e9681fb --- /dev/null +++ b/backend/app/locations/repository.py @@ -0,0 +1,203 @@ +"""Set-based PostgreSQL reads for Mine Map v1.""" + +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from typing import Any +from uuid import UUID + +import psycopg +from psycopg.rows import dict_row + +from app.config import settings + + +ConnectionFactory = Callable[[], psycopg.Connection] +ACTIVE_REPAIR_STATUSES = ( + "OPEN", + "IN_PROGRESS", + "WAITING_FOR_PARTS", + "READY_FOR_TEST", +) + + +def default_connection_factory() -> psycopg.Connection: + return psycopg.connect( + host=settings.postgres_host, + port=settings.postgres_port, + dbname=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + connect_timeout=5, + ) + + +LATEST_TELEMETRY_SQL = """ + LEFT JOIN LATERAL ( + SELECT snapshots.observed_at, + ROUND((snapshots.mhs_av / 1000000.0)::numeric, 2)::float + AS hashrate_ths, + ROUND(snapshots.power_w::numeric, 2)::float AS power_w, + ROUND(snapshots.chip_temp_max_c::numeric, 2)::float + AS chip_temp_max_c, + ROUND(snapshots.chip_temp_avg_c::numeric, 2)::float + AS chip_temp_avg_c, + snapshots.hash_stable, snapshots.pool_rejected_percent + FROM miner_status_snapshots snapshots + WHERE snapshots.miner_id = miners.id + ORDER BY snapshots.observed_at DESC, snapshots.id DESC + LIMIT 1 + ) latest ON true +""" + +ACTIVE_REPAIR_SQL = """ + LEFT JOIN LATERAL ( + SELECT repairs.id, repairs.status, repairs.reason + FROM repairs + WHERE repairs.miner_id = miners.id AND repairs.status = ANY(%s) + ORDER BY repairs.opened_at, repairs.id + LIMIT 1 + ) active_repair ON true +""" + + +class LocationMapRepository: + """Own all persistence used by the Mine Map read model.""" + + def __init__( + self, + connection_factory: ConnectionFactory | None = None, + *, + connection: psycopg.Connection | None = None, + ): + self._connection_factory = connection_factory or default_connection_factory + self._connection = connection + + @contextmanager + def _connection_scope(self) -> Iterator[psycopg.Connection]: + if self._connection is not None: + yield self._connection + return + connection = self._connection_factory() + try: + yield connection + finally: + connection.close() + + def read_map( + self, + *, + site_id: UUID | None = None, + room_id: UUID | None = None, + ) -> dict[str, list[dict[str, Any]]]: + position_where = [] + position_params: list[Any] = [list(ACTIVE_REPAIR_STATUSES)] + virtual_where = [ + "COALESCE(mls.asset_state, 'waiting_placement') = ANY(%s)", + "NOT EXISTS (SELECT 1 FROM location_positions assigned " + "WHERE assigned.miner_id = miners.id)", + ] + virtual_params: list[Any] = [ + list(ACTIVE_REPAIR_STATUSES), + ["waiting_placement", "storage", "repair", "decommissioned"], + ] + if site_id is not None: + position_where.append("rooms.site_id = %s") + position_params.append(site_id) + virtual_where.append("miners.site_id = %s") + virtual_params.append(site_id) + if room_id is not None: + position_where.append("lp.room_id = %s") + position_params.append(room_id) + virtual_where.append("miners.room_id = %s") + virtual_params.append(room_id) + position_filter = ( + "WHERE " + " AND ".join(position_where) if position_where else "" + ) + virtual_filter = "WHERE " + " AND ".join(virtual_where) + + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute("SELECT id, name FROM sites ORDER BY lower(name), id") + sites = [dict(row) for row in cur.fetchall()] + cur.execute( + """ + SELECT rooms.id, rooms.site_id, sites.name AS site_name, + rooms.name, rooms.sort_order + FROM rooms JOIN sites ON sites.id = rooms.site_id + ORDER BY lower(sites.name), rooms.sort_order, + lower(rooms.name), rooms.id + """ + ) + rooms = [dict(row) for row in cur.fetchall()] + cur.execute( + f""" + SELECT lp.id AS location_position_id, sites.id AS site_id, + sites.name AS site_name, rooms.id AS room_id, + COALESCE(rooms.name, lp.room_name) AS room_name, + lp.rack_name, lp.shelf_name, lp.shelf_index, + lp.position_index, lp.position_label, lp.position_kind, + lp.position_status, (miners.id IS NOT NULL) AS occupied, + lp.expected_mac, lp.equipment_label, lp.notes, + lp.source_file, lp.source_cell, lp.conflict_reason, + miners.id AS miner_id, miners.asset_tag AS asset_id, + miners.current_mac, host(miners.current_ip) AS current_ip, + miners.status AS miner_status, miners.hostname, + miners.miner_sn, miners.power_sn, miners.model, + miners.miner_type, miners.firmware_version, + miners.lifecycle_status, miners.last_seen_at, + COALESCE(mls.asset_state, 'unassigned') AS asset_state, + latest.observed_at, latest.hashrate_ths, latest.power_w, + latest.chip_temp_max_c, latest.chip_temp_avg_c, + latest.hash_stable, latest.pool_rejected_percent, + active_repair.id AS active_repair_id, + active_repair.status AS active_repair_status, + active_repair.reason AS active_repair_reason + FROM location_positions lp + LEFT JOIN rooms ON rooms.id = lp.room_id + LEFT JOIN sites ON sites.id = rooms.site_id + LEFT JOIN miners ON miners.id = lp.miner_id + LEFT JOIN miner_location_states mls ON mls.miner_id = miners.id + {LATEST_TELEMETRY_SQL} + {ACTIVE_REPAIR_SQL} + {position_filter} + ORDER BY lower(sites.name) NULLS LAST, + rooms.sort_order NULLS LAST, + lower(COALESCE(rooms.name, lp.room_name)), + lower(lp.rack_name), lp.shelf_index NULLS LAST, + lower(lp.shelf_name), lp.position_index, lp.id + """, + position_params, + ) + positions = [dict(row) for row in cur.fetchall()] + cur.execute( + f""" + SELECT miners.id AS miner_id, miners.site_id, miners.room_id, + miners.asset_tag AS asset_id, + COALESCE(mls.asset_state, 'waiting_placement') AS asset_state, + miners.current_mac, host(miners.current_ip) AS current_ip, + miners.status AS miner_status, miners.status, miners.hostname, + miners.miner_sn, miners.power_sn, miners.model, + miners.miner_type, miners.firmware_version, + miners.lifecycle_status, miners.last_seen_at, + latest.observed_at, latest.hashrate_ths, latest.power_w, + latest.chip_temp_max_c, latest.chip_temp_avg_c, + latest.hash_stable, + active_repair.id AS active_repair_id, + active_repair.status AS active_repair_status, + active_repair.reason AS active_repair_reason + FROM miners + LEFT JOIN miner_location_states mls ON mls.miner_id = miners.id + {LATEST_TELEMETRY_SQL} + {ACTIVE_REPAIR_SQL} + {virtual_filter} + ORDER BY asset_state, lower(miners.asset_tag), miners.id + """, + virtual_params, + ) + virtual_miners = [dict(row) for row in cur.fetchall()] + return { + "sites": sites, + "rooms": rooms, + "positions": positions, + "virtual_miners": virtual_miners, + } diff --git a/backend/app/locations/router.py b/backend/app/locations/router.py new file mode 100644 index 0000000..625f89d --- /dev/null +++ b/backend/app/locations/router.py @@ -0,0 +1,30 @@ +"""HTTP boundary for the read-only Mine Map API.""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Query + +from app.identity import require_permission, user_has_permission +from app.locations.dependencies import get_location_map_service +from app.locations.models import MineMapResponse +from app.locations.service import LocationMapService + + +router = APIRouter(prefix="/api/location", tags=["locations"]) +MapUser = Annotated[dict, Depends(require_permission("locations.view"))] +MapService = Annotated[LocationMapService, Depends(get_location_map_service)] + + +@router.get("/map", response_model=MineMapResponse) +def get_mine_map( + current_user: MapUser, + service: MapService, + site_id: Annotated[UUID | None, Query()] = None, + room_id: Annotated[UUID | None, Query()] = None, +) -> MineMapResponse: + return service.get_map( + site_id=site_id, + room_id=room_id, + include_miner_details=user_has_permission(current_user, "miners.view"), + ) diff --git a/backend/app/locations/service.py b/backend/app/locations/service.py new file mode 100644 index 0000000..6c80028 --- /dev/null +++ b/backend/app/locations/service.py @@ -0,0 +1,87 @@ +"""Mine Map read-model composition independent of HTTP.""" + +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from app.locations.models import ( + MapActiveRepair, + MapPosition, + MapRoom, + MapSite, + MapVirtualMiner, + MineMapData, + MineMapMeta, + MineMapResponse, +) +from app.locations.repository import LocationMapRepository + + +MINER_FIELDS = { + "miner_id", "asset_id", "current_mac", "expected_mac", "current_ip", "miner_status", + "status", + "hostname", "miner_sn", "power_sn", "model", "miner_type", + "firmware_version", "lifecycle_status", "last_seen_at", "asset_state", + "observed_at", "hashrate_ths", "power_w", "chip_temp_max_c", + "chip_temp_avg_c", "hash_stable", "pool_rejected_percent", +} + + +class LocationMapService: + def __init__(self, repository: LocationMapRepository): + self.repository = repository + + @staticmethod + def _repair(row: dict[str, Any]) -> MapActiveRepair | None: + repair_id = row.pop("active_repair_id", None) + status = row.pop("active_repair_status", None) + reason = row.pop("active_repair_reason", None) + if repair_id is None: + return None + return MapActiveRepair(id=repair_id, status=status, reason=reason) + + @classmethod + def _redact(cls, row: dict[str, Any]) -> dict[str, Any]: + redacted = dict(row) + for field in MINER_FIELDS: + redacted[field] = None + redacted["active_repair_id"] = None + redacted["active_repair_status"] = None + redacted["active_repair_reason"] = None + return redacted + + def get_map( + self, + *, + site_id: UUID | None = None, + room_id: UUID | None = None, + include_miner_details: bool, + ) -> MineMapResponse: + rows = self.repository.read_map(site_id=site_id, room_id=room_id) + positions = [] + for source in rows["positions"]: + row = dict(source) if include_miner_details else self._redact(source) + row["active_repair"] = self._repair(row) + positions.append(MapPosition.model_validate(row)) + virtual_miners = [] + if include_miner_details: + for source in rows["virtual_miners"]: + row = dict(source) + row["active_repair"] = self._repair(row) + virtual_miners.append(MapVirtualMiner.model_validate(row)) + return MineMapResponse( + data=MineMapData( + sites=[MapSite.model_validate(row) for row in rows["sites"]], + rooms=[MapRoom.model_validate(row) for row in rows["rooms"]], + positions=positions, + virtual_miners=virtual_miners, + ), + meta=MineMapMeta( + generated_at=datetime.now(timezone.utc), + site_id=site_id, + room_id=room_id, + position_count=len(positions), + virtual_miner_count=len(virtual_miners), + miner_details_included=include_miner_details, + ), + ) diff --git a/backend/app/main.py b/backend/app/main.py index 074c658..5be2b39 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,10 +16,12 @@ from app.identity import ( SESSION_COOKIE, get_current_user, install_identity_middleware, + require_permission, router as identity_router, ) from app.frontend.router import router as frontend_router from app.inventory.router import router as inventory_router +from app.locations.router import router as locations_router from app.components.router import router as components_router from app.repairs.exceptions import ( ControlBoardReplacementActorRequired, @@ -45,6 +47,7 @@ app.include_router(identity_router) app.include_router(frontend_router) app.include_router(inventory_router) app.include_router(components_router) +app.include_router(locations_router) install_identity_middleware(app) @@ -1873,7 +1876,10 @@ def list_miner_hashboards(miner_id: str, current_user: dict = Depends(get_curren @app.get("/api/miners/{miner_id}/status-snapshots") -def list_miner_status_snapshots(miner_id: str, current_user: dict = Depends(get_current_user)): +def list_miner_status_snapshots( + miner_id: str, + current_user: dict = Depends(require_permission("miners.view")), +): conn = None try: conn = get_db_connection() @@ -16482,8 +16488,7 @@ def list_location_import_issues( conn.close() -@app.get("/api/location/map") -def get_location_map( +def get_legacy_location_map( current_user: dict = Depends(get_current_user), ): ensure_location_tables() diff --git a/backend/tests/test_frontend_shell.py b/backend/tests/test_frontend_shell.py index 742b211..3cad337 100644 --- a/backend/tests/test_frontend_shell.py +++ b/backend/tests/test_frontend_shell.py @@ -138,25 +138,24 @@ class FrontendShellTests(unittest.TestCase): self.assertIn("No events are fabricated", response.text) self.assertNotIn("100 TH/s", response.text) - def test_map_is_a_structured_honest_placeholder(self): + def test_map_is_a_structured_operational_view(self): self.authenticate() response = self.client.get("/monitoring/map") for label in ( "Site", "Room", - "Rack", - "Status layer", - "Selection details", + "Operational Status", + "Miner details", + "Find miner", ): self.assertIn(label, response.text) - self.assertIn( - "Live layout and telemetry data are not", - response.text, - ) - self.assertIn( - "connected to this Internal Alpha canvas", - response.text, - ) + for value in ( + 'id="mapSite"', 'id="mapRoom"', 'id="mapLayer"', + 'id="mapViewport"', 'id="mapInspector"', + '/assets/minerops-mine-map.js', + ): + self.assertIn(value, response.text) + self.assertNotIn("Live layout and telemetry data are not", response.text) def test_data_views_include_all_request_states(self): self.authenticate() @@ -336,6 +335,7 @@ class FrontendShellTests(unittest.TestCase): for path in ( "/assets/minerops-app.css", "/assets/minerops-app.js", + "/assets/minerops-mine-map.js", ): with self.subTest(path=path): response = self.client.get(path) diff --git a/backend/tests/test_mine_map.py b/backend/tests/test_mine_map.py new file mode 100644 index 0000000..096510d --- /dev/null +++ b/backend/tests/test_mine_map.py @@ -0,0 +1,356 @@ +import ast +import unittest +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import patch +from uuid import UUID, uuid4 + +from fastapi import FastAPI +import psycopg +from fastapi.testclient import TestClient + +from app.config import settings +from app.identity import get_current_user +from app.locations.dependencies import get_location_map_service +from app.locations.repository import LocationMapRepository +from app.locations.router import router +from app.locations.service import LocationMapService + + +NOW = datetime(2026, 8, 10, 12, 0, tzinfo=timezone.utc) +STATIC_ROOT = Path(__file__).parents[1] / "app" / "frontend" / "static" + + +def map_rows(): + site_a, site_b, room_a, room_b = uuid4(), uuid4(), uuid4(), uuid4() + miner_id, position_id, repair_id = uuid4(), uuid4(), uuid4() + return { + "sites": [{"id": site_a, "name": "North"}, {"id": site_b, "name": "South"}], + "rooms": [ + {"id": room_a, "site_id": site_a, "site_name": "North", "name": "Room 1", "sort_order": 1}, + {"id": room_b, "site_id": site_b, "site_name": "South", "name": "Room 1", "sort_order": 1}, + ], + "positions": [{ + "location_position_id": position_id, "site_id": site_a, + "site_name": "North", "room_id": room_a, "room_name": "Room 1", + "rack_name": "A", "shelf_name": "Top", "shelf_index": 1, + "position_index": 1, "position_label": "A-1", "position_kind": "miner_slot", + "position_status": "occupied", "occupied": True, + "expected_mac": "de:ad:be:ef:00:01", + "equipment_label": None, "notes": None, "source_file": None, + "source_cell": None, "conflict_reason": None, "miner_id": miner_id, + "asset_id": '', "current_mac": "aa:bb:cc:dd:ee:ff", + "current_ip": "192.0.2.10", "miner_status": "online", "hostname": "miner-a", + "miner_sn": "SERIAL-1", "power_sn": None, "model": "M50", + "miner_type": "WhatsMiner", "firmware_version": "1.0", + "lifecycle_status": "in_repair", "last_seen_at": NOW, + "asset_state": "shelved", "observed_at": NOW, "hashrate_ths": 120.5, + "power_w": 3320.0, "chip_temp_max_c": 71.0, "chip_temp_avg_c": 66.0, + "hash_stable": True, "pool_rejected_percent": 0.2, + "active_repair_id": repair_id, "active_repair_status": "OPEN", + "active_repair_reason": "CONTROL_BOARD", + }, { + "location_position_id": uuid4(), "site_id": site_a, "site_name": "North", + "room_id": room_a, "room_name": "Room 1", "rack_name": "A", + "shelf_name": "Top", "shelf_index": 1, "position_index": 2, + "position_label": "A-2", "position_kind": "miner_slot", + "position_status": "available", "occupied": False, + "active_repair_id": None, "active_repair_status": None, + "active_repair_reason": None, + }], + "virtual_miners": [], + } + + +class FakeLocationRepository: + def __init__(self): + self.rows = map_rows() + self.calls = [] + + def read_map(self, *, site_id=None, room_id=None): + self.calls.append((site_id, room_id)) + return self.rows + + +class MineMapServiceTests(unittest.TestCase): + def test_service_builds_typed_bulk_map_with_repair_and_empty_position(self): + repository = FakeLocationRepository() + response = LocationMapService(repository).get_map(include_miner_details=True) + self.assertEqual(len(repository.calls), 1) + self.assertEqual(response.data.rooms[0].name, response.data.rooms[1].name) + self.assertNotEqual(response.data.rooms[0].site_id, response.data.rooms[1].site_id) + self.assertTrue(response.data.positions[0].occupied) + self.assertEqual(response.data.positions[0].active_repair.status, "OPEN") + self.assertFalse(response.data.positions[1].occupied) + self.assertEqual(response.data.positions[0].hashrate_ths, 120.5) + + def test_location_only_user_receives_layout_without_miner_details(self): + response = LocationMapService(FakeLocationRepository()).get_map( + include_miner_details=False + ) + position = response.data.positions[0] + self.assertTrue(position.occupied) + self.assertIsNone(position.miner_id) + self.assertIsNone(position.asset_id) + self.assertIsNone(position.expected_mac) + for field in ("current_mac", "current_ip", "miner_status", "hostname", + "miner_sn", "power_sn", "model", "miner_type", + "firmware_version", "lifecycle_status", "last_seen_at", + "asset_state", "observed_at", "hashrate_ths", "power_w", + "chip_temp_max_c", "chip_temp_avg_c", "hash_stable", + "pool_rejected_percent"): + self.assertIsNone(getattr(position, field), field) + self.assertIsNone(position.active_repair) + self.assertEqual(response.data.virtual_miners, []) + self.assertFalse(response.meta.miner_details_included) + + + def test_service_preserves_legacy_position_without_assigning_durable_scope(self): + repository = FakeLocationRepository() + legacy = dict(repository.rows["positions"][1]) + legacy.update({ + "location_position_id": uuid4(), "site_id": None, "site_name": None, + "room_id": None, "room_name": "Imported Room", "rack_name": "A", + "shelf_name": "Top", "position_index": 3, + }) + repository.rows["positions"].append(legacy) + response = LocationMapService(repository).get_map(include_miner_details=True) + position = response.data.positions[-1] + self.assertIsNone(position.site_id) + self.assertIsNone(position.room_id) + self.assertEqual(position.room_name, "Imported Room") + + +class StubMapService: + def __init__(self): + self.repository = FakeLocationRepository() + self.service = LocationMapService(self.repository) + self.include = None + + def get_map(self, **kwargs): + self.include = kwargs["include_miner_details"] + return self.service.get_map(**kwargs) + + +class MineMapRouterTests(unittest.TestCase): + def setUp(self): + self.service = StubMapService() + self.app = FastAPI() + self.app.include_router(router) + self.app.dependency_overrides[get_location_map_service] = lambda: self.service + self.client = TestClient(self.app) + + def tearDown(self): + self.client.close() + + def authenticate(self, permissions): + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(uuid4()), "role": "Custom", "permissions": list(permissions) + } + + def test_map_requires_authentication_and_locations_permission(self): + self.assertEqual(self.client.get("/api/location/map").status_code, 401) + self.authenticate(("miners.view",)) + self.assertEqual(self.client.get("/api/location/map").status_code, 403) + + def test_map_returns_typed_envelope_and_permission_aware_miner_shape(self): + self.authenticate(("locations.view", "miners.view")) + response = self.client.get("/api/location/map") + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["meta"]["miner_details_included"]) + self.assertEqual(response.json()["data"]["positions"][0]["asset_id"], + '') + self.assertIsNone(response.json()["error"]) + self.authenticate(("locations.view",)) + response = self.client.get("/api/location/map") + redacted = response.json()["data"]["positions"][0] + self.assertIsNone(redacted["miner_id"]) + self.assertIsNone(redacted["expected_mac"]) + self.assertIsNone(redacted["active_repair"]) + self.assertEqual(response.json()["data"]["virtual_miners"], []) + self.assertFalse(response.json()["meta"]["miner_details_included"]) + + def test_uuid_filters_are_validated_and_forwarded(self): + self.authenticate(("locations.view", "miners.view")) + site_id, room_id = uuid4(), uuid4() + response = self.client.get( + "/api/location/map", params={"site_id": site_id, "room_id": room_id} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(self.service.repository.calls[-1], (site_id, room_id)) + self.assertEqual(self.client.get("/api/location/map?site_id=bad").status_code, 422) + + def test_router_has_no_sql_or_database_driver_imports(self): + path = Path(__file__).parents[1] / "app" / "locations" / "router.py" + source = path.read_text(encoding="utf-8") + modules = [] + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Import): modules.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: modules.append(node.module) + self.assertFalse(any(name.startswith(("psycopg", "sqlalchemy", "asyncpg")) for name in modules)) + for keyword in ("SELECT ", "INSERT ", "UPDATE ", "DELETE "): + self.assertNotIn(keyword, source.upper()) + + +class MineMapRepositoryIntegrationTests(unittest.TestCase): + def test_set_based_query_scopes_same_named_rooms_by_uuid_and_site(self): + connection = psycopg.connect( + host=settings.postgres_host, + port=settings.postgres_port, + dbname=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + ) + try: + repository = LocationMapRepository(connection=connection) + site_a, site_b, room_a, room_b = uuid4(), uuid4(), uuid4(), uuid4() + miner_id = uuid4() + room_name = "Map Same Name " + uuid4().hex[:8] + with connection.cursor() as cur: + cur.execute( + "INSERT INTO sites (id, name) VALUES (%s, %s), (%s, %s)", + (site_a, "Map North " + uuid4().hex[:8], + site_b, "Map South " + uuid4().hex[:8]), + ) + cur.execute( + "INSERT INTO rooms (id, site_id, name, sort_order) " + "VALUES (%s, %s, %s, 1), (%s, %s, %s, 1)", + (room_a, site_a, room_name, room_b, site_b, room_name), + ) + cur.execute( + "INSERT INTO miners (id, site_id, room_id, asset_tag, status, " + "lifecycle_status) VALUES (%s, %s, %s, %s, 'online', 'active')", + (miner_id, site_a, room_a, "MAP-" + uuid4().hex[:8]), + ) + cur.execute( + "INSERT INTO location_positions " + "(room_id, room_name, rack_name, shelf_name, shelf_index, " + "position_index, position_kind, position_status, miner_id) " + "VALUES (%s, %s, 'A', '1', 1, 1, 'miner_slot', 'occupied', %s), " + "(%s, %s, 'B', '1', 1, 1, 'miner_slot', 'available', NULL)", + (room_a, room_name, miner_id, room_b, room_name), + ) + cur.execute( + "INSERT INTO miner_status_snapshots " + "(miner_id, mhs_av, power_w, chip_temp_max_c, hash_stable) " + "VALUES (%s, 120000000, 3300, 70, true)", + (miner_id,), + ) + result = repository.read_map(site_id=site_a, room_id=room_a) + self.assertEqual(len(result["positions"]), 1) + row = result["positions"][0] + self.assertEqual(row["site_id"], site_a) + self.assertEqual(row["room_id"], room_a) + self.assertTrue(row["occupied"]) + self.assertEqual(row["hashrate_ths"], 120.0) + matching_rooms = [room for room in result["rooms"] if room["name"] == room_name] + self.assertEqual({room["site_id"] for room in matching_rooms}, {site_a, site_b}) + finally: + connection.rollback() + connection.close() + + +class DummyCursor: + def __enter__(self): return self + def __exit__(self, *args): return False + def execute(self, *args): return None + def fetchall(self): return [] + + +class DummyConnection: + def cursor(self, **kwargs): return DummyCursor() + def close(self): return None + + +class StatusSnapshotPermissionTests(unittest.TestCase): + def setUp(self): + from app.main import list_miner_status_snapshots + self.app = FastAPI() + self.app.get("/api/miners/{miner_id}/status-snapshots")( + list_miner_status_snapshots + ) + self.client = TestClient(self.app) + + def tearDown(self): + self.app.dependency_overrides.pop(get_current_user, None) + self.client.close() + + def test_status_snapshots_require_authentication_permission_and_allow_reader(self): + miner_id = uuid4() + self.assertEqual(self.client.get(f"/api/miners/{miner_id}/status-snapshots").status_code, 401) + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(uuid4()), "role": "Custom", "permissions": ["locations.view"] + } + self.assertEqual(self.client.get(f"/api/miners/{miner_id}/status-snapshots").status_code, 403) + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(uuid4()), "role": "Custom", "permissions": ["miners.view"] + } + with patch("app.main.get_db_connection", return_value=DummyConnection()): + response = self.client.get(f"/api/miners/{miner_id}/status-snapshots") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"], []) + + def test_established_roles_and_custom_roles_follow_miners_view_permission(self): + miner_id = uuid4() + allowed = (("Admin", []), ("Manager", ["miners.view"]), + ("Technician", ["miners.view"]), ("Read Only", ["miners.view"]), + ("Custom", ["miners.view"])) + with patch("app.main.get_db_connection", return_value=DummyConnection()): + for role, permissions in allowed: + with self.subTest(role=role): + self.app.dependency_overrides[get_current_user] = lambda r=role, p=permissions: { + "id": str(uuid4()), "role": r, "permissions": p + } + response = self.client.get(f"/api/miners/{miner_id}/status-snapshots") + self.assertEqual(response.status_code, 200) + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(uuid4()), "role": "Custom", "permissions": [] + } + self.assertEqual( + self.client.get(f"/api/miners/{miner_id}/status-snapshots").status_code, 403 + ) + + +class MineMapFrontendAuditTests(unittest.TestCase): + def test_map_module_uses_one_bulk_endpoint_and_safe_dom(self): + source = (STATIC_ROOT / "mine-map.js").read_text(encoding="utf-8") + self.assertEqual(source.count('fetch("/api/location/map"'), 1) + self.assertNotIn("/api/inventory/miners", source) + self.assertNotIn("status-snapshots", source) + for unsafe in ("innerHTML", "outerHTML", "insertAdjacentHTML", + "document.write", "eval(", "Function("): + self.assertNotIn(unsafe, source) + self.assertIn("textContent", source) + self.assertIn("encodeURIComponent(position.miner_id)", source) + self.assertIn('window.addEventListener("popstate"', source) + self.assertIn('el.mapWorkspace.addEventListener("click"', source) + self.assertIn('el.mapWorkspace.addEventListener("keydown"', source) + self.assertIn('tile.setAttribute("aria-pressed"', source) + self.assertIn("syncTileTabStops", source) + self.assertIn("renderLegacyPositions", source) + self.assertIn("minerDetailsIncluded", source) + + def test_map_has_factual_status_rules_without_thresholds_or_mutation(self): + source = (STATIC_ROOT / "mine-map.js").read_text(encoding="utf-8") + for invented in ("> 90", ">= 90", "staleMinutes", "degradedThreshold"): + self.assertNotIn(invented, source) + self.assertIn('runtime === "offline"', source) + self.assertIn("position.hash_stable === false", source) + self.assertNotIn('method: "POST"', source) + self.assertNotIn('method: "PATCH"', source) + self.assertNotIn('method: "DELETE"', source) + self.assertIn('if (!state.minerDetailsIncluded)', source) + self.assertIn('Number.isFinite(value)', source) + + def test_map_repository_uses_constant_set_based_queries(self): + source = (Path(__file__).parents[1] / "app" / "locations" / + "repository.py").read_text(encoding="utf-8") + self.assertEqual(source.count("cur.execute("), 4) + self.assertEqual(source.count("LEFT JOIN LATERAL"), 2) + self.assertIn("ORDER BY snapshots.observed_at DESC, snapshots.id DESC", source) + self.assertIn("ORDER BY repairs.opened_at, repairs.id", source) + self.assertNotIn("for miner", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/design/MINE_MAP.md b/docs/design/MINE_MAP.md index 2e97f90..da906b9 100644 --- a/docs/design/MINE_MAP.md +++ b/docs/design/MINE_MAP.md @@ -2,16 +2,10 @@ ## Status -This document is the implementation-ready design baseline for Mine Map v1. It -defines the smallest useful spatial operations view that can be built from the -current MinerOps location, inventory, telemetry, and repair foundations. It -does not claim that the described v1 interface is implemented. - -The current application exposes a legacy Location Map at `/locations`, a Mine -Map placeholder at `/monitoring/map`, and a bulk location response at -`GET /api/location/map`. Mine Map v1 replaces the placeholder with a read-only, -permission-aware operational view. Existing location-management workflows -remain available separately and are not redesigned by this specification. +This document is the design baseline for the implemented Mine Map v1. The +current application exposes the read-only operational view at `/monitoring/map` +and retains the legacy Location Map at `/locations` for existing location +management workflows. Those mutation workflows are not redesigned by v1. ## Purpose @@ -245,8 +239,8 @@ Operational Status is the default layer. It composes current facts with this display precedence: 1. Location conflict, blocked position, switch, or empty position. -2. Lifecycle `retired`. -3. Active repair or lifecycle `in_repair`. +2. Active repair or lifecycle `in_repair`. +3. Lifecycle `retired` or decommissioned location state. 4. Lifecycle `spare`. 5. Runtime status explicitly equal to `offline`. 6. `hash_stable = false`, labelled **Hash unstable**. @@ -310,19 +304,13 @@ physical hierarchy or selection model. ## Filtering And Search -V1 filters are allowlisted and based on returned facts: - -- explicit runtime status; -- lifecycle; -- model or miner type; -- authoritative active repair presence; -- location state; -- position kind/status; and -- presence or absence of current telemetry. - -Search supports Asset ID, miner serial, current MAC, current IP, model, room, -rack, shelf, and position label where those fields are available. Asset ID and -lifecycle require the map response addition described below. +The implemented filters are allowlisted and use returned miner facts: derived +operational status, lifecycle, model, and authoritative active-repair presence. +Search covers Asset ID, miner serial, current MAC, current IP, and model. Location +context remains visible while nonmatching occupied tiles are deemphasized. Users +without `miners.view` receive none of these miner fields; miner search, miner +filters, and non-status layers are disabled rather than operating on redacted +values. Filtering preserves physical context. Positions remain in the layout; nonmatching occupied tiles are deemphasized and marked as filtered. A separate @@ -347,12 +335,12 @@ Where authorized and available, the inspector shows: - miner serial number; - current MAC and IP address; - full site, room, rack, shelf, and position path; -- runtime status and last seen time; +- runtime status and telemetry observation age; - lifecycle; -- active repair status, reason, and repair UUID; +- active repair status and reason; - latest hashrate in TH/s; - latest power in watts; -- latest chip maximum and average temperature; +- latest chip maximum temperature; - telemetry observation time; and - a link to full miner detail. @@ -366,30 +354,27 @@ needed by the map response. Shareable state is URL-backed. The initial query contract is: ```text -/monitoring/map?site_id={uuid}&room_id={uuid}&rack={label}&layer=status&miner_id={uuid} +/monitoring/map?site_id={uuid}&room_id={uuid}&layer=status&miner_id={uuid} ``` -Optional repeated or encoded parameters hold allowlisted filters and search. -Rack is a label because racks do not have UUIDs. Unknown, unauthorized, or -malformed parameters are ignored with a nonblocking notice; they never broaden -data access. +Optional encoded parameters hold allowlisted filters and search. Rack and shelf +do not have URL identifiers because they are labels rather than durable entities. +Unknown, unauthorized, or malformed parameters are discarded and the canonical +URL is replaced; they never broaden data access. The URL preserves: - selected site and room; -- optional rack focus; - layer; - filters and search; and - selected miner. -History state or session state preserves room scroll position, focused tile, -and any future map viewport that is too transient for a URL. Selecting or -closing a miner updates history so browser Back and Forward restore predictable -selection. Refresh and bookmarked links reconstruct the same shareable view. +Selecting or closing a miner updates history so browser Back and Forward restore +predictable URL-backed selection. Refresh and bookmarked links reconstruct the +same valid shareable state. Scroll position is not persisted by v1. When full miner detail opens in the same tab, the return URL contains the map -query state. Returning restores the prior scroll position when it remains -valid. A new tab may be offered as a normal browser link, not forced. +query state. A new tab remains available through normal browser behavior. ## Responsive Behavior @@ -502,27 +487,17 @@ It returns: or decommissioned location states; and - counts in `meta`. -Limitations: +The implemented additive response also supplies site and room UUIDs, Asset ID, +lifecycle, authoritative active repair context, generated-at metadata, typed +contracts, and optional server-side site and room filters. Its read path follows +Router -> Service -> Repository -> PostgreSQL. Users with `locations.view` but +without `miners.view` receive physical layout and occupancy with miner identity, +telemetry, repair, and virtual-miner details removed. -- no site ID or site name; -- no room UUID in each position; -- room ordering joins positions to rooms by case-insensitive room name, which is - ambiguous when different sites use the same room name; -- no Asset ID; -- no lifecycle status; -- no authoritative active repair UUID/status/reason; -- no tags; -- no environment temperature; -- no generated-at or response freshness metadata; -- no server-side site/room filter; and -- integrated `main.py` implementation with no typed response model. - -The endpoint is already efficient in shape. V1 should preserve its path for -compatibility and implement an additive, typed map read model through the -required Router -> Service -> Repository -> PostgreSQL boundaries. It should -accept allowlisted `site_id` and `room_id` filters and add the missing identity, -lifecycle, site, and authoritative repair fields. A second competing map -endpoint is not required solely for v1. +Tags and environment temperature are not included because v1 does not require +them. Rack and shelf remain labels. The frontend renders legacy positions without +`room_id` in a separate **Unscoped legacy positions** section; it does not attach +their denormalized room labels to a selected site or room. ### `GET /api/sites` @@ -569,10 +544,10 @@ needs these fields, but it is not used to populate tiles. ### `GET /api/miners/{miner_id}/status-snapshots` -Authentication is required by the route, but the current legacy endpoint does -not enforce `miners.view`. Mine Map must not treat authentication alone as -sufficient authorization. Any v1 use of this supplemental endpoint requires a -backend permission correction to `miners.view` first. +Authentication and `miners.view` are required. Admin is allowed by the existing +admin override; Manager, Technician, Read Only, and custom roles are authorized +according to their effective `miners.view` permission. Mine Map does not call +this endpoint per tile. Returns up to 100 recent snapshots with hashrate, power, efficiency, environment and chip temperatures, fans, uptime, stability, rejection rate, @@ -603,10 +578,9 @@ need these endpoints because the location map already includes the relevant raw latest measurements. V1 must not combine their classifications into a new status policy by accident. -## Required Map Read Model Additions +## Implemented Map Read Model -Before the full v1 behavior is enabled, the additive `GET /api/location/map` -contract must provide: +The additive `GET /api/location/map` contract provides: - site UUID and name; - room UUID as well as room name; @@ -618,10 +592,12 @@ contract must provide: - per-miner latest telemetry observation time; and - truthful nulls for unavailable fields. -The query must remain set-based, parameterized, permission-controlled, and -bounded by selected scope. The repository owns SQL, the service owns response -composition and any future status policy, and the router owns HTTP validation, -authorization, and the typed response envelope. +The query is set-based, parameterized, permission-controlled, and uses four +fixed database executions independent of fleet size. Optional site and room UUID +filters are supported by the API; the current frontend performs one bulk request +and renders the selected room locally. The repository owns SQL, the service owns +response composition, and the router owns HTTP validation, authorization, and +the typed response envelope. V1 does not require a write endpoint. Reading the map, changing layer, applying filters, selecting a miner, or opening the inspector must never mutate location, @@ -638,11 +614,11 @@ lifecycle, repair, or telemetry state. remain authoritative. All default roles currently receive both `locations.view` and `miners.view`, -but custom role combinations are possible. Before implementing the additive map -response, decide whether users with only `locations.view` receive a reduced -miner shape or whether the enriched response requires both permissions. The API -must not expose fields merely because the standard role matrix usually grants -both. +but custom role combinations are possible. The backend implements the boundary: +`locations.view` alone returns layout and occupancy while redacting Asset ID, +miner UUID, expected/current MAC, IP, serials, model, runtime/lifecycle/location +state, telemetry, active repair, and virtual-miner data. This is authoritative +server-side redaction rather than frontend hiding. ## Architecture Boundary @@ -683,7 +659,7 @@ The smallest useful v1 includes: - selected-miner inspector; - existing miner-detail navigation; - URL-backed context and predictable browser history; -- loading, empty, denied, error, partial, and refresh-failure states; +- loading, empty, denied, error, and partial-data states; - desktop, tablet, and phone behavior; - keyboard, screen-reader, touch, zoom, and reduced-motion support; - permission-aware presentation; and @@ -713,31 +689,17 @@ The following are explicitly outside v1: - selected-miner telemetry charts; and - generalized infrastructure modelling beyond current position records. -## Backend And Data Gaps +## Remaining Backend And Data Gaps -These gaps block parts of the acceptance criteria: - -1. **Multi-site location identity:** The current map response omits site and - room UUIDs and joins room ordering by name. Same-named rooms across sites - cannot be represented reliably until the query is site-scoped. -2. **Asset and lifecycle context:** Asset ID and lifecycle are not in the map - response. Without them, the map cannot truthfully distinguish spare, - retired, and in-repair lifecycle states. -3. **Authoritative repair context:** No bulk modular repair projection is joined - to map miners. The legacy repair queue is not an equivalent source. -4. **Freshness policy:** No single approved stale/down threshold exists. V1 can - show explicit offline state and observation age, but cannot classify stale - consistently until policy is centralized. -5. **Health and thermal policy:** Hashrate, rejection, power, and temperature +1. **Freshness policy:** No single approved stale/down threshold exists. V1 + shows explicit offline state and observation age but does not classify stale. +2. **Health and thermal policy:** Hashrate, rejection, power, and temperature measurements exist, but model-aware degraded and hot thresholds do not. -6. **Location vocabularies:** Position and location-state values are not - database-constrained. Unknown legacy values require truthful fallback and a - later preflight before constraints are added. -7. **Rack and shelf identity/order:** Rack and shelf are labels, and racks lack - an explicit sort field. V1 can render them but cannot provide durable rack or - shelf links or guaranteed operator-defined rack ordering. -8. **Map contract:** The current response has no typed model or generated-at - metadata and cannot be server-filtered by site or room. +3. **Location vocabularies:** Position and location-state values are not + database-constrained. Unknown legacy values require truthful fallback. +4. **Rack and shelf identity/order:** Rack and shelf are labels, and racks lack + an explicit sort field. V1 cannot provide durable rack or shelf links or + operator-defined rack ordering. ## Acceptance Criteria @@ -777,15 +739,13 @@ boundary rather than implying complete health classification. ## Unresolved Decisions -The following decisions must be made before implementation begins: +The following decisions remain for later map and data work: -1. Whether the enriched map response requires both `locations.view` and - `miners.view`, or returns a reduced miner shape to location-only users. -2. Which backend-owned policy defines stale, down, hot, and degraded states, +1. Which backend-owned policy defines stale, down, hot, and degraded states, including scope, thresholds, collector cadence, and configuration source. -3. Whether rack ordering may use a documented natural-label sort for v1 or - requires an additive persisted sort field before rollout. -4. Whether `location_positions.room_id` can be made required after legacy-data +2. Whether rack ordering may use a documented natural-label sort or requires an + additive persisted sort field. +3. Whether `location_positions.room_id` can be made required after legacy-data preflight, eliminating name-only room association for production map data. These decisions do not require changing the durable miner identity, existing diff --git a/scripts/test-dev-baseline.sh b/scripts/test-dev-baseline.sh index c028e3f..531ec18 100755 --- a/scripts/test-dev-baseline.sh +++ b/scripts/test-dev-baseline.sh @@ -167,6 +167,7 @@ for path in monitoring/map operations/repairs assets/miners; do done [[ "$(status_for "" GET /assets/minerops-app.css)" == "200" ]] [[ "$(status_for "" GET /assets/minerops-app.js)" == "200" ]] +[[ "$(status_for "" GET /assets/minerops-mine-map.js)" == "200" ]] for account in \ 'manager|Operations Manager|Manager|manager@example.com' \ @@ -213,6 +214,10 @@ done [[ "$(status_for "${technician_cookie}" GET /system-health)" == "403" ]] [[ "$(status_for "${readonly_cookie}" GET /locations)" == "200" ]] [[ "$(status_for "${readonly_cookie}" POST /api/rooms '{"name":"Must Not Be Created"}')" == "403" ]] +[[ "$(status_for "" GET /api/location/map)" == "401" ]] +[[ "$(status_for "${readonly_cookie}" GET /api/location/map)" == "200" ]] +[[ "$(status_for "" GET /api/miners/00000000-0000-0000-0000-000000000999/status-snapshots)" == "401" ]] +[[ "$(status_for "${readonly_cookie}" GET /api/miners/00000000-0000-0000-0000-000000000999/status-snapshots)" == "200" ]] echo "Checking Begin Repair permission and workflow..." "${compose[@]}" exec -T postgres sh -ec \ diff --git a/scripts/test-mine-map.sh b/scripts/test-mine-map.sh new file mode 100755 index 0000000..f7f561f --- /dev/null +++ b/scripts/test-mine-map.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" +project_name="${MINEROPS_MINE_MAP_TEST_PROJECT:-minerops-mine-map-$$}" +compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml) +cleanup() { + status=$? + "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + exit "${status}" +} +trap cleanup EXIT +[[ -f .env.dev ]] || { echo "Missing .env.dev." >&2; exit 1; } +"${compose[@]}" up --detach postgres redis +for attempt in $(seq 1 60); do + "${compose[@]}" exec -T postgres sh -ec 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break + (( attempt < 60 )) || { echo "PostgreSQL did not become ready." >&2; exit 1; } + sleep 1 +done +"${compose[@]}" build backend +"${compose[@]}" run --rm --no-deps backend alembic upgrade head +"${compose[@]}" run --rm --no-deps -v "${repo_root}/backend/tests:/app/tests:ro" \ + backend python -m unittest -v tests.test_mine_map tests.test_frontend_shell