feat: add Mine Map v1

This commit is contained in:
lussierj committed 2026-08-10 19:55:20 +00:00
1 parent 548051899e
commit 60a831950c
16 files changed
+2024 -160

No files matched your search

+9
View File
@@ -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)
+368
View File
@@ -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); }
}
+629
View File
@@ -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();
}());
+93 -38
View File
@@ -79,48 +79,93 @@ def _dashboard_content() -> str:
"""
def _map_content() -> str:
return """
<div class="notice" role="status">Live layout and telemetry data are not
connected to this Internal Alpha canvas. No production state is represented
below.</div>
<section class="map-layout" aria-label="Mine Map placeholder">
<aside class="map-controls" aria-labelledby="mapHierarchyTitle">
<h2 id="mapHierarchyTitle">Location hierarchy</h2>
<ul class="hierarchy">
<li>Site</li>
<li>Room</li>
<li>Rack</li>
<li>Shelf / position</li>
</ul>
<div class="control-stack">
<div class="field">
<label for="statusLayer">Status layer</label>
<select id="statusLayer" disabled>
<option>Live status unavailable</option>
</select>
def _map_content(can_manage_locations: bool = False) -> str:
manage_link = (
'<a class="object-link" href="/locations">Manage locations</a>'
if can_manage_locations
else ""
)
return f"""
<section class="map-context" aria-labelledby="mapControlsTitle">
<div class="map-context-head">
<div>
<h2 id="mapControlsTitle">Map context</h2>
<p id="mapScope" class="panel-copy">Select a site and room.</p>
</div>
<div class="field">
<label for="mapFilter">Filters</label>
<input id="mapFilter" type="search"
placeholder="Filters not connected" disabled>
{manage_link}
</div>
<div class="map-control-grid">
<div class="field"><label for="mapSite">Site</label>
<select id="mapSite" aria-describedby="mapScope"><option>Loading sites...</option></select></div>
<div class="field"><label for="mapRoom">Room</label>
<select id="mapRoom"><option>Loading rooms...</option></select></div>
<div class="field"><label for="mapLayer">Layer</label>
<select id="mapLayer">
<option value="status">Operational Status</option>
<option value="temperature">Temperature</option>
<option value="hashrate">Hashrate</option>
<option value="power">Power</option>
<option value="repair">Repair State</option>
</select></div>
<div class="field map-search"><label for="mapSearch">Find miner</label>
<input id="mapSearch" type="search" autocomplete="off"
placeholder="Asset ID, serial, MAC, or IP"></div>
</div>
</aside>
<div class="map-viewport" aria-label="Operational map viewport">
<div class="map-empty">
<strong>Operational canvas</strong>
<span>Site, room, rack, miner, and status overlays will render here
when authoritative layout and telemetry sources are connected.</span>
<details class="map-filters" id="mapFilters">
<summary>Filters <span id="mapFilterCount" aria-live="polite"></span></summary>
<div class="map-filter-grid">
<div class="field"><label for="mapLifecycle">Lifecycle</label>
<select id="mapLifecycle"><option value="">All lifecycle states</option></select></div>
<div class="field"><label for="mapModel">Model</label>
<select id="mapModel"><option value="">All models</option></select></div>
<div class="field"><label for="mapRepair">Repair</label>
<select id="mapRepair"><option value="">All repair states</option><option value="active">Active repair</option><option value="none">No active repair</option><option value="legacy">Legacy repair location</option></select></div>
<div class="field"><label for="mapStatus">Operational status</label>
<select id="mapStatus"><option value="">All operational states</option></select></div>
<button id="mapClearFilters" class="quiet-button" type="button">Clear filters</button>
</div>
</details>
</section>
<section class="map-status-band" aria-label="Map summary">
<div id="mapSummary" class="map-summary" aria-live="polite"></div>
<div id="mapFreshness" class="freshness">Waiting for map data</div>
</section>
<div id="mapCriticalState" class="state-message" data-kind="error" role="alert" hidden>
<strong id="mapCriticalTitle">Map unavailable</strong>
<span id="mapCriticalDetail">The map could not be loaded.</span>
<button id="mapRetry" class="command-button" type="button">Retry</button>
</div>
<div id="mapLoading" class="state-message" role="status">
<strong>Loading Mine Map</strong>
<span>Requesting the current physical layout and operational observations.</span>
</div>
<section class="mine-map-workspace" id="mapWorkspace" hidden>
<div class="map-main-column">
<div id="mapLegend" class="map-legend" aria-label="Map legend"></div>
<div id="mapNotice" class="map-notice" role="status" hidden></div>
<div id="mapViewport" class="map-viewport-v1"
aria-label="Room racks, shelves, and positions"></div>
<section id="mapLegacySection" class="unplaced-section" hidden
aria-labelledby="mapLegacyTitle">
<div class="panel-head"><div><h2 id="mapLegacyTitle">Unscoped legacy positions</h2>
<p class="panel-copy">Stored positions without a durable room relationship. They are not assigned to the selected site or room.</p></div></div>
<div id="mapLegacyPositions" class="legacy-position-list"></div>
</section>
<section id="mapUnplacedSection" class="unplaced-section" hidden
aria-labelledby="unplacedTitle">
<div class="panel-head"><div><h2 id="unplacedTitle">Unplaced and operational areas</h2>
<p class="panel-copy">Miner records without a configured rack position.</p></div></div>
<div id="mapUnplaced"></div>
</section>
</div>
<aside class="map-inspector" aria-labelledby="selectionTitle">
<h2 id="selectionTitle">Selection details</h2>
<dl class="definition-list">
<dt>Selection</dt><dd>None</dd>
<dt>Status</dt><dd>Not connected</dd>
<dt>Freshness</dt><dd>Unavailable</dd>
</dl>
<aside id="mapInspector" class="map-inspector-v1" aria-labelledby="mapInspectorTitle" hidden>
<div class="inspector-head">
<div><p class="eyebrow">Selected position</p><h2 id="mapInspectorTitle">Miner details</h2></div>
<button id="mapInspectorClose" class="icon-button" type="button" aria-label="Close miner details" title="Close miner details">X</button>
</div>
<p id="mapInspectorStatus" class="status-callout"></p>
<dl id="mapInspectorDetails" class="definition-list inspector-details"></dl>
<a id="mapOpenMiner" class="command-link" href="/assets/miners">Open Miner</a>
</aside>
</section>
"""
@@ -242,7 +287,16 @@ def render_app_page(
if can_view_miners
else '<div class="global-search-unavailable" aria-hidden="true"></div>'
)
content = CONTENT[view]()
content = (
_map_content(has_permission("locations.manage"))
if view == "map"
else CONTENT[view]()
)
map_script = (
'<script src="/assets/minerops-mine-map.js" defer></script>'
if view == "map"
else ""
)
return f"""<!doctype html>
<html lang="en">
<head>
@@ -297,5 +351,6 @@ def render_app_page(
</main>
</div>
<script src="/assets/minerops-app.js" defer></script>
{map_script}
</body>
</html>"""
+7
View File
@@ -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"]
+8
View File
@@ -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())
+118
View File
@@ -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
+203
View File
@@ -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,
}
+30
View File
@@ -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"),
)
+87
View File
@@ -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,
),
)
+8 -3
View File
@@ -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()
+12 -12
View File
@@ -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)
+356
View File
@@ -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": '<img src=x onerror="alert(1)">', "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"],
'<img src=x onerror="alert(1)">')
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()
+67 -107
View File
@@ -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
+5
View File
@@ -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 \
+24
View File
@@ -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