feat: polish Mine Map UX

This commit is contained in:
lussierj committed 2026-08-11 14:27:09 +00:00
1 parent d06ee7bcc6
commit 06ed8248f5
5 files changed
+383 -90

No files matched your search

+197 -47
View File
@@ -6,6 +6,7 @@
const ids = [
"mapSite", "mapRoom", "mapLayer", "mapSearch", "mapLifecycle",
"mapModel", "mapRepair", "mapStatus", "mapClearFilters",
"mapProblemsOnly",
"mapFilterCount", "mapScope", "mapSummary", "mapFreshness",
"mapCriticalState", "mapCriticalTitle", "mapCriticalDetail",
"mapRetry", "mapLoading", "mapWorkspace", "mapLegend", "mapNotice",
@@ -27,6 +28,7 @@
model: "",
repair: "",
status: "",
problemsOnly: false,
minerId: "",
minerDetailsIncluded: false
};
@@ -71,50 +73,100 @@
return Math.floor(seconds / 86400) + "d ago";
}
const PROBLEM_KEYS = new Set([
"offline", "unknown", "unstable", "repair", "conflict", "blocked",
"expected"
]);
const MEASUREMENT_LAYERS = {
temperature: { field: "chip_temp_max_c", suffix: " C max" },
hashrate: { field: "hashrate_ths", suffix: " TH/s" },
power: { field: "power_w", suffix: " W" }
};
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: "!" };
return { key: "conflict", label: "Location conflict", mark: "Conflict" };
}
if (kind === "blocked" || status === "blocked") {
return { key: "blocked", label: "Blocked position", mark: "x" };
return { key: "blocked", label: "Blocked position", mark: "Blocked" };
}
if (kind === "switch" || status === "switch") {
return { key: "switch", label: "Switch / equipment", mark: "S" };
return { key: "switch", label: "Switch / equipment", mark: "Equipment" };
}
if (!position.occupied) {
if (position.expected_mac) {
return { key: "expected", label: "Expected miner not resolved", mark: "?" };
return { key: "expected", label: "Expected miner not resolved", mark: "Expected" };
}
if (kind === "miner slot" && status === "available") {
return { key: "empty", label: "Empty position", mark: "+" };
return { key: "empty", label: "Empty position", mark: "Empty" };
}
return { key: "unknown", label: "Unknown position state", mark: "?" };
return { key: "unknown", label: "Unknown position state", mark: "Unknown" };
}
if (!state.minerDetailsIncluded) {
return { key: "restricted", label: "Occupied; miner details restricted", mark: "L" };
return { key: "restricted", label: "Occupied; miner details restricted", mark: "Restricted" };
}
if (position.active_repair || position.lifecycle_status === "in_repair") {
return { key: "repair", label: "In repair", mark: "R" };
return { key: "repair", label: "In repair", mark: "Repair" };
}
if (position.lifecycle_status === "retired" || position.asset_state === "decommissioned") {
return { key: "retired", label: "Retired / decommissioned", mark: "X" };
return { key: "retired", label: "Retired / decommissioned", mark: "Retired" };
}
if (position.lifecycle_status === "spare") {
return { key: "spare", label: "Spare inventory", mark: "P" };
return { key: "spare", label: "Spare inventory", mark: "Spare" };
}
const runtime = words(position.miner_status);
if (runtime === "offline") {
return { key: "offline", label: "Offline", mark: "O" };
return { key: "offline", label: "Offline", mark: "Offline" };
}
if (position.hash_stable === false) {
return { key: "unstable", label: "Hash unstable", mark: "H" };
return { key: "unstable", label: "Hash unstable", mark: "Unstable" };
}
if (runtime === "online") {
return { key: "online", label: "Online", mark: "ON" };
return { key: "online", label: "Online", mark: "Online" };
}
return { key: "unknown", label: "Operational state unknown", mark: "?" };
return { key: "unknown", label: "Operational state unknown", mark: "Unknown" };
}
function isProblem(position) {
return PROBLEM_KEYS.has(operational(position).key);
}
function measurementScale(positions) {
const config = MEASUREMENT_LAYERS[state.layer];
if (!config) return null;
const values = positions.filter(function (position) {
return position.occupied && matches(position) &&
(!state.problemsOnly || isProblem(position));
}).map(function (position) {
return position[config.field];
}).filter(function (value) {
return typeof value === "number" && Number.isFinite(value);
});
if (!values.length) return { config: config, min: null, max: null };
let min = values[0];
let max = values[0];
for (const value of values.slice(1)) {
min = Math.min(min, value);
max = Math.max(max, value);
}
return {
config: config,
min: min,
max: max
};
}
function relativePercent(value, scale) {
if (!scale || scale.min === null || scale.max === null ||
typeof value !== "number" || !Number.isFinite(value)) return null;
if (scale.min === scale.max) return 50;
return Math.max(0, Math.min(
100,
((value - scale.min) / (scale.max - scale.min)) * 100
));
}
function layerValue(position, condition) {
@@ -208,8 +260,10 @@
if (!allowed) {
state.layer = "status";
state.query = state.lifecycle = state.model = state.repair = state.status = "";
state.problemsOnly = false;
el.mapSearch.value = "";
el.mapRepair.value = "";
el.mapProblemsOnly.checked = false;
}
for (const item of el.mapLayer.options) {
item.disabled = !allowed && item.value !== "status";
@@ -221,6 +275,7 @@
el.mapRepair.disabled = !allowed;
el.mapStatus.disabled = !allowed;
el.mapClearFilters.disabled = !allowed;
el.mapProblemsOnly.disabled = !allowed;
el.mapSearch.closest(".field").hidden = !allowed;
document.getElementById("mapFilters").hidden = !allowed;
el.mapPermissionNote.hidden = allowed;
@@ -256,7 +311,8 @@
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
repair: state.repair, status: state.status,
problems: state.problemsOnly ? "1" : "", miner_id: state.minerId
};
for (const pair of Object.entries(values)) {
if (pair[1]) params.set(pair[0], pair[1]);
@@ -283,43 +339,95 @@
state.repair = ["active", "none", "legacy"].includes(params.get("repair"))
? params.get("repair") : "";
state.status = params.get("status") || "";
state.problemsOnly = params.get("problems") === "1";
state.minerId = params.get("miner_id") || "";
syncPermissionControls();
el.mapLayer.value = state.layer;
el.mapSearch.value = state.query;
el.mapRepair.value = state.repair;
el.mapProblemsOnly.checked = state.problemsOnly;
}
function legend() {
function appendLegendItem(key, label, mark) {
const item = create("span", "legend-item");
const marker = create("span", "legend-marker", mark);
marker.dataset.status = key;
marker.setAttribute("aria-hidden", "true");
item.append(marker, document.createTextNode(label));
el.mapLegend.appendChild(item);
}
function legend(scale) {
const entries = !state.minerDetailsIncluded
? [["restricted", "Miner details restricted"], ["empty", "Empty"],
["conflict", "Conflict"], ["blocked", "Blocked"], ["switch", "Equipment"]]
? [["restricted", "Miner details restricted", "Restricted"],
["empty", "Empty position", "Empty"],
["conflict", "Location conflict", "Conflict"],
["blocked", "Blocked position", "Blocked"],
["switch", "Switch or equipment", "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"]];
? [["online", "Online", "Online"], ["offline", "Offline", "Offline"],
["unstable", "Hash unstable", "Unstable"],
["repair", "In repair", "Repair"], ["spare", "Spare", "Spare"],
["retired", "Retired", "Retired"],
["unknown", "Operational state unknown", "Unknown"],
["empty", "Empty position", "Empty"],
["conflict", "Location conflict", "Conflict"]]
: [["online", "Online marker", "Online"],
["offline", "Offline marker", "Offline"],
["unstable", "Hash unstable marker", "Unstable"],
["repair", "In-repair marker", "Repair"],
["unknown", "Unknown-state marker", "Unknown"],
["empty", "Empty position", "Empty"]];
el.mapLegend.replaceChildren();
el.mapLegend.appendChild(create("strong", "", el.mapLayer.options[el.mapLayer.selectedIndex].text));
el.mapLegend.appendChild(create(
"strong", "legend-title", el.mapLayer.options[el.mapLayer.selectedIndex].text
));
if (scale) {
const range = create("span", "relative-scale");
if (scale.min === null || scale.max === null) {
range.textContent = "Relative range unavailable: no valid displayed readings";
} else {
const low = metric(scale.min, scale.config.suffix);
const high = metric(scale.max, scale.config.suffix);
range.setAttribute(
"aria-label",
"Relative range across emphasized miners from " + low + " to " + high
);
range.append(
create("span", "relative-scale-label", "Relative range (emphasized)"),
create("span", "relative-scale-value", low),
create("span", "relative-scale-track"),
create("span", "relative-scale-value", high)
);
}
el.mapLegend.appendChild(range);
}
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);
appendLegendItem(entry[0], entry[1], entry[2]);
}
}
function positionTile(position) {
function positionTile(position, scale) {
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;
tile.dataset.occupied = String(position.occupied);
tile.dataset.problem = String(isProblem(position));
if (position.miner_id) tile.dataset.minerId = position.miner_id;
if (position.occupied && !matches(position)) tile.dataset.filtered = "true";
if (state.problemsOnly && position.occupied && !isProblem(position)) {
tile.dataset.problemsMuted = "true";
}
if (String(position.miner_id) === state.minerId) tile.dataset.selected = "true";
const scaleEligible = position.occupied && matches(position) &&
(!state.problemsOnly || isProblem(position));
const relative = scale && scaleEligible
? relativePercent(position[scale.config.field], scale) : null;
if (relative !== null) {
tile.dataset.measurement = "true";
tile.style.setProperty("--relative-percent", relative.toFixed(2) + "%");
}
if (tile.tagName === "BUTTON") {
tile.type = "button";
tile.tabIndex = -1;
@@ -332,14 +440,19 @@
", " + condition.label);
}
const head = create("span", "position-head");
const marker = create("span", "status-marker", condition.mark);
marker.setAttribute("aria-hidden", "true");
head.append(create("strong", "position-label", display(position.position_label, "Position " + position.position_index)),
create("span", "status-marker", condition.mark));
marker);
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);
tile.append(head, name);
if (position.occupied) {
tile.append(
create("span", "position-value", layerValue(position, condition)),
create("span", "position-freshness", state.minerDetailsIncluded
? age(position.observed_at) : "Miner details restricted")
);
}
return tile;
}
@@ -354,7 +467,8 @@
})) state.minerId = "";
syncPermissionControls();
syncFilterOptions();
legend();
const scale = measurementScale(positions);
legend(scale);
el.mapViewport.replaceChildren();
if (!state.data.sites.length) {
el.mapNotice.hidden = false;
@@ -386,7 +500,9 @@
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));
for (const position of shelfEntry[1]) {
grid.appendChild(positionTile(position, scale));
}
shelf.appendChild(grid);
rack.appendChild(shelf);
}
@@ -398,7 +514,8 @@
renderUnplaced();
renderInspector();
syncTileTabStops();
const count = [state.query, state.lifecycle, state.model, state.repair, state.status].filter(Boolean).length;
const count = [state.query, state.lifecycle, state.model, state.repair,
state.status, state.problemsOnly].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."
@@ -407,17 +524,19 @@
function renderSummary(positions) {
const facts = { occupied: 0, empty: 0, conflict: 0, offline: 0,
repair: 0, unknown: 0, restricted: 0 };
repair: 0, unknown: 0, restricted: 0, problems: 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;
if (isProblem(position)) facts.problems += 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]);
["Problems", facts.problems], ["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");
@@ -521,8 +640,27 @@
if (!current && tiles.length) tiles[0].tabIndex = 0;
}
function syncSelection() {
for (const tile of mapTiles()) {
const selected = tile.dataset.minerId === state.minerId;
tile.dataset.selected = String(selected);
tile.setAttribute("aria-pressed", String(selected));
}
renderInspector();
syncTileTabStops();
}
function bind() {
const controls = [el.mapLayer, el.mapSearch, el.mapLifecycle, el.mapModel, el.mapRepair, el.mapStatus];
let renderFrame = null;
function scheduleRender() {
if (renderFrame !== null) window.cancelAnimationFrame(renderFrame);
renderFrame = window.requestAnimationFrame(function () {
renderFrame = null;
renderMap();
setUrl("replace");
});
}
el.mapSite.addEventListener("change", function () {
state.siteId = el.mapSite.value; state.roomId = ""; state.minerId = "";
renderMap(); setUrl("push");
@@ -535,18 +673,25 @@
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");
scheduleRender();
});
}
el.mapProblemsOnly.addEventListener("change", function () {
state.problemsOnly = el.mapProblemsOnly.checked;
scheduleRender();
});
el.mapClearFilters.addEventListener("click", function () {
state.query = state.lifecycle = state.model = state.repair = state.status = "";
el.mapSearch.value = ""; el.mapRepair.value = ""; renderMap(); setUrl("replace");
state.problemsOnly = false;
el.mapSearch.value = ""; el.mapRepair.value = "";
el.mapProblemsOnly.checked = false;
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");
syncSelection(); setUrl("push");
el.mapInspectorClose.focus();
});
el.mapWorkspace.addEventListener("keydown", function (event) {
@@ -562,11 +707,16 @@
next.focus();
});
el.mapInspectorClose.addEventListener("click", function () {
const restoreId = state.minerId; state.minerId = ""; renderMap(); setUrl("push");
const restoreId = state.minerId; state.minerId = "";
syncSelection(); setUrl("push");
const restore = mapTiles().find(function (tile) {
return tile.dataset.minerId === restoreId;
});
if (restore) { restore.tabIndex = 0; restore.focus(); }
if (restore) {
for (const tile of mapTiles()) tile.tabIndex = -1;
restore.tabIndex = 0;
restore.focus();
}
});
el.mapRetry.addEventListener("click", load);
window.addEventListener("popstate", function () {