feat: add internal alpha frontend shell
This commit is contained in:
1 parent
eeb3c7634e
commit
b741fd799b
10 files changed
+1982
-15
No files matched your search
@@ -0,0 +1,348 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const view = document.body.dataset.view || "";
|
||||
const canViewMiners =
|
||||
document.body.dataset.canViewMiners === "true";
|
||||
|
||||
function text(value, fallback) {
|
||||
if (value === null || value === undefined || String(value).trim() === "") {
|
||||
return fallback || "Not available";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function statusName(value) {
|
||||
return text(value, "unknown").toLowerCase().replaceAll(" ", "_");
|
||||
}
|
||||
|
||||
function create(tag, options) {
|
||||
const element = document.createElement(tag);
|
||||
const values = options || {};
|
||||
if (values.className) element.className = values.className;
|
||||
if (values.text !== undefined) element.textContent = values.text;
|
||||
if (values.href) element.href = values.href;
|
||||
return element;
|
||||
}
|
||||
|
||||
function showState(name, message) {
|
||||
for (const state of document.querySelectorAll("[data-view-state]")) {
|
||||
state.hidden = state.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;
|
||||
}
|
||||
|
||||
async function api(path) {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(function () {
|
||||
controller.abort();
|
||||
}, 15000);
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal
|
||||
});
|
||||
} catch (error) {
|
||||
if (error && error.name === "AbortError") {
|
||||
throw new Error(
|
||||
"MinerOps took too long to respond. Retry in a moment."
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
"MinerOps could not reach the backend. Check the connection and retry."
|
||||
);
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
if (response.status === 401) {
|
||||
window.location.replace(
|
||||
"/login?next=" +
|
||||
encodeURIComponent(window.location.pathname + window.location.search)
|
||||
);
|
||||
throw new Error("Your session expired. Sign in again to continue.");
|
||||
}
|
||||
if (response.status === 403) {
|
||||
const denied = new Error(
|
||||
"Your account does not have permission to view this information."
|
||||
);
|
||||
denied.permissionDenied = true;
|
||||
throw denied;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
"MinerOps could not load this information. Retry in a moment."
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"MinerOps returned an invalid response. Retry in a moment."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function setupShell() {
|
||||
const menu = document.getElementById("menuButton");
|
||||
const nav = document.getElementById("primaryNav");
|
||||
if (menu && nav) {
|
||||
menu.addEventListener("click", function () {
|
||||
const open = nav.dataset.open !== "true";
|
||||
nav.dataset.open = String(open);
|
||||
menu.setAttribute("aria-expanded", String(open));
|
||||
});
|
||||
}
|
||||
|
||||
const search = document.getElementById("globalSearchForm");
|
||||
const searchInput = document.getElementById("globalSearchInput");
|
||||
if (search && searchInput) {
|
||||
search.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
const query = searchInput.value.trim();
|
||||
const target = query
|
||||
? "/assets/miners?query=" + encodeURIComponent(query)
|
||||
: "/assets/miners";
|
||||
window.location.assign(target);
|
||||
});
|
||||
}
|
||||
|
||||
const logout = document.getElementById("logoutButton");
|
||||
if (logout) {
|
||||
logout.addEventListener("click", async function () {
|
||||
logout.disabled = true;
|
||||
try {
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
credentials: "same-origin"
|
||||
});
|
||||
} finally {
|
||||
window.location.replace("/login");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addCell(row, label, content) {
|
||||
const cell = create("td");
|
||||
cell.dataset.label = label;
|
||||
if (content instanceof Node) cell.appendChild(content);
|
||||
else cell.textContent = text(content);
|
||||
row.appendChild(cell);
|
||||
}
|
||||
|
||||
function status(value) {
|
||||
const label = create("span", {
|
||||
className: "status-text",
|
||||
text: text(value, "Unknown").replaceAll("_", " ")
|
||||
});
|
||||
label.dataset.status = statusName(value);
|
||||
return label;
|
||||
}
|
||||
|
||||
function dateTime(value) {
|
||||
if (!value) return "Not available";
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.valueOf())
|
||||
? String(value)
|
||||
: parsed.toLocaleString();
|
||||
}
|
||||
|
||||
async function loadRepairs() {
|
||||
const rows = document.getElementById("repairRows");
|
||||
if (!rows) return;
|
||||
showState("loading");
|
||||
try {
|
||||
const envelope = await api("/api/repairs/queue");
|
||||
if (
|
||||
!envelope ||
|
||||
!envelope.data ||
|
||||
typeof envelope.data !== "object" ||
|
||||
!Array.isArray(envelope.data.items)
|
||||
) {
|
||||
throw new Error(
|
||||
"MinerOps returned an invalid repair response."
|
||||
);
|
||||
}
|
||||
const items = envelope.data.items;
|
||||
if (!items.every(function (item) {
|
||||
return item &&
|
||||
typeof item === "object" &&
|
||||
typeof item.miner_id === "string" &&
|
||||
item.miner_id.trim() !== "";
|
||||
})) {
|
||||
throw new Error(
|
||||
"MinerOps returned an incomplete repair response."
|
||||
);
|
||||
}
|
||||
rows.replaceChildren();
|
||||
if (!items.length) {
|
||||
showState("empty");
|
||||
return;
|
||||
}
|
||||
for (const repair of items) {
|
||||
const row = create("tr");
|
||||
const identity = create("div");
|
||||
const minerLabel = text(
|
||||
repair.current_mac,
|
||||
text(repair.miner_id, "Unknown miner")
|
||||
);
|
||||
if (canViewMiners) {
|
||||
identity.appendChild(create("a", {
|
||||
className: "object-link",
|
||||
text: minerLabel,
|
||||
href: "/miners/" +
|
||||
encodeURIComponent(repair.miner_id) +
|
||||
"/detail"
|
||||
}));
|
||||
} else {
|
||||
identity.appendChild(create("strong", {
|
||||
text: minerLabel
|
||||
}));
|
||||
}
|
||||
identity.appendChild(create("span", {
|
||||
className: "secondary",
|
||||
text: text(repair.current_ip, "No current IP")
|
||||
}));
|
||||
addCell(row, "Miner", identity);
|
||||
addCell(row, "Model", repair.model);
|
||||
addCell(
|
||||
row,
|
||||
"Repair state",
|
||||
status(repair.latest_event_status || repair.asset_state)
|
||||
);
|
||||
addCell(
|
||||
row,
|
||||
"Reported issue",
|
||||
repair.reported_error ||
|
||||
repair.latest_event_title ||
|
||||
"No reported issue"
|
||||
);
|
||||
addCell(
|
||||
row,
|
||||
"Last update",
|
||||
dateTime(repair.latest_event_at || repair.state_updated_at)
|
||||
);
|
||||
rows.appendChild(row);
|
||||
}
|
||||
showState("ready");
|
||||
} catch (error) {
|
||||
showState(
|
||||
error.permissionDenied ? "denied" : "error",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function locationText(miner) {
|
||||
if (miner.room_id) return "Room " + miner.room_id;
|
||||
if (miner.site_id) return "Site " + miner.site_id;
|
||||
return "Not assigned";
|
||||
}
|
||||
|
||||
async function loadMiners(query) {
|
||||
const rows = document.getElementById("minerRows");
|
||||
if (!rows) return;
|
||||
showState("loading");
|
||||
const params = new URLSearchParams({ limit: "100", offset: "0" });
|
||||
if (query) params.set("query", query);
|
||||
try {
|
||||
const envelope = await api(
|
||||
"/api/inventory/miners?" + params.toString()
|
||||
);
|
||||
if (!envelope || !Array.isArray(envelope.data)) {
|
||||
throw new Error(
|
||||
"MinerOps returned an invalid inventory response."
|
||||
);
|
||||
}
|
||||
const miners = envelope.data;
|
||||
if (!miners.every(function (miner) {
|
||||
return miner &&
|
||||
typeof miner === "object" &&
|
||||
typeof miner.id === "string" &&
|
||||
miner.id.trim() !== "";
|
||||
})) {
|
||||
throw new Error(
|
||||
"MinerOps returned an incomplete inventory response."
|
||||
);
|
||||
}
|
||||
rows.replaceChildren();
|
||||
if (!miners.length) {
|
||||
showState(
|
||||
"empty",
|
||||
query
|
||||
? "No authorized miners match this search."
|
||||
: "No miners have been added or discovered yet."
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const miner of miners) {
|
||||
const row = create("tr");
|
||||
const identity = create("div");
|
||||
const link = create("a", {
|
||||
className: "object-link",
|
||||
text: text(miner.asset_id, "No Asset ID"),
|
||||
href: "/miners/" +
|
||||
encodeURIComponent(miner.id) +
|
||||
"/detail"
|
||||
});
|
||||
identity.appendChild(link);
|
||||
identity.appendChild(create("span", {
|
||||
className: "secondary",
|
||||
text: miner.id
|
||||
}));
|
||||
addCell(row, "Asset ID", identity);
|
||||
addCell(
|
||||
row,
|
||||
"Current MAC",
|
||||
miner.current_mac || "Not observed"
|
||||
);
|
||||
addCell(row, "Model", miner.model);
|
||||
addCell(row, "Lifecycle", status(miner.lifecycle_status));
|
||||
addCell(row, "Runtime", status(miner.runtime_status));
|
||||
addCell(row, "Location", locationText(miner));
|
||||
rows.appendChild(row);
|
||||
}
|
||||
showState("ready");
|
||||
} catch (error) {
|
||||
showState(
|
||||
error.permissionDenied ? "denied" : "error",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function setupMiners() {
|
||||
const form = document.getElementById("minerSearchForm");
|
||||
const input = document.getElementById("minerSearchInput");
|
||||
if (!form || !input) return;
|
||||
const initial =
|
||||
new URLSearchParams(window.location.search).get("query") || "";
|
||||
input.value = initial;
|
||||
form.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
const query = input.value.trim();
|
||||
const url = query
|
||||
? "/assets/miners?query=" + encodeURIComponent(query)
|
||||
: "/assets/miners";
|
||||
window.history.pushState({ query: query }, "", url);
|
||||
loadMiners(query);
|
||||
});
|
||||
window.addEventListener("popstate", function () {
|
||||
const query =
|
||||
new URLSearchParams(window.location.search).get("query") || "";
|
||||
input.value = query;
|
||||
loadMiners(query);
|
||||
});
|
||||
loadMiners(initial);
|
||||
}
|
||||
|
||||
setupShell();
|
||||
if (view === "repairs") loadRepairs();
|
||||
if (view === "miners") setupMiners();
|
||||
})();
|
||||
Reference in new issue
Block a user