feat: polish Mine Map UX
This commit is contained in:
1 parent
d06ee7bcc6
commit
06ed8248f5
5 files changed
+383
-90
No files matched your search
@@ -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 () {
|
||||
|
||||
Reference in new issue
Block a user