feat: add Fleet operations view
This commit is contained in:
1 parent
c6d01df31c
commit
786a1ab910
12 files changed
+1062
-148
No files matched your search
@@ -0,0 +1,357 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
if (document.body.dataset.view !== "miners") return;
|
||||
|
||||
const canViewLocations = document.body.dataset.canViewLocations === "true";
|
||||
const ids = [
|
||||
"fleetFilters", "fleetQuery", "fleetLifecycle", "fleetModel",
|
||||
"fleetRuntime", "fleetRepair", "fleetOperational", "fleetTags",
|
||||
"fleetProblems", "fleetReset", "fleetRows", "fleetResultSummary",
|
||||
"fleetFreshness", "fleetPrevious", "fleetNext", "fleetLimit"
|
||||
];
|
||||
const el = {};
|
||||
for (const id of ids) el[id] = document.getElementById(id);
|
||||
if (canViewLocations) {
|
||||
el.fleetSite = document.getElementById("fleetSite");
|
||||
el.fleetRoom = document.getElementById("fleetRoom");
|
||||
}
|
||||
|
||||
const limits = new Set([25, 50, 100, 250]);
|
||||
const state = { offset: 0, limit: 100, returned: 0, locationOptions: null,
|
||||
siteId: "", roomId: "" };
|
||||
|
||||
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 create(tag, className, value) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (value !== undefined) node.textContent = value;
|
||||
return node;
|
||||
}
|
||||
|
||||
function showState(name, message) {
|
||||
for (const node of document.querySelectorAll("[data-view-state]")) {
|
||||
node.hidden = node.dataset.viewState !== name;
|
||||
}
|
||||
const active = document.querySelector('[data-view-state="' + name + '"]');
|
||||
const detail = active && active.querySelector("[data-state-detail]");
|
||||
if (detail && message) detail.textContent = message;
|
||||
}
|
||||
|
||||
function finite(value) {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function metric(value, suffix) {
|
||||
return finite(value) ? String(value) + suffix : "Unavailable";
|
||||
}
|
||||
|
||||
function age(value) {
|
||||
if (!value) return "Never observed";
|
||||
const time = new Date(value).valueOf();
|
||||
if (Number.isNaN(time)) return "Observation time unavailable";
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - time) / 1000));
|
||||
if (seconds < 60) return seconds + "s ago";
|
||||
if (seconds < 3600) return Math.floor(seconds / 60) + "m ago";
|
||||
if (seconds < 86400) return Math.floor(seconds / 3600) + "h ago";
|
||||
return Math.floor(seconds / 86400) + "d ago";
|
||||
}
|
||||
|
||||
function operational(miner) {
|
||||
if (miner.active_repair || miner.lifecycle_status === "in_repair") {
|
||||
return { key: "repair", label: "In repair", problem: true };
|
||||
}
|
||||
if (miner.lifecycle_status === "retired") {
|
||||
return { key: "retired", label: "Retired", problem: false };
|
||||
}
|
||||
if (miner.lifecycle_status === "spare") {
|
||||
return { key: "spare", label: "Spare", problem: false };
|
||||
}
|
||||
const runtime = words(miner.runtime_status);
|
||||
if (runtime === "offline") {
|
||||
return { key: "offline", label: "Offline", problem: true };
|
||||
}
|
||||
if (miner.hash_stable === false) {
|
||||
return { key: "unstable", label: "Hash unstable", problem: true };
|
||||
}
|
||||
if (runtime === "online") {
|
||||
return { key: "online", label: "Online", problem: false };
|
||||
}
|
||||
return { key: "unknown", label: "Operational state unknown", problem: true };
|
||||
}
|
||||
|
||||
function prefixedLabel(value, prefix) {
|
||||
const label = display(value, "");
|
||||
if (!label) return "";
|
||||
return new RegExp("^" + prefix + "\\b", "i").test(label)
|
||||
? label : prefix + " " + label;
|
||||
}
|
||||
|
||||
function locationText(miner) {
|
||||
if (!canViewLocations || !miner.location_position_id) return "Not mapped";
|
||||
const parts = [];
|
||||
if (miner.site_name) parts.push(miner.site_name);
|
||||
if (miner.room_name) parts.push(miner.room_name);
|
||||
if (miner.rack_label) parts.push(prefixedLabel(miner.rack_label, "Rack"));
|
||||
if (miner.shelf_label) parts.push(prefixedLabel(miner.shelf_label, "Shelf"));
|
||||
parts.push(display(
|
||||
miner.position_label,
|
||||
miner.position_index === null ? "Position unavailable" : "Position " + miner.position_index
|
||||
));
|
||||
return parts.join(" / ");
|
||||
}
|
||||
|
||||
function detailLink(miner) {
|
||||
return "/miners/" + encodeURIComponent(miner.id) + "/detail";
|
||||
}
|
||||
|
||||
function mapLink(miner) {
|
||||
if (!canViewLocations || !miner.location_position_id) return null;
|
||||
const params = new URLSearchParams({ miner_id: miner.id });
|
||||
if (miner.site_id && miner.room_id) {
|
||||
params.set("site_id", miner.site_id);
|
||||
params.set("room_id", miner.room_id);
|
||||
}
|
||||
return "/monitoring/map?" + params.toString();
|
||||
}
|
||||
|
||||
function addCell(row, label, content, className) {
|
||||
const cell = create("td", className || "");
|
||||
cell.dataset.label = label;
|
||||
if (content instanceof Node) cell.appendChild(content);
|
||||
else cell.textContent = display(content);
|
||||
row.appendChild(cell);
|
||||
}
|
||||
|
||||
function stack(primary, secondary) {
|
||||
const box = create("div", "fleet-stack");
|
||||
box.append(create("span", "fleet-primary", display(primary)));
|
||||
if (secondary) box.append(create("span", "secondary", secondary));
|
||||
return box;
|
||||
}
|
||||
|
||||
function renderRow(miner) {
|
||||
const condition = operational(miner);
|
||||
const row = create("tr", "fleet-row");
|
||||
row.dataset.status = condition.key;
|
||||
|
||||
const identity = create("div", "fleet-stack");
|
||||
const link = create("a", "object-link", display(miner.asset_id, "No Asset ID"));
|
||||
link.href = detailLink(miner);
|
||||
identity.append(link, create("span", "secondary fleet-uuid", miner.id));
|
||||
addCell(row, "Miner", identity, "fleet-identity");
|
||||
|
||||
const status = create("div", "fleet-stack");
|
||||
const marker = create("span", "fleet-status", condition.label);
|
||||
marker.dataset.status = condition.key;
|
||||
status.append(marker, create("span", "secondary", age(miner.observed_at)));
|
||||
addCell(row, "Operational", status);
|
||||
addCell(row, "Model / Serial", stack(miner.model, display(miner.miner_sn)));
|
||||
addCell(row, "Network", stack(miner.current_mac, display(miner.current_ip)));
|
||||
addCell(row, "Location", locationText(miner), "fleet-location");
|
||||
addCell(row, "Hashrate", metric(miner.hashrate_ths, " TH/s"));
|
||||
addCell(row, "Power", metric(miner.power_w, " W"));
|
||||
addCell(row, "Max chip temp", metric(miner.chip_temp_max_c, " C"));
|
||||
const repair = miner.active_repair
|
||||
? words(miner.active_repair.status) + " / " + display(miner.active_repair.reason)
|
||||
: "No active repair";
|
||||
addCell(row, "Lifecycle / Repair", stack(words(miner.lifecycle_status), repair));
|
||||
|
||||
const actions = create("div", "fleet-actions");
|
||||
const open = create("a", "command-link", "Open miner");
|
||||
open.href = detailLink(miner);
|
||||
actions.appendChild(open);
|
||||
const mapHref = mapLink(miner);
|
||||
if (mapHref) {
|
||||
const map = create("a", "quiet-link", "View on Mine Map");
|
||||
map.href = mapHref;
|
||||
actions.appendChild(map);
|
||||
}
|
||||
addCell(row, "Actions", actions);
|
||||
return row;
|
||||
}
|
||||
|
||||
function option(value, label) {
|
||||
const node = document.createElement("option");
|
||||
node.value = value;
|
||||
node.textContent = label;
|
||||
return node;
|
||||
}
|
||||
|
||||
function syncLocationOptions(selectedSite, selectedRoom) {
|
||||
if (!canViewLocations || !state.locationOptions) return;
|
||||
el.fleetSite.replaceChildren(option("", "All sites"));
|
||||
for (const site of state.locationOptions.sites || []) {
|
||||
el.fleetSite.appendChild(option(String(site.id), site.name));
|
||||
}
|
||||
el.fleetSite.value = Array.from(el.fleetSite.options).some(function (item) {
|
||||
return item.value === selectedSite;
|
||||
}) ? selectedSite : "";
|
||||
el.fleetRoom.replaceChildren(option("", "All rooms"));
|
||||
for (const room of state.locationOptions.rooms || []) {
|
||||
if (!el.fleetSite.value || String(room.site_id) === el.fleetSite.value) {
|
||||
el.fleetRoom.appendChild(option(String(room.id), room.name));
|
||||
}
|
||||
}
|
||||
el.fleetRoom.value = Array.from(el.fleetRoom.options).some(function (item) {
|
||||
return item.value === selectedRoom;
|
||||
}) ? selectedRoom : "";
|
||||
state.siteId = el.fleetSite.value;
|
||||
state.roomId = el.fleetRoom.value;
|
||||
}
|
||||
|
||||
function readUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
el.fleetQuery.value = (params.get("query") || "").slice(0, 255);
|
||||
el.fleetLifecycle.value = params.get("lifecycle_status") || "";
|
||||
el.fleetModel.value = (params.get("model") || "").slice(0, 255);
|
||||
el.fleetRuntime.value = params.get("runtime_status") || "";
|
||||
el.fleetRepair.value = params.get("repair") || "";
|
||||
el.fleetOperational.value = params.get("operational_state") || "";
|
||||
el.fleetTags.value = params.getAll("tags").join(", ");
|
||||
el.fleetProblems.checked = params.get("problems") === "1";
|
||||
const requestedLimit = Number(params.get("limit"));
|
||||
state.limit = limits.has(requestedLimit) ? requestedLimit : 100;
|
||||
state.offset = Math.max(0, Number.parseInt(params.get("offset") || "0", 10) || 0);
|
||||
el.fleetLimit.value = String(state.limit);
|
||||
if (canViewLocations) {
|
||||
state.siteId = params.get("site_id") || "";
|
||||
state.roomId = params.get("room_id") || "";
|
||||
syncLocationOptions(state.siteId, state.roomId);
|
||||
}
|
||||
}
|
||||
|
||||
function requestParams() {
|
||||
const params = new URLSearchParams();
|
||||
const values = {
|
||||
query: el.fleetQuery.value.trim(),
|
||||
lifecycle_status: el.fleetLifecycle.value,
|
||||
model: el.fleetModel.value.trim(),
|
||||
runtime_status: el.fleetRuntime.value,
|
||||
repair: el.fleetRepair.value,
|
||||
operational_state: el.fleetOperational.value,
|
||||
problems: el.fleetProblems.checked ? "1" : "",
|
||||
site_id: canViewLocations ? state.siteId : "",
|
||||
room_id: canViewLocations ? state.roomId : "",
|
||||
limit: String(state.limit), offset: String(state.offset)
|
||||
};
|
||||
for (const pair of Object.entries(values)) {
|
||||
if (pair[1]) params.set(pair[0], pair[1]);
|
||||
}
|
||||
for (const tag of el.fleetTags.value.split(",")) {
|
||||
const clean = tag.trim();
|
||||
if (clean) params.append("tags", clean);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function setUrl(mode) {
|
||||
const params = requestParams();
|
||||
window.history[mode === "push" ? "pushState" : "replaceState"](
|
||||
{}, "", "/assets/miners?" + params.toString()
|
||||
);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
showState("loading");
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(function () { controller.abort(); }, 15000);
|
||||
try {
|
||||
const response = await fetch("/api/inventory/miners?" + requestParams().toString(), {
|
||||
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) {
|
||||
const denied = new Error("Your account cannot view this fleet information.");
|
||||
denied.permissionDenied = true;
|
||||
throw denied;
|
||||
}
|
||||
if (!response.ok) throw new Error("The fleet service returned " + response.status + ".");
|
||||
const envelope = await response.json();
|
||||
if (!envelope || !Array.isArray(envelope.data) || !envelope.meta) {
|
||||
throw new Error("MinerOps returned an incomplete fleet response.");
|
||||
}
|
||||
state.returned = envelope.data.length;
|
||||
state.locationOptions = envelope.meta.location_options || null;
|
||||
const selectedSite = canViewLocations ? state.siteId : "";
|
||||
const selectedRoom = canViewLocations ? state.roomId : "";
|
||||
syncLocationOptions(selectedSite, selectedRoom);
|
||||
el.fleetRows.replaceChildren();
|
||||
for (const miner of envelope.data) el.fleetRows.appendChild(renderRow(miner));
|
||||
if (!envelope.data.length) {
|
||||
el.fleetPrevious.disabled = state.offset === 0;
|
||||
el.fleetNext.disabled = true;
|
||||
showState("empty", state.offset
|
||||
? "No miners are present on this page. Return to the previous page."
|
||||
: "No authorized miners match the current filters.");
|
||||
return;
|
||||
}
|
||||
el.fleetPrevious.disabled = state.offset === 0;
|
||||
el.fleetNext.disabled = state.returned < state.limit;
|
||||
const first = state.offset + 1;
|
||||
const last = state.offset + state.returned;
|
||||
el.fleetResultSummary.textContent = "Showing miners " + first + "-" + last +
|
||||
". Total fleet size is not calculated.";
|
||||
el.fleetFreshness.textContent = state.returned + " miners on this page";
|
||||
showState("ready");
|
||||
} catch (error) {
|
||||
showState(error.permissionDenied ? "denied" : "error",
|
||||
error && error.name === "AbortError"
|
||||
? "MinerOps took too long to respond. Retry in a moment."
|
||||
: display(error && error.message, "The fleet could not be loaded."));
|
||||
} finally {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
el.fleetFilters.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
if (canViewLocations) {
|
||||
state.siteId = el.fleetSite.value; state.roomId = el.fleetRoom.value;
|
||||
}
|
||||
state.offset = 0; setUrl("push"); load();
|
||||
});
|
||||
el.fleetReset.addEventListener("click", function () {
|
||||
el.fleetFilters.reset(); state.offset = 0; state.limit = 100;
|
||||
state.siteId = ""; state.roomId = "";
|
||||
syncLocationOptions("", ""); setUrl("push"); load();
|
||||
});
|
||||
el.fleetPrevious.addEventListener("click", function () {
|
||||
state.offset = Math.max(0, state.offset - state.limit); setUrl("push"); load();
|
||||
});
|
||||
el.fleetNext.addEventListener("click", function () {
|
||||
state.offset += state.limit; setUrl("push"); load();
|
||||
});
|
||||
el.fleetLimit.addEventListener("change", function () {
|
||||
state.limit = Number(el.fleetLimit.value); state.offset = 0; setUrl("push"); load();
|
||||
});
|
||||
if (canViewLocations) {
|
||||
el.fleetSite.addEventListener("change", function () {
|
||||
state.siteId = el.fleetSite.value; state.roomId = "";
|
||||
syncLocationOptions(state.siteId, "");
|
||||
});
|
||||
el.fleetRoom.addEventListener("change", function () {
|
||||
state.roomId = el.fleetRoom.value;
|
||||
});
|
||||
}
|
||||
window.addEventListener("popstate", function () { readUrl(); load(); });
|
||||
|
||||
readUrl();
|
||||
load();
|
||||
}());
|
||||
Reference in new issue
Block a user