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