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
+2027 -163

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();
}());
+96 -41
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>
</div>
<div class="field">
<label for="mapFilter">Filters</label>
<input id="mapFilter" type="search"
placeholder="Filters not connected" disabled>
</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>
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>
{manage_link}
</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>
<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>
<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 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()