780 lines
35 KiB
JavaScript
780 lines
35 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
if (document.body.dataset.view !== "map") return;
|
|
|
|
const ids = [
|
|
"mapSite", "mapRoom", "mapLayer", "mapSearch", "mapLifecycle",
|
|
"mapModel", "mapRepair", "mapStatus", "mapClearFilters",
|
|
"mapProblemsOnly",
|
|
"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: "",
|
|
problemsOnly: false,
|
|
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";
|
|
}
|
|
|
|
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: "Conflict" };
|
|
}
|
|
if (kind === "blocked" || status === "blocked") {
|
|
return { key: "blocked", label: "Blocked position", mark: "Blocked" };
|
|
}
|
|
if (kind === "switch" || status === "switch") {
|
|
return { key: "switch", label: "Switch / equipment", mark: "Equipment" };
|
|
}
|
|
if (!position.occupied) {
|
|
if (position.expected_mac) {
|
|
return { key: "expected", label: "Expected miner not resolved", mark: "Expected" };
|
|
}
|
|
if (kind === "miner slot" && status === "available") {
|
|
return { key: "empty", label: "Empty position", mark: "Empty" };
|
|
}
|
|
return { key: "unknown", label: "Unknown position state", mark: "Unknown" };
|
|
}
|
|
if (!state.minerDetailsIncluded) {
|
|
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: "Repair" };
|
|
}
|
|
if (position.lifecycle_status === "retired" || position.asset_state === "decommissioned") {
|
|
return { key: "retired", label: "Retired / decommissioned", mark: "Retired" };
|
|
}
|
|
if (position.lifecycle_status === "spare") {
|
|
return { key: "spare", label: "Spare inventory", mark: "Spare" };
|
|
}
|
|
const runtime = words(position.miner_status);
|
|
if (runtime === "offline") {
|
|
return { key: "offline", label: "Offline", mark: "Offline" };
|
|
}
|
|
if (position.hash_stable === false) {
|
|
return { key: "unstable", label: "Hash unstable", mark: "Unstable" };
|
|
}
|
|
if (runtime === "online") {
|
|
return { key: "online", label: "Online", mark: "Online" };
|
|
}
|
|
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) {
|
|
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 = "";
|
|
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";
|
|
}
|
|
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.mapProblemsOnly.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,
|
|
problems: state.problemsOnly ? "1" : "", 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.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 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", "Restricted"],
|
|
["empty", "Empty position", "Empty"],
|
|
["conflict", "Location conflict", "Conflict"],
|
|
["blocked", "Blocked position", "Blocked"],
|
|
["switch", "Switch or equipment", "Equipment"]]
|
|
: state.layer === "status"
|
|
? [["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", "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) {
|
|
appendLegendItem(entry[0], entry[1], entry[2]);
|
|
}
|
|
}
|
|
|
|
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;
|
|
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");
|
|
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)),
|
|
marker);
|
|
const name = create("span", "position-miner", position.occupied ? identity(position) : condition.label);
|
|
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;
|
|
}
|
|
|
|
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();
|
|
const scale = measurementScale(positions);
|
|
legend(scale);
|
|
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, scale));
|
|
}
|
|
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, 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."
|
|
: ". 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, 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,
|
|
["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");
|
|
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 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");
|
|
});
|
|
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;
|
|
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 = "";
|
|
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;
|
|
syncSelection(); 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 = "";
|
|
syncSelection(); setUrl("push");
|
|
const restore = mapTiles().find(function (tile) {
|
|
return tile.dataset.minerId === restoreId;
|
|
});
|
|
if (restore) {
|
|
for (const tile of mapTiles()) tile.tabIndex = -1;
|
|
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();
|
|
}());
|