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
@@ -756,7 +756,7 @@ tbody tr:last-child td {
|
||||
|
||||
/* Mine Map v1 */
|
||||
.map-context {
|
||||
padding: 15px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
@@ -767,32 +767,76 @@ tbody tr:last-child td {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.map-context h2,
|
||||
.map-inspector-v1 h2,
|
||||
.unplaced-section h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.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;
|
||||
grid-template-columns: minmax(130px, .8fr) minmax(130px, .8fr)
|
||||
minmax(150px, .9fr) minmax(190px, 1.35fr) auto;
|
||||
align-items: end;
|
||||
gap: 7px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.map-context .field {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.map-context .field label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.map-context select,
|
||||
.map-context input[type="search"] {
|
||||
min-height: 36px;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.map-problems-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 36px;
|
||||
padding: 6px 9px;
|
||||
border: 1px solid #9aa6b1;
|
||||
border-radius: 4px;
|
||||
background: #f8fafb;
|
||||
color: #263442;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.map-problems-control input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
accent-color: #176b9a;
|
||||
}
|
||||
|
||||
.map-problems-control:has(input:checked) {
|
||||
border-color: #176b9a;
|
||||
background: #edf7fb;
|
||||
}
|
||||
|
||||
.map-filters {
|
||||
margin-top: 12px;
|
||||
margin-top: 7px;
|
||||
border-top: 1px solid #e3e8ed;
|
||||
padding-top: 10px;
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.map-filters summary {
|
||||
min-height: 36px;
|
||||
min-height: 28px;
|
||||
cursor: pointer;
|
||||
color: #334252;
|
||||
font-size: 13px;
|
||||
@@ -803,7 +847,7 @@ tbody tr:last-child td {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(135px, 1fr)) auto;
|
||||
align-items: end;
|
||||
gap: 10px;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.map-filter-grid .quiet-button,
|
||||
@@ -815,7 +859,7 @@ tbody tr:last-child td {
|
||||
|
||||
.map-status-band {
|
||||
align-items: flex-start;
|
||||
margin: 12px 0;
|
||||
margin: 7px 0;
|
||||
}
|
||||
|
||||
.map-summary,
|
||||
@@ -843,7 +887,7 @@ tbody tr:last-child td {
|
||||
|
||||
.mine-map-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
grid-template-columns: minmax(0, 1fr) 300px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
@@ -860,8 +904,8 @@ tbody tr:last-child td {
|
||||
}
|
||||
|
||||
.map-legend {
|
||||
min-height: 50px;
|
||||
padding: 10px 14px;
|
||||
min-height: 38px;
|
||||
padding: 6px 9px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #f7f8fa;
|
||||
font-size: 12px;
|
||||
@@ -870,23 +914,72 @@ tbody tr:last-child td {
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.legend-marker,
|
||||
.status-marker {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid #758493;
|
||||
border-radius: 4px;
|
||||
border-radius: 3px;
|
||||
color: #263442;
|
||||
background: #eef1f4;
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.legend-marker {
|
||||
min-height: 21px;
|
||||
padding: 2px 5px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.status-marker {
|
||||
flex: 0 1 auto;
|
||||
min-height: 17px;
|
||||
max-width: 100%;
|
||||
padding: 1px 4px;
|
||||
font-size: 8px;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.legend-marker[data-status="online"],
|
||||
.map-position[data-status="online"] .status-marker { border-color: #658675; color: #315c45; background: #f0f6f2; }
|
||||
.legend-marker[data-status="offline"],
|
||||
.map-position[data-status="offline"] .status-marker { border-color: #b42318; color: #8f1d14; background: #fff; }
|
||||
.legend-marker[data-status="unstable"],
|
||||
.map-position[data-status="unstable"] .status-marker { border-color: #9a5b00; color: #754600; background: #fff; }
|
||||
.legend-marker[data-status="repair"],
|
||||
.map-position[data-status="repair"] .status-marker { border-color: #6b4ca5; color: #523884; background: #fff; }
|
||||
.legend-marker[data-status="unknown"],
|
||||
.map-position[data-status="unknown"] .status-marker { border-color: #596675; color: #354351; background: #fff; }
|
||||
|
||||
.relative-scale {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding-right: 8px;
|
||||
border-right: 1px solid var(--line);
|
||||
color: #40505f;
|
||||
}
|
||||
|
||||
.relative-scale-label {
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.relative-scale-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.relative-scale-track {
|
||||
width: 64px;
|
||||
height: 6px;
|
||||
border: 1px solid #8e9ba8;
|
||||
background: linear-gradient(90deg, #dfe9ee, #176b9a);
|
||||
}
|
||||
|
||||
.map-notice {
|
||||
margin: 14px;
|
||||
padding: 13px;
|
||||
@@ -898,11 +991,11 @@ tbody tr:last-child td {
|
||||
|
||||
.map-viewport-v1 {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
gap: 8px;
|
||||
min-height: 380px;
|
||||
max-height: calc(100vh - 330px);
|
||||
max-height: calc(100vh - 265px);
|
||||
overflow: auto;
|
||||
padding: 14px;
|
||||
padding: 8px;
|
||||
background: #e9edf0;
|
||||
}
|
||||
|
||||
@@ -914,7 +1007,7 @@ tbody tr:last-child td {
|
||||
|
||||
.rack-title {
|
||||
margin: 0;
|
||||
padding: 9px 12px;
|
||||
padding: 6px 9px;
|
||||
color: #fff;
|
||||
background: #263442;
|
||||
font-size: 14px;
|
||||
@@ -922,7 +1015,7 @@ tbody tr:last-child td {
|
||||
|
||||
.map-shelf {
|
||||
display: grid;
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
grid-template-columns: 78px minmax(0, 1fr);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
@@ -932,7 +1025,7 @@ tbody tr:last-child td {
|
||||
|
||||
.shelf-title {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
padding: 7px;
|
||||
color: #40505f;
|
||||
background: #f3f5f7;
|
||||
font-size: 12px;
|
||||
@@ -940,20 +1033,21 @@ tbody tr:last-child td {
|
||||
|
||||
.position-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(142px, 1fr));
|
||||
gap: 7px;
|
||||
padding: 8px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(108px, 1fr));
|
||||
gap: 4px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.map-position {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto auto;
|
||||
gap: 5px;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
min-height: 118px;
|
||||
padding: 9px;
|
||||
min-height: 84px;
|
||||
padding: 6px;
|
||||
border: 1px solid #8e9ba8;
|
||||
border-left: 5px solid #758493;
|
||||
border-left: 3px solid #758493;
|
||||
border-radius: 4px;
|
||||
color: var(--ink);
|
||||
background: #fff;
|
||||
@@ -974,17 +1068,43 @@ button.map-position:hover {
|
||||
}
|
||||
|
||||
.map-position[data-filtered="true"] {
|
||||
opacity: .34;
|
||||
opacity: .28;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.map-position[data-status="online"] { border-left-color: #177245; }
|
||||
.map-position[data-problems-muted="true"] {
|
||||
opacity: .16;
|
||||
filter: grayscale(.8);
|
||||
}
|
||||
|
||||
.map-position[data-selected="true"] {
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.map-position[data-problem="true"] {
|
||||
border-left-width: 5px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.map-position[data-measurement="true"]::after {
|
||||
position: absolute;
|
||||
right: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: var(--relative-percent);
|
||||
height: 4px;
|
||||
background: #176b9a;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.map-position[data-status="online"] { border-left-color: #658675; }
|
||||
.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="empty"] { min-height: 56px; border-style: dashed; border-left-color: #aeb8c2; color: #687785; background: transparent; opacity: .62; }
|
||||
.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; }
|
||||
@@ -1005,10 +1125,10 @@ button.map-position:hover {
|
||||
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; }
|
||||
.position-label { flex: 1 1 auto; min-width: 0; font-size: 10px; line-height: 1.2; }
|
||||
.position-miner { font-size: 11px; font-weight: 750; line-height: 1.2; }
|
||||
.position-value { color: #334252; font-size: 10px; font-variant-numeric: tabular-nums; }
|
||||
.position-freshness { color: var(--muted); font-size: 9px; }
|
||||
|
||||
.map-inspector-v1 {
|
||||
position: sticky;
|
||||
@@ -1110,14 +1230,19 @@ button.map-position:hover {
|
||||
.map-context-head,
|
||||
.map-status-band { align-items: flex-start; flex-direction: column; }
|
||||
.map-control-grid,
|
||||
.map-filter-grid { grid-template-columns: 1fr; }
|
||||
.map-filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.map-search { grid-column: 1 / -1; }
|
||||
.map-problems-control { min-height: 44px; }
|
||||
.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; }
|
||||
.position-grid { grid-template-columns: repeat(2, minmax(108px, 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); }
|
||||
.map-control-grid,
|
||||
.map-filter-grid { grid-template-columns: 1fr; }
|
||||
.map-search { grid-column: auto; }
|
||||
.position-grid { grid-template-columns: repeat(2, minmax(104px, 1fr)); }
|
||||
}
|
||||
@@ -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 () {
|
||||
|
||||
@@ -110,6 +110,10 @@ def _map_content(can_manage_locations: bool = False) -> str:
|
||||
<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>
|
||||
<label class="map-problems-control" for="mapProblemsOnly">
|
||||
<input id="mapProblemsOnly" type="checkbox">
|
||||
<span>Problems only</span>
|
||||
</label>
|
||||
</div>
|
||||
<details class="map-filters" id="mapFilters">
|
||||
<summary>Filters <span id="mapFilterCount" aria-live="polite"></span></summary>
|
||||
|
||||
@@ -152,6 +152,7 @@ class FrontendShellTests(unittest.TestCase):
|
||||
self.assertIn(label, response.text)
|
||||
for value in (
|
||||
'id="mapSite"', 'id="mapRoom"', 'id="mapLayer"',
|
||||
'id="mapProblemsOnly"',
|
||||
'id="mapViewport"', 'id="mapInspector"',
|
||||
'/assets/minerops-mine-map.js',
|
||||
):
|
||||
|
||||
@@ -329,6 +329,7 @@ class MineMapFrontendAuditTests(unittest.TestCase):
|
||||
self.assertIn("syncTileTabStops", source)
|
||||
self.assertIn("renderLegacyPositions", source)
|
||||
self.assertIn("minerDetailsIncluded", source)
|
||||
self.assertIn("mapProblemsOnly", source)
|
||||
|
||||
def test_map_has_factual_status_rules_without_thresholds_or_mutation(self):
|
||||
source = (STATIC_ROOT / "mine-map.js").read_text(encoding="utf-8")
|
||||
@@ -342,6 +343,18 @@ class MineMapFrontendAuditTests(unittest.TestCase):
|
||||
self.assertIn('if (!state.minerDetailsIncluded)', source)
|
||||
self.assertIn('Number.isFinite(value)', source)
|
||||
|
||||
def test_problems_and_measurement_layers_use_factual_relative_values(self):
|
||||
source = (STATIC_ROOT / "mine-map.js").read_text(encoding="utf-8")
|
||||
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
|
||||
self.assertIn("const PROBLEM_KEYS = new Set", source)
|
||||
for state in ("offline", "unknown", "unstable", "repair"):
|
||||
self.assertIn(f'"{state}"', source)
|
||||
self.assertIn("state.problemsOnly", source)
|
||||
self.assertIn('"--relative-percent"', source)
|
||||
self.assertIn("Relative range", source)
|
||||
self.assertIn('data-problems-muted="true"', css)
|
||||
self.assertIn(".relative-scale-track", css)
|
||||
|
||||
def test_map_repository_uses_constant_set_based_queries(self):
|
||||
source = (Path(__file__).parents[1] / "app" / "locations" /
|
||||
"repository.py").read_text(encoding="utf-8")
|
||||
|
||||
Reference in new issue
Block a user