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()