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
@@ -54,6 +54,15 @@ def mine_map_script() -> Response:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/assets/minerops-fleet.js", include_in_schema=False)
|
||||||
|
def fleet_script() -> Response:
|
||||||
|
return Response(
|
||||||
|
(STATIC_ROOT / "fleet.js").read_text(encoding="utf-8"),
|
||||||
|
media_type="text/javascript",
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
|
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
|
||||||
def dashboard_page(current_user: DashboardUser) -> str:
|
def dashboard_page(current_user: DashboardUser) -> str:
|
||||||
return render_page("dashboard", current_user)
|
return render_page("dashboard", current_user)
|
||||||
|
|||||||
@@ -1246,3 +1246,114 @@ button.map-position:hover {
|
|||||||
.map-search { grid-column: auto; }
|
.map-search { grid-column: auto; }
|
||||||
.position-grid { grid-template-columns: repeat(2, minmax(104px, 1fr)); }
|
.position-grid { grid-template-columns: repeat(2, minmax(104px, 1fr)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fleet operations */
|
||||||
|
.fleet-panel { padding: 14px; }
|
||||||
|
.fleet-filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 1.6fr) repeat(5, minmax(130px, 1fr));
|
||||||
|
align-items: end;
|
||||||
|
gap: 9px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.fleet-filters .field { min-width: 0; }
|
||||||
|
.fleet-filters .field input,
|
||||||
|
.fleet-filters .field select { min-height: 38px; }
|
||||||
|
.fleet-search { grid-column: span 2; }
|
||||||
|
.check-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 38px;
|
||||||
|
gap: 7px;
|
||||||
|
color: #40505f;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
.check-control input { width: 17px; height: 17px; }
|
||||||
|
.fleet-filter-actions { display: flex; gap: 7px; }
|
||||||
|
.fleet-filter-actions .quiet-button { color: var(--ink); }
|
||||||
|
.fleet-table { min-width: 1320px; }
|
||||||
|
.fleet-table th,
|
||||||
|
.fleet-table td { padding: 8px 9px; }
|
||||||
|
.fleet-table th { white-space: nowrap; }
|
||||||
|
.fleet-row { border-left: 4px solid transparent; }
|
||||||
|
.fleet-row[data-status="offline"] { border-left-color: #b42318; background: #fff5f4; }
|
||||||
|
.fleet-row[data-status="unstable"] { border-left-color: #9a5b00; background: #fff9ed; }
|
||||||
|
.fleet-row[data-status="repair"] { border-left-color: #6b4ca5; background: #f7f3fc; }
|
||||||
|
.fleet-row[data-status="unknown"] { border-left-color: #596675; background: #f7f8fa; }
|
||||||
|
.fleet-row[data-status="online"],
|
||||||
|
.fleet-row[data-status="spare"],
|
||||||
|
.fleet-row[data-status="retired"] { border-left-color: #c6ced5; }
|
||||||
|
.fleet-stack { display: grid; gap: 2px; }
|
||||||
|
.fleet-primary { font-weight: 700; }
|
||||||
|
.fleet-status { font-weight: 750; white-space: nowrap; }
|
||||||
|
.fleet-status::before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
margin-right: 7px;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.fleet-status[data-status="online"] { color: #315c45; }
|
||||||
|
.fleet-status[data-status="offline"] { color: #8f1d14; }
|
||||||
|
.fleet-status[data-status="unstable"] { color: #754600; }
|
||||||
|
.fleet-status[data-status="repair"] { color: #523884; }
|
||||||
|
.fleet-status[data-status="unknown"] { color: #354351; }
|
||||||
|
.fleet-status[data-status="spare"] { color: #075b91; }
|
||||||
|
.fleet-status[data-status="retired"] { color: #4d5660; }
|
||||||
|
.fleet-uuid { max-width: 150px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.fleet-location { min-width: 180px; max-width: 260px; }
|
||||||
|
.fleet-actions { display: grid; gap: 6px; white-space: nowrap; }
|
||||||
|
.quiet-link { color: #40505f; font-weight: 700; }
|
||||||
|
.fleet-result-summary { margin: 0 0 8px; color: var(--muted); font-size: 12px; }
|
||||||
|
.fleet-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 9px;
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #40505f;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.fleet-pagination select { min-height: 34px; }
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.fleet-filters { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.fleet-search { grid-column: span 2; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.fleet-panel { padding: 12px; }
|
||||||
|
.fleet-panel .panel-head { align-items: flex-start; }
|
||||||
|
.fleet-filters { grid-template-columns: 1fr 1fr; }
|
||||||
|
.fleet-search { grid-column: 1 / -1; }
|
||||||
|
.fleet-filter-actions { grid-column: 1 / -1; }
|
||||||
|
.fleet-filter-actions > * { flex: 1; }
|
||||||
|
.fleet-table { min-width: 0; }
|
||||||
|
.fleet-table tbody { gap: 12px; }
|
||||||
|
.fleet-table tr { border-left-width: 4px; padding: 8px; }
|
||||||
|
.fleet-table td {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(95px, .8fr) minmax(0, 1.6fr);
|
||||||
|
gap: 10px;
|
||||||
|
padding: 7px 5px;
|
||||||
|
}
|
||||||
|
.fleet-table td::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
color: #40505f;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
.fleet-location { min-width: 0; max-width: none; }
|
||||||
|
.fleet-actions { white-space: normal; }
|
||||||
|
.fleet-pagination { justify-content: space-between; flex-wrap: wrap; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.fleet-filters { grid-template-columns: 1fr; }
|
||||||
|
.fleet-search, .fleet-filter-actions { grid-column: auto; }
|
||||||
|
.fleet-table td { grid-template-columns: 88px minmax(0, 1fr); }
|
||||||
|
}
|
||||||
@@ -239,110 +239,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
setupShell();
|
||||||
if (view === "repairs") loadRepairs();
|
if (view === "repairs") loadRepairs();
|
||||||
if (view === "miners") setupMiners();
|
|
||||||
})();
|
})();
|
||||||
@@ -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();
|
||||||
|
}());
|
||||||
@@ -222,33 +222,72 @@ def _repairs_content() -> str:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _miners_content() -> str:
|
def _miners_content(can_view_locations: bool) -> str:
|
||||||
|
location_filters = """
|
||||||
|
<div class="field"><label for="fleetSite">Site</label>
|
||||||
|
<select id="fleetSite"><option value="">All sites</option></select></div>
|
||||||
|
<div class="field"><label for="fleetRoom">Room</label>
|
||||||
|
<select id="fleetRoom"><option value="">All rooms</option></select></div>
|
||||||
|
""" if can_view_locations else ""
|
||||||
return f"""
|
return f"""
|
||||||
<section class="panel" aria-labelledby="minerListTitle">
|
<section class="panel fleet-panel" aria-labelledby="minerListTitle">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div><h2 id="minerListTitle">Fleet</h2>
|
||||||
<h2 id="minerListTitle">Miner inventory</h2>
|
<p class="panel-copy">Current miner identity, operating state, telemetry, and repair context.</p></div>
|
||||||
<p class="panel-copy">Real inventory records. Search matches the
|
<span class="freshness" id="fleetFreshness">Loading current fleet</span>
|
||||||
fields supported by the Inventory API.</p>
|
|
||||||
</div>
|
|
||||||
<span class="freshness">Up to 100 results</span>
|
|
||||||
</div>
|
</div>
|
||||||
<form class="filter-bar" id="minerSearchForm" role="search">
|
<form class="fleet-filters" id="fleetFilters" role="search">
|
||||||
<div class="field">
|
<div class="field fleet-search"><label for="fleetQuery">Search miners</label>
|
||||||
<label for="minerSearchInput">Search miners</label>
|
<input id="fleetQuery" type="search" autocomplete="off"
|
||||||
<input id="minerSearchInput" type="search" autocomplete="off"
|
placeholder="Asset ID, MAC, IP, model, or serial"></div>
|
||||||
placeholder="Asset ID, MAC, serial, model, or IP">
|
<div class="field"><label for="fleetLifecycle">Lifecycle</label>
|
||||||
|
<select id="fleetLifecycle"><option value="">All lifecycle states</option>
|
||||||
|
<option value="active">Active</option><option value="spare">Spare</option>
|
||||||
|
<option value="in_repair">In repair</option><option value="retired">Retired</option></select></div>
|
||||||
|
<div class="field"><label for="fleetModel">Model</label>
|
||||||
|
<input id="fleetModel" type="text" autocomplete="off" placeholder="Any model"></div>
|
||||||
|
<div class="field"><label for="fleetRuntime">Runtime</label>
|
||||||
|
<select id="fleetRuntime"><option value="">All runtime states</option>
|
||||||
|
<option value="online">Online</option><option value="offline">Offline</option>
|
||||||
|
<option value="unknown">Unknown</option></select></div>
|
||||||
|
<div class="field"><label for="fleetRepair">Repair</label>
|
||||||
|
<select id="fleetRepair"><option value="">Any repair state</option>
|
||||||
|
<option value="active">Active repair</option><option value="none">No active repair</option></select></div>
|
||||||
|
<div class="field"><label for="fleetOperational">Operational state</label>
|
||||||
|
<select id="fleetOperational"><option value="">All operational states</option>
|
||||||
|
<option value="online">Online</option><option value="offline">Offline</option>
|
||||||
|
<option value="unstable">Hash unstable</option><option value="repair">In repair</option>
|
||||||
|
<option value="spare">Spare</option><option value="retired">Retired</option>
|
||||||
|
<option value="unknown">Unknown</option></select></div>
|
||||||
|
{location_filters}
|
||||||
|
<div class="field"><label for="fleetTags">Tags</label>
|
||||||
|
<input id="fleetTags" type="text" autocomplete="off" placeholder="Comma separated"></div>
|
||||||
|
<label class="check-control" for="fleetProblems">
|
||||||
|
<input id="fleetProblems" type="checkbox"> Problems only</label>
|
||||||
|
<div class="fleet-filter-actions">
|
||||||
|
<button class="command-button" type="submit">Apply</button>
|
||||||
|
<button class="quiet-button" id="fleetReset" type="button">Reset</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="command-button" type="submit">Search</button>
|
|
||||||
</form>
|
</form>
|
||||||
{_state_messages("miners")}
|
{_state_messages("miners")}
|
||||||
<div class="table-wrap" data-view-state="ready" hidden>
|
<div data-view-state="ready" hidden>
|
||||||
<table>
|
<p class="fleet-result-summary" id="fleetResultSummary" aria-live="polite"></p>
|
||||||
<thead><tr><th>Asset ID</th><th>Current MAC</th><th>Model</th>
|
<div class="table-wrap fleet-table-wrap">
|
||||||
<th>Lifecycle</th><th>Runtime</th><th>Location</th></tr></thead>
|
<table class="fleet-table">
|
||||||
<tbody id="minerRows"></tbody>
|
<thead><tr><th>Miner</th><th>Operational</th><th>Model / Serial</th>
|
||||||
</table>
|
<th>Network</th><th>Location</th><th>Hashrate</th><th>Power</th>
|
||||||
|
<th>Max chip temp</th><th>Lifecycle / Repair</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody id="fleetRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<nav class="fleet-pagination" aria-label="Fleet pages">
|
||||||
|
<button class="quiet-button" id="fleetPrevious" type="button" disabled>Previous</button>
|
||||||
|
<label for="fleetLimit">Rows per page</label>
|
||||||
|
<select id="fleetLimit"><option value="25">25</option><option value="50">50</option>
|
||||||
|
<option value="100">100</option><option value="250">250</option></select>
|
||||||
|
<button class="quiet-button" id="fleetNext" type="button" disabled>Next</button>
|
||||||
|
</nav>
|
||||||
</section>
|
</section>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -297,11 +336,15 @@ def render_app_page(
|
|||||||
content = (
|
content = (
|
||||||
_map_content(has_permission("locations.manage"))
|
_map_content(has_permission("locations.manage"))
|
||||||
if view == "map"
|
if view == "map"
|
||||||
|
else _miners_content(has_permission("locations.view"))
|
||||||
|
if view == "miners"
|
||||||
else CONTENT[view]()
|
else CONTENT[view]()
|
||||||
)
|
)
|
||||||
map_script = (
|
page_script = (
|
||||||
'<script src="/assets/minerops-mine-map.js" defer></script>'
|
'<script src="/assets/minerops-mine-map.js" defer></script>'
|
||||||
if view == "map"
|
if view == "map"
|
||||||
|
else '<script src="/assets/minerops-fleet.js" defer></script>'
|
||||||
|
if view == "miners"
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
return f"""<!doctype html>
|
return f"""<!doctype html>
|
||||||
@@ -313,7 +356,8 @@ def render_app_page(
|
|||||||
<link rel="stylesheet" href="/assets/minerops-app.css">
|
<link rel="stylesheet" href="/assets/minerops-app.css">
|
||||||
</head>
|
</head>
|
||||||
<body data-view="{escape(view)}"
|
<body data-view="{escape(view)}"
|
||||||
data-can-view-miners="{str(can_view_miners).lower()}">
|
data-can-view-miners="{str(can_view_miners).lower()}"
|
||||||
|
data-can-view-locations="{str(has_permission("locations.view")).lower()}">
|
||||||
<a class="skip-link" href="#mainContent">Skip to main content</a>
|
<a class="skip-link" href="#mainContent">Skip to main content</a>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@@ -358,6 +402,6 @@ def render_app_page(
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script src="/assets/minerops-app.js" defer></script>
|
<script src="/assets/minerops-app.js" defer></script>
|
||||||
{map_script}
|
{page_script}
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
@@ -17,16 +17,36 @@ class InventoryTag(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryActiveRepair(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
status: str
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
class InventoryMinerSummary(BaseModel):
|
class InventoryMinerSummary(BaseModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
asset_id: str | None = None
|
asset_id: str | None = None
|
||||||
current_mac: str | None = None
|
current_mac: str | None = None
|
||||||
current_ip: str | None = None
|
current_ip: str | None = None
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
|
miner_sn: str | None = None
|
||||||
lifecycle_status: LifecycleStatus
|
lifecycle_status: LifecycleStatus
|
||||||
runtime_status: str
|
runtime_status: str
|
||||||
site_id: UUID | None = None
|
site_id: UUID | None = None
|
||||||
|
site_name: str | None = None
|
||||||
room_id: UUID | None = None
|
room_id: UUID | None = None
|
||||||
|
room_name: str | None = None
|
||||||
|
location_position_id: UUID | None = None
|
||||||
|
rack_label: str | None = None
|
||||||
|
shelf_label: str | None = None
|
||||||
|
position_label: str | None = None
|
||||||
|
position_index: int | None = None
|
||||||
|
observed_at: datetime | None = None
|
||||||
|
hashrate_ths: float | None = None
|
||||||
|
power_w: float | None = None
|
||||||
|
chip_temp_max_c: float | None = None
|
||||||
|
hash_stable: bool | None = None
|
||||||
|
active_repair: InventoryActiveRepair | None = None
|
||||||
tags: list[InventoryTag] = Field(default_factory=list)
|
tags: list[InventoryTag] = Field(default_factory=list)
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
@@ -35,7 +55,6 @@ class InventoryMinerSummary(BaseModel):
|
|||||||
|
|
||||||
class InventoryMinerDetail(InventoryMinerSummary):
|
class InventoryMinerDetail(InventoryMinerSummary):
|
||||||
hostname: str | None = None
|
hostname: str | None = None
|
||||||
miner_sn: str | None = None
|
|
||||||
control_board_sn: str | None = None
|
control_board_sn: str | None = None
|
||||||
psu_serial: str | None = None
|
psu_serial: str | None = None
|
||||||
firmware_version: str | None = None
|
firmware_version: str | None = None
|
||||||
@@ -99,15 +118,41 @@ class InventorySearchFilters(BaseModel):
|
|||||||
query: str | None = None
|
query: str | None = None
|
||||||
asset_id: str | None = None
|
asset_id: str | None = None
|
||||||
lifecycle_status: str | None = None
|
lifecycle_status: str | None = None
|
||||||
|
model: str | None = None
|
||||||
|
runtime_status: str | None = None
|
||||||
|
repair: Literal["active", "none"] | None = None
|
||||||
|
operational_state: Literal[
|
||||||
|
"online", "offline", "unknown", "unstable", "repair", "spare", "retired"
|
||||||
|
] | None = None
|
||||||
|
problems: bool = False
|
||||||
|
site_id: UUID | None = None
|
||||||
|
room_id: UUID | None = None
|
||||||
tags: list[str] = Field(default_factory=list)
|
tags: list[str] = Field(default_factory=list)
|
||||||
limit: int = Field(default=100, ge=1, le=500)
|
limit: int = Field(default=100, ge=1, le=500)
|
||||||
offset: int = Field(default=0, ge=0)
|
offset: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class InventorySiteOption(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryRoomOption(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
site_id: UUID
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryLocationOptions(BaseModel):
|
||||||
|
sites: list[InventorySiteOption] = Field(default_factory=list)
|
||||||
|
rooms: list[InventoryRoomOption] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class InventoryListMeta(BaseModel):
|
class InventoryListMeta(BaseModel):
|
||||||
limit: int
|
limit: int
|
||||||
offset: int
|
offset: int
|
||||||
returned: int
|
returned: int
|
||||||
|
location_options: InventoryLocationOptions = Field(default_factory=InventoryLocationOptions)
|
||||||
|
|
||||||
|
|
||||||
class InventoryMinerListResponse(BaseModel):
|
class InventoryMinerListResponse(BaseModel):
|
||||||
|
|||||||
@@ -59,6 +59,101 @@ MINER_SELECT = f"""
|
|||||||
) tag_data ON true
|
) tag_data ON true
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
ACTIVE_REPAIR_STATUSES = (
|
||||||
|
"OPEN",
|
||||||
|
"IN_PROGRESS",
|
||||||
|
"WAITING_FOR_PARTS",
|
||||||
|
"READY_FOR_TEST",
|
||||||
|
)
|
||||||
|
|
||||||
|
OPERATIONAL_STATE_SQL = """
|
||||||
|
CASE
|
||||||
|
WHEN active_repair.id IS NOT NULL OR miners.lifecycle_status = 'in_repair'
|
||||||
|
THEN 'repair'
|
||||||
|
WHEN miners.lifecycle_status = 'retired' THEN 'retired'
|
||||||
|
WHEN miners.lifecycle_status = 'spare' THEN 'spare'
|
||||||
|
WHEN lower(trim(miners.status)) = 'offline' THEN 'offline'
|
||||||
|
WHEN latest.hash_stable = false THEN 'unstable'
|
||||||
|
WHEN lower(trim(miners.status)) = 'online' THEN 'online'
|
||||||
|
ELSE 'unknown'
|
||||||
|
END
|
||||||
|
"""
|
||||||
|
|
||||||
|
FLEET_SELECT = f"""
|
||||||
|
SELECT
|
||||||
|
miners.id, miners.asset_tag, miners.current_mac,
|
||||||
|
host(miners.current_ip) AS current_ip, miners.model, miners.miner_sn,
|
||||||
|
miners.lifecycle_status, miners.status, miners.created_at,
|
||||||
|
miners.updated_at, miners.last_seen_at, tag_data.tags,
|
||||||
|
latest.observed_at, latest.hashrate_ths, latest.power_w,
|
||||||
|
latest.chip_temp_max_c, latest.hash_stable,
|
||||||
|
active_repair.id AS active_repair_id,
|
||||||
|
active_repair.status AS active_repair_status,
|
||||||
|
active_repair.reason AS active_repair_reason,
|
||||||
|
{{location_fields}}
|
||||||
|
FROM miners
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COALESCE(
|
||||||
|
jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'id', tags.id, 'name', tags.name,
|
||||||
|
'created_at', tags.created_at, 'updated_at', tags.updated_at
|
||||||
|
) ORDER BY lower({TAG_TRIMMED_NAME_SQL}), tags.id
|
||||||
|
), '[]'::jsonb
|
||||||
|
) AS tags
|
||||||
|
FROM miner_tags JOIN tags ON tags.id = miner_tags.tag_id
|
||||||
|
WHERE miner_tags.miner_id = miners.id
|
||||||
|
) tag_data ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT snapshots.observed_at,
|
||||||
|
ROUND((snapshots.mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths,
|
||||||
|
ROUND(snapshots.power_w::numeric, 2)::float AS power_w,
|
||||||
|
ROUND(snapshots.chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c,
|
||||||
|
snapshots.hash_stable
|
||||||
|
FROM miner_status_snapshots snapshots
|
||||||
|
WHERE snapshots.miner_id = miners.id
|
||||||
|
ORDER BY snapshots.observed_at DESC, snapshots.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) latest ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT repairs.id, repairs.status, repairs.reason
|
||||||
|
FROM repairs
|
||||||
|
WHERE repairs.miner_id = miners.id AND repairs.status = ANY(%s)
|
||||||
|
ORDER BY repairs.opened_at, repairs.id
|
||||||
|
LIMIT 1
|
||||||
|
) active_repair ON true
|
||||||
|
{{location_join}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
LOCATION_FIELDS = """
|
||||||
|
mapped.site_id, mapped.site_name, mapped.room_id, mapped.room_name,
|
||||||
|
mapped.location_position_id, mapped.rack_label, mapped.shelf_label,
|
||||||
|
mapped.position_label, mapped.position_index
|
||||||
|
"""
|
||||||
|
|
||||||
|
REDACTED_LOCATION_FIELDS = """
|
||||||
|
NULL::uuid AS site_id, NULL::text AS site_name,
|
||||||
|
NULL::uuid AS room_id, NULL::text AS room_name,
|
||||||
|
NULL::uuid AS location_position_id, NULL::text AS rack_label,
|
||||||
|
NULL::text AS shelf_label, NULL::text AS position_label,
|
||||||
|
NULL::integer AS position_index
|
||||||
|
"""
|
||||||
|
|
||||||
|
LOCATION_JOIN = """
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT sites.id AS site_id, sites.name AS site_name, rooms.id AS room_id,
|
||||||
|
COALESCE(rooms.name, lp.room_name) AS room_name,
|
||||||
|
lp.id AS location_position_id, lp.rack_name AS rack_label,
|
||||||
|
lp.shelf_name AS shelf_label, lp.position_label, lp.position_index
|
||||||
|
FROM location_positions lp
|
||||||
|
LEFT JOIN rooms ON rooms.id = lp.room_id
|
||||||
|
LEFT JOIN sites ON sites.id = rooms.site_id
|
||||||
|
WHERE lp.miner_id = miners.id
|
||||||
|
ORDER BY lp.id
|
||||||
|
LIMIT 1
|
||||||
|
) mapped ON true
|
||||||
|
"""
|
||||||
|
|
||||||
MINER_WRITE_COLUMNS = {
|
MINER_WRITE_COLUMNS = {
|
||||||
"site_id",
|
"site_id",
|
||||||
"room_id",
|
"room_id",
|
||||||
@@ -184,8 +279,18 @@ class InventoryRepository:
|
|||||||
(asset_id,),
|
(asset_id,),
|
||||||
)
|
)
|
||||||
|
|
||||||
def list_miners(self, *, limit: int = 100, offset: int = 0) -> list[dict]:
|
def list_miners(
|
||||||
return self.search_miners(limit=limit, offset=offset)
|
self,
|
||||||
|
*,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
include_location_details: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
|
return self.search_miners(
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
include_location_details=include_location_details,
|
||||||
|
)
|
||||||
|
|
||||||
def search_miners(
|
def search_miners(
|
||||||
self,
|
self,
|
||||||
@@ -193,12 +298,20 @@ class InventoryRepository:
|
|||||||
query: str | None = None,
|
query: str | None = None,
|
||||||
asset_id: str | None = None,
|
asset_id: str | None = None,
|
||||||
lifecycle_status: str | None = None,
|
lifecycle_status: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
runtime_status: str | None = None,
|
||||||
|
repair: str | None = None,
|
||||||
|
operational_state: str | None = None,
|
||||||
|
problems: bool = False,
|
||||||
|
site_id: UUID | None = None,
|
||||||
|
room_id: UUID | None = None,
|
||||||
tag_names: Sequence[str] = (),
|
tag_names: Sequence[str] = (),
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
|
include_location_details: bool = False,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
conditions: list[str] = []
|
conditions: list[str] = []
|
||||||
params: list[Any] = []
|
params: list[Any] = [list(ACTIVE_REPAIR_STATUSES)]
|
||||||
|
|
||||||
if query:
|
if query:
|
||||||
pattern = f"%{query}%"
|
pattern = f"%{query}%"
|
||||||
@@ -217,15 +330,36 @@ class InventoryRepository:
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
params.extend([pattern] * 8)
|
params.extend([pattern] * 8)
|
||||||
|
|
||||||
if asset_id:
|
if asset_id:
|
||||||
conditions.append("lower(trim(miners.asset_tag)) = lower(trim(%s))")
|
conditions.append("lower(trim(miners.asset_tag)) = lower(trim(%s))")
|
||||||
params.append(asset_id)
|
params.append(asset_id)
|
||||||
|
|
||||||
if lifecycle_status:
|
if lifecycle_status:
|
||||||
conditions.append("miners.lifecycle_status = %s")
|
conditions.append("miners.lifecycle_status = %s")
|
||||||
params.append(lifecycle_status)
|
params.append(lifecycle_status)
|
||||||
|
if model:
|
||||||
|
conditions.append("lower(trim(miners.model)) = lower(trim(%s))")
|
||||||
|
params.append(model)
|
||||||
|
if runtime_status:
|
||||||
|
conditions.append("lower(trim(miners.status)) = lower(trim(%s))")
|
||||||
|
params.append(runtime_status)
|
||||||
|
if repair == "active":
|
||||||
|
conditions.append("active_repair.id IS NOT NULL")
|
||||||
|
elif repair == "none":
|
||||||
|
conditions.append("active_repair.id IS NULL")
|
||||||
|
if operational_state:
|
||||||
|
conditions.append(f"({OPERATIONAL_STATE_SQL}) = %s")
|
||||||
|
params.append(operational_state)
|
||||||
|
if problems:
|
||||||
|
conditions.append(
|
||||||
|
f"({OPERATIONAL_STATE_SQL}) = ANY(%s)"
|
||||||
|
)
|
||||||
|
params.append(["offline", "unknown", "unstable", "repair"])
|
||||||
|
if site_id:
|
||||||
|
conditions.append("mapped.site_id = %s")
|
||||||
|
params.append(site_id)
|
||||||
|
if room_id:
|
||||||
|
conditions.append("mapped.room_id = %s")
|
||||||
|
params.append(room_id)
|
||||||
for tag_name in tag_names:
|
for tag_name in tag_names:
|
||||||
conditions.append(
|
conditions.append(
|
||||||
f"""
|
f"""
|
||||||
@@ -241,6 +375,14 @@ class InventoryRepository:
|
|||||||
)
|
)
|
||||||
params.append(tag_name)
|
params.append(tag_name)
|
||||||
|
|
||||||
|
location_fields = (
|
||||||
|
LOCATION_FIELDS if include_location_details else REDACTED_LOCATION_FIELDS
|
||||||
|
)
|
||||||
|
location_join = LOCATION_JOIN if include_location_details else ""
|
||||||
|
select_sql = FLEET_SELECT.format(
|
||||||
|
location_fields=location_fields,
|
||||||
|
location_join=location_join,
|
||||||
|
)
|
||||||
where_sql = " AND ".join(conditions) if conditions else "true"
|
where_sql = " AND ".join(conditions) if conditions else "true"
|
||||||
params.extend([limit, offset])
|
params.extend([limit, offset])
|
||||||
|
|
||||||
@@ -248,18 +390,44 @@ class InventoryRepository:
|
|||||||
with connection.cursor(row_factory=dict_row) as cur:
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
{MINER_SELECT}
|
{select_sql}
|
||||||
WHERE {where_sql}
|
WHERE {where_sql}
|
||||||
ORDER BY
|
ORDER BY lower(trim(miners.asset_tag)) NULLS LAST,
|
||||||
lower(trim(miners.asset_tag)) NULLS LAST,
|
miners.created_at, miners.id
|
||||||
miners.created_at,
|
|
||||||
miners.id
|
|
||||||
LIMIT %s OFFSET %s
|
LIMIT %s OFFSET %s
|
||||||
""",
|
""",
|
||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
return [dict(row) for row in cur.fetchall()]
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
def list_location_filter_options(self) -> dict[str, list[dict]]:
|
||||||
|
with self._connection_scope() as connection:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT sites.id, sites.name
|
||||||
|
FROM location_positions lp
|
||||||
|
JOIN miners ON miners.id = lp.miner_id
|
||||||
|
JOIN rooms ON rooms.id = lp.room_id
|
||||||
|
JOIN sites ON sites.id = rooms.site_id
|
||||||
|
GROUP BY sites.id, sites.name
|
||||||
|
ORDER BY lower(sites.name), sites.id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
sites = [dict(row) for row in cur.fetchall()]
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT rooms.id, rooms.site_id, rooms.name
|
||||||
|
FROM location_positions lp
|
||||||
|
JOIN miners ON miners.id = lp.miner_id
|
||||||
|
JOIN rooms ON rooms.id = lp.room_id
|
||||||
|
GROUP BY rooms.id, rooms.site_id, rooms.name
|
||||||
|
ORDER BY lower(rooms.name), rooms.id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rooms = [dict(row) for row in cur.fetchall()]
|
||||||
|
return {"sites": sites, "rooms": rooms}
|
||||||
|
|
||||||
def create_miner(self, values: Mapping[str, Any]) -> dict:
|
def create_miner(self, values: Mapping[str, Any]) -> dict:
|
||||||
clean_values = {
|
clean_values = {
|
||||||
key: value for key, value in values.items() if key in MINER_WRITE_COLUMNS
|
key: value for key, value in values.items() if key in MINER_WRITE_COLUMNS
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
|
||||||
from app.identity import require_permission
|
from app.identity import require_permission, user_has_permission
|
||||||
from app.inventory.dependencies import get_inventory_service
|
from app.inventory.dependencies import get_inventory_service
|
||||||
from app.inventory.exceptions import (
|
from app.inventory.exceptions import (
|
||||||
DuplicateAssetID,
|
DuplicateAssetID,
|
||||||
@@ -16,6 +16,7 @@ from app.inventory.models import (
|
|||||||
InventoryAssetIDHistoryResponse,
|
InventoryAssetIDHistoryResponse,
|
||||||
InventoryCreateRequest,
|
InventoryCreateRequest,
|
||||||
InventoryListMeta,
|
InventoryListMeta,
|
||||||
|
InventoryLocationOptions,
|
||||||
InventoryMetadataUpdateRequest,
|
InventoryMetadataUpdateRequest,
|
||||||
InventoryMinerDetailResponse,
|
InventoryMinerDetailResponse,
|
||||||
InventoryMinerListResponse,
|
InventoryMinerListResponse,
|
||||||
@@ -102,21 +103,35 @@ def list_inventory_miners(
|
|||||||
service: InventoryServiceDependency,
|
service: InventoryServiceDependency,
|
||||||
current_user: InventoryReadUser,
|
current_user: InventoryReadUser,
|
||||||
) -> InventoryMinerListResponse:
|
) -> InventoryMinerListResponse:
|
||||||
del current_user
|
include_location_details = user_has_permission(current_user, "locations.view")
|
||||||
|
if (filters.site_id or filters.room_id) and not include_location_details:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Location viewing permission is required for location filters",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
miners = service.search_miners(filters)
|
miners = service.search_miners(
|
||||||
|
filters,
|
||||||
|
include_location_details=include_location_details,
|
||||||
|
)
|
||||||
except InventoryValidationError as exc:
|
except InventoryValidationError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail=str(exc),
|
detail=str(exc),
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
location_options = InventoryLocationOptions()
|
||||||
|
if include_location_details:
|
||||||
|
location_options = InventoryLocationOptions.model_validate(
|
||||||
|
service.list_location_filter_options()
|
||||||
|
)
|
||||||
return InventoryMinerListResponse(
|
return InventoryMinerListResponse(
|
||||||
data=miners,
|
data=miners,
|
||||||
meta=InventoryListMeta(
|
meta=InventoryListMeta(
|
||||||
limit=filters.limit,
|
limit=filters.limit,
|
||||||
offset=filters.offset,
|
offset=filters.offset,
|
||||||
returned=len(miners),
|
returned=len(miners),
|
||||||
|
location_options=location_options,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -79,16 +79,37 @@ class InventoryService:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _summary_model(cls, row: dict) -> InventoryMinerSummary:
|
def _summary_model(cls, row: dict) -> InventoryMinerSummary:
|
||||||
|
active_repair = None
|
||||||
|
if row.get("active_repair_id") is not None:
|
||||||
|
active_repair = {
|
||||||
|
"id": row["active_repair_id"],
|
||||||
|
"status": row["active_repair_status"],
|
||||||
|
"reason": row["active_repair_reason"],
|
||||||
|
}
|
||||||
return InventoryMinerSummary(
|
return InventoryMinerSummary(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
asset_id=row.get("asset_tag"),
|
asset_id=row.get("asset_tag"),
|
||||||
current_mac=row.get("current_mac"),
|
current_mac=row.get("current_mac"),
|
||||||
current_ip=row.get("current_ip"),
|
current_ip=row.get("current_ip"),
|
||||||
model=row.get("model"),
|
model=row.get("model"),
|
||||||
|
miner_sn=row.get("miner_sn"),
|
||||||
lifecycle_status=row["lifecycle_status"],
|
lifecycle_status=row["lifecycle_status"],
|
||||||
runtime_status=row["status"],
|
runtime_status=row["status"],
|
||||||
site_id=row.get("site_id"),
|
site_id=row.get("site_id"),
|
||||||
|
site_name=row.get("site_name"),
|
||||||
room_id=row.get("room_id"),
|
room_id=row.get("room_id"),
|
||||||
|
room_name=row.get("room_name"),
|
||||||
|
location_position_id=row.get("location_position_id"),
|
||||||
|
rack_label=row.get("rack_label"),
|
||||||
|
shelf_label=row.get("shelf_label"),
|
||||||
|
position_label=row.get("position_label"),
|
||||||
|
position_index=row.get("position_index"),
|
||||||
|
observed_at=row.get("observed_at"),
|
||||||
|
hashrate_ths=row.get("hashrate_ths"),
|
||||||
|
power_w=row.get("power_w"),
|
||||||
|
chip_temp_max_c=row.get("chip_temp_max_c"),
|
||||||
|
hash_stable=row.get("hash_stable"),
|
||||||
|
active_repair=active_repair,
|
||||||
tags=[cls._tag_model(tag) for tag in row.get("tags") or []],
|
tags=[cls._tag_model(tag) for tag in row.get("tags") or []],
|
||||||
created_at=row["created_at"],
|
created_at=row["created_at"],
|
||||||
updated_at=row["updated_at"],
|
updated_at=row["updated_at"],
|
||||||
@@ -101,7 +122,6 @@ class InventoryService:
|
|||||||
return InventoryMinerDetail(
|
return InventoryMinerDetail(
|
||||||
**summary.model_dump(),
|
**summary.model_dump(),
|
||||||
hostname=row.get("hostname"),
|
hostname=row.get("hostname"),
|
||||||
miner_sn=row.get("miner_sn"),
|
|
||||||
control_board_sn=row.get("control_board_sn"),
|
control_board_sn=row.get("control_board_sn"),
|
||||||
psu_serial=row.get("power_sn"),
|
psu_serial=row.get("power_sn"),
|
||||||
firmware_version=row.get("firmware_version"),
|
firmware_version=row.get("firmware_version"),
|
||||||
@@ -140,12 +160,18 @@ class InventoryService:
|
|||||||
def search_miners(
|
def search_miners(
|
||||||
self,
|
self,
|
||||||
filters: InventorySearchFilters,
|
filters: InventorySearchFilters,
|
||||||
|
*,
|
||||||
|
include_location_details: bool = False,
|
||||||
) -> list[InventoryMinerSummary]:
|
) -> list[InventoryMinerSummary]:
|
||||||
lifecycle_status = None
|
lifecycle_status = None
|
||||||
if filters.lifecycle_status:
|
if filters.lifecycle_status:
|
||||||
lifecycle_status = self.validate_lifecycle_status(
|
lifecycle_status = self.validate_lifecycle_status(
|
||||||
filters.lifecycle_status
|
filters.lifecycle_status
|
||||||
)
|
)
|
||||||
|
if (filters.site_id or filters.room_id) and not include_location_details:
|
||||||
|
raise InventoryValidationError(
|
||||||
|
"Location filters require location viewing permission"
|
||||||
|
)
|
||||||
asset_id = None
|
asset_id = None
|
||||||
if filters.asset_id:
|
if filters.asset_id:
|
||||||
asset_id = self.normalize_asset_id(filters.asset_id)
|
asset_id = self.normalize_asset_id(filters.asset_id)
|
||||||
@@ -158,12 +184,23 @@ class InventoryService:
|
|||||||
query=self._clean_optional_text(filters.query),
|
query=self._clean_optional_text(filters.query),
|
||||||
asset_id=asset_id,
|
asset_id=asset_id,
|
||||||
lifecycle_status=lifecycle_status,
|
lifecycle_status=lifecycle_status,
|
||||||
|
model=self._clean_optional_text(filters.model),
|
||||||
|
runtime_status=self._clean_optional_text(filters.runtime_status),
|
||||||
|
repair=filters.repair,
|
||||||
|
operational_state=filters.operational_state,
|
||||||
|
problems=filters.problems,
|
||||||
|
site_id=filters.site_id,
|
||||||
|
room_id=filters.room_id,
|
||||||
tag_names=tag_names,
|
tag_names=tag_names,
|
||||||
limit=filters.limit,
|
limit=filters.limit,
|
||||||
offset=filters.offset,
|
offset=filters.offset,
|
||||||
|
include_location_details=include_location_details,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def list_location_filter_options(self) -> dict[str, list[dict]]:
|
||||||
|
return self.repository.list_location_filter_options()
|
||||||
|
|
||||||
def create_miner(
|
def create_miner(
|
||||||
self,
|
self,
|
||||||
request: InventoryCreateRequest,
|
request: InventoryCreateRequest,
|
||||||
|
|||||||
@@ -363,6 +363,7 @@ class FrontendShellTests(unittest.TestCase):
|
|||||||
"/assets/minerops-app.css",
|
"/assets/minerops-app.css",
|
||||||
"/assets/minerops-app.js",
|
"/assets/minerops-app.js",
|
||||||
"/assets/minerops-mine-map.js",
|
"/assets/minerops-mine-map.js",
|
||||||
|
"/assets/minerops-fleet.js",
|
||||||
):
|
):
|
||||||
with self.subTest(path=path):
|
with self.subTest(path=path):
|
||||||
response = self.client.get(path)
|
response = self.client.get(path)
|
||||||
@@ -405,12 +406,76 @@ class FrontendShellTests(unittest.TestCase):
|
|||||||
self.assertIn("element.textContent = values.text", javascript)
|
self.assertIn("element.textContent = values.text", javascript)
|
||||||
self.assertIn("cell.textContent = text(content)", javascript)
|
self.assertIn("cell.textContent = text(content)", javascript)
|
||||||
self.assertIn("encodeURIComponent(repair.miner_id)", javascript)
|
self.assertIn("encodeURIComponent(repair.miner_id)", javascript)
|
||||||
self.assertIn("encodeURIComponent(miner.id)", javascript)
|
|
||||||
self.assertIn("new AbortController()", javascript)
|
self.assertIn("new AbortController()", javascript)
|
||||||
self.assertIn("invalid repair response", javascript)
|
self.assertIn("invalid repair response", javascript)
|
||||||
self.assertIn("invalid inventory response", javascript)
|
|
||||||
self.assertIn("window.location.replace", javascript)
|
self.assertIn("window.location.replace", javascript)
|
||||||
|
|
||||||
|
def test_fleet_page_is_semantic_responsive_and_permission_aware(self):
|
||||||
|
self.authenticate()
|
||||||
|
response = self.client.get("/assets/miners")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
for value in (
|
||||||
|
'id="fleetFilters"', 'id="fleetRows"', 'id="fleetResultSummary"',
|
||||||
|
'<table class="fleet-table">', '<th>Operational</th>',
|
||||||
|
'id="fleetPrevious"', 'id="fleetNext"',
|
||||||
|
'/assets/minerops-fleet.js', 'data-can-view-locations="true"',
|
||||||
|
'id="fleetSite"', 'id="fleetRoom"',
|
||||||
|
):
|
||||||
|
self.assertIn(value, response.text)
|
||||||
|
|
||||||
|
self.authenticate(permissions=("miners.view",))
|
||||||
|
restricted = self.client.get("/assets/miners").text
|
||||||
|
self.assertIn('data-can-view-locations="false"', restricted)
|
||||||
|
self.assertNotIn('id="fleetSite"', restricted)
|
||||||
|
self.assertNotIn('id="fleetRoom"', restricted)
|
||||||
|
|
||||||
|
def test_fleet_reset_action_has_a_visible_label(self):
|
||||||
|
self.authenticate()
|
||||||
|
response = self.client.get("/assets/miners")
|
||||||
|
self.assertIn(
|
||||||
|
'<button class="quiet-button" id="fleetReset" '
|
||||||
|
'type="button">Reset</button>',
|
||||||
|
response.text,
|
||||||
|
)
|
||||||
|
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
|
||||||
|
self.assertIn(
|
||||||
|
".fleet-filter-actions .quiet-button { color: var(--ink); }",
|
||||||
|
css,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_fleet_script_dom_contract_matches_authorized_page(self):
|
||||||
|
self.authenticate()
|
||||||
|
response = self.client.get("/assets/miners")
|
||||||
|
source = (STATIC_ROOT / "fleet.js").read_text(encoding="utf-8")
|
||||||
|
declared = re.search(r"const ids = \[(.*?)\];", source, re.DOTALL)
|
||||||
|
self.assertIsNotNone(declared)
|
||||||
|
required = set(re.findall(r'"([A-Za-z][A-Za-z0-9]+)"', declared.group(1)))
|
||||||
|
required.update(("fleetSite", "fleetRoom"))
|
||||||
|
rendered = set(re.findall(r'\bid="([^"]+)"', response.text))
|
||||||
|
self.assertEqual(required - rendered, set())
|
||||||
|
|
||||||
|
def test_fleet_script_is_bounded_read_only_and_xss_safe(self):
|
||||||
|
source = (STATIC_ROOT / "fleet.js").read_text(encoding="utf-8")
|
||||||
|
self.assertEqual(source.count('fetch("/api/inventory/miners?"'), 1)
|
||||||
|
self.assertNotIn("/api/location/map", source)
|
||||||
|
self.assertNotIn("setInterval", source)
|
||||||
|
for method in ('method: "POST"', 'method: "PATCH"', 'method: "PUT"', 'method: "DELETE"'):
|
||||||
|
self.assertNotIn(method, source)
|
||||||
|
for unsafe in ("innerHTML", "insertAdjacentHTML", "document.write"):
|
||||||
|
self.assertNotIn(unsafe, source)
|
||||||
|
self.assertIn("textContent", source)
|
||||||
|
self.assertIn('"/miners/" + encodeURIComponent(miner.id) + "/detail"', source)
|
||||||
|
self.assertIn('"/monitoring/map?" + params.toString()', source)
|
||||||
|
self.assertNotRegex(source.lower(), r"stale.{0,20}(threshold|minutes|hours)")
|
||||||
|
self.assertNotIn("lowHashrate", source)
|
||||||
|
|
||||||
|
def test_fleet_css_has_status_text_mobile_cards_and_visible_focus(self):
|
||||||
|
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
|
||||||
|
self.assertIn('.fleet-row[data-status="offline"]', css)
|
||||||
|
self.assertIn("content: attr(data-label)", css)
|
||||||
|
self.assertIn(".fleet-pagination", css)
|
||||||
|
self.assertIn(":focus-visible", css)
|
||||||
|
|
||||||
def test_styles_have_no_motion_to_reduce(self):
|
def test_styles_have_no_motion_to_reduce(self):
|
||||||
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
|
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
|
||||||
self.assertNotIn("animation:", css)
|
self.assertNotIn("animation:", css)
|
||||||
|
|||||||
@@ -45,6 +45,21 @@ def inventory_row(**overrides):
|
|||||||
"created_at": now,
|
"created_at": now,
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
"tags": [],
|
"tags": [],
|
||||||
|
"site_name": None,
|
||||||
|
"room_name": None,
|
||||||
|
"location_position_id": None,
|
||||||
|
"rack_label": None,
|
||||||
|
"shelf_label": None,
|
||||||
|
"position_label": None,
|
||||||
|
"position_index": None,
|
||||||
|
"observed_at": None,
|
||||||
|
"hashrate_ths": None,
|
||||||
|
"power_w": None,
|
||||||
|
"chip_temp_max_c": None,
|
||||||
|
"hash_stable": None,
|
||||||
|
"active_repair_id": None,
|
||||||
|
"active_repair_status": None,
|
||||||
|
"active_repair_reason": None,
|
||||||
}
|
}
|
||||||
row.update(overrides)
|
row.update(overrides)
|
||||||
return row
|
return row
|
||||||
@@ -56,6 +71,7 @@ class FakeInventoryRepository:
|
|||||||
self.created_values = None
|
self.created_values = None
|
||||||
self.asset_id_history = []
|
self.asset_id_history = []
|
||||||
self.update_error = None
|
self.update_error = None
|
||||||
|
self.search_kwargs = None
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def transaction(self):
|
def transaction(self):
|
||||||
@@ -89,6 +105,7 @@ class FakeInventoryRepository:
|
|||||||
return list(self.rows.values())[offset : offset + limit]
|
return list(self.rows.values())[offset : offset + limit]
|
||||||
|
|
||||||
def search_miners(self, **kwargs):
|
def search_miners(self, **kwargs):
|
||||||
|
self.search_kwargs = kwargs
|
||||||
rows = list(self.rows.values())
|
rows = list(self.rows.values())
|
||||||
lifecycle = kwargs.get("lifecycle_status")
|
lifecycle = kwargs.get("lifecycle_status")
|
||||||
if lifecycle:
|
if lifecycle:
|
||||||
@@ -416,6 +433,54 @@ class InventoryServiceTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(self.repository.asset_id_history, [])
|
self.assertEqual(self.repository.asset_id_history, [])
|
||||||
|
|
||||||
|
def test_fleet_projection_mapping_and_filter_forwarding(self):
|
||||||
|
miner_id = uuid4()
|
||||||
|
repair_id = uuid4()
|
||||||
|
site_id = uuid4()
|
||||||
|
row = inventory_row(
|
||||||
|
id=miner_id,
|
||||||
|
miner_sn="MINER-SN",
|
||||||
|
site_id=site_id,
|
||||||
|
site_name="North Site",
|
||||||
|
observed_at=datetime.now(timezone.utc),
|
||||||
|
hashrate_ths=0.0,
|
||||||
|
power_w=0.0,
|
||||||
|
chip_temp_max_c=0.0,
|
||||||
|
hash_stable=False,
|
||||||
|
active_repair_id=repair_id,
|
||||||
|
active_repair_status="IN_PROGRESS",
|
||||||
|
active_repair_reason="Hashboard diagnostics",
|
||||||
|
)
|
||||||
|
self.repository.rows[miner_id] = row
|
||||||
|
filters = InventorySearchFilters(
|
||||||
|
model=" M50 ", runtime_status="offline", repair="active",
|
||||||
|
operational_state="repair", problems=True, site_id=site_id,
|
||||||
|
limit=25, offset=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.service.search_miners(
|
||||||
|
filters, include_location_details=True
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
self.assertEqual(result.miner_sn, "MINER-SN")
|
||||||
|
self.assertEqual(result.hashrate_ths, 0.0)
|
||||||
|
self.assertEqual(result.power_w, 0.0)
|
||||||
|
self.assertEqual(result.chip_temp_max_c, 0.0)
|
||||||
|
self.assertFalse(result.hash_stable)
|
||||||
|
self.assertEqual(result.active_repair.id, repair_id)
|
||||||
|
self.assertEqual(self.repository.search_kwargs["model"], "M50")
|
||||||
|
self.assertEqual(self.repository.search_kwargs["site_id"], site_id)
|
||||||
|
self.assertTrue(self.repository.search_kwargs["include_location_details"])
|
||||||
|
|
||||||
|
def test_location_filter_without_projection_is_rejected(self):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
InventoryValidationError, "Location filters require"
|
||||||
|
):
|
||||||
|
self.service.search_miners(
|
||||||
|
InventorySearchFilters(site_id=uuid4())
|
||||||
|
)
|
||||||
|
self.assertIsNone(self.repository.search_kwargs)
|
||||||
|
|
||||||
def test_search_validation_precedes_repository(self):
|
def test_search_validation_precedes_repository(self):
|
||||||
with self.assertRaises(InventoryValidationError):
|
with self.assertRaises(InventoryValidationError):
|
||||||
self.service.search_miners(
|
self.service.search_miners(
|
||||||
@@ -485,6 +550,22 @@ class InventoryRepositoryIntegrationTests(unittest.TestCase):
|
|||||||
miner_id,
|
miner_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fleet = self.repository.search_miners(
|
||||||
|
model="M50",
|
||||||
|
runtime_status="unknown",
|
||||||
|
operational_state="unknown",
|
||||||
|
problems=True,
|
||||||
|
include_location_details=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(fleet[0]["id"], miner_id)
|
||||||
|
self.assertEqual(fleet[0]["miner_sn"], "MINER-001")
|
||||||
|
self.assertIsNone(fleet[0]["observed_at"])
|
||||||
|
self.assertIsNone(fleet[0]["location_position_id"])
|
||||||
|
self.assertEqual(
|
||||||
|
self.repository.list_location_filter_options(),
|
||||||
|
{"sites": [], "rooms": []},
|
||||||
|
)
|
||||||
|
|
||||||
updated = self.repository.update_miner(
|
updated = self.repository.update_miner(
|
||||||
miner_id,
|
miner_id,
|
||||||
{"lifecycle_status": "spare", "notes": "Bench stock"},
|
{"lifecycle_status": "spare", "notes": "Bench stock"},
|
||||||
|
|||||||
@@ -43,16 +43,24 @@ class StubInventoryService:
|
|||||||
self.history_result: list[InventoryAssetIDHistoryEntry] = []
|
self.history_result: list[InventoryAssetIDHistoryEntry] = []
|
||||||
self.history_error: Exception | None = None
|
self.history_error: Exception | None = None
|
||||||
self.history_miner_id: UUID | None = None
|
self.history_miner_id: UUID | None = None
|
||||||
|
self.include_location_details = False
|
||||||
|
self.location_options = {"sites": [], "rooms": []}
|
||||||
|
|
||||||
def search_miners(
|
def search_miners(
|
||||||
self,
|
self,
|
||||||
filters: InventorySearchFilters,
|
filters: InventorySearchFilters,
|
||||||
|
*,
|
||||||
|
include_location_details: bool = False,
|
||||||
) -> list[InventoryMinerSummary]:
|
) -> list[InventoryMinerSummary]:
|
||||||
self.search_filters = filters
|
self.search_filters = filters
|
||||||
|
self.include_location_details = include_location_details
|
||||||
if self.search_error is not None:
|
if self.search_error is not None:
|
||||||
raise self.search_error
|
raise self.search_error
|
||||||
return self.search_result
|
return self.search_result
|
||||||
|
|
||||||
|
def list_location_filter_options(self):
|
||||||
|
return self.location_options
|
||||||
|
|
||||||
def create_miner(
|
def create_miner(
|
||||||
self,
|
self,
|
||||||
request: InventoryCreateRequest,
|
request: InventoryCreateRequest,
|
||||||
@@ -110,10 +118,10 @@ def miner_summary(miner_id: UUID | None = None) -> InventoryMinerSummary:
|
|||||||
|
|
||||||
|
|
||||||
def miner_detail(miner_id: UUID | None = None) -> InventoryMinerDetail:
|
def miner_detail(miner_id: UUID | None = None) -> InventoryMinerDetail:
|
||||||
summary = miner_summary(miner_id)
|
values = miner_summary(miner_id).model_dump()
|
||||||
|
values["miner_sn"] = "MINER-001"
|
||||||
return InventoryMinerDetail(
|
return InventoryMinerDetail(
|
||||||
**summary.model_dump(),
|
**values,
|
||||||
miner_sn="MINER-001",
|
|
||||||
control_board_sn="CB-001",
|
control_board_sn="CB-001",
|
||||||
psu_serial="PSU-001",
|
psu_serial="PSU-001",
|
||||||
)
|
)
|
||||||
@@ -410,10 +418,77 @@ class InventoryRouterTests(unittest.TestCase):
|
|||||||
self.assertEqual(body["data"][0]["asset_id"], "ASSET-001")
|
self.assertEqual(body["data"][0]["asset_id"], "ASSET-001")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
body["meta"],
|
body["meta"],
|
||||||
{"limit": 100, "offset": 0, "returned": 1},
|
{"limit": 100, "offset": 0, "returned": 1,
|
||||||
|
"location_options": {"sites": [], "rooms": []}},
|
||||||
)
|
)
|
||||||
self.assertIsNone(body["error"])
|
self.assertIsNone(body["error"])
|
||||||
|
|
||||||
|
def test_list_projection_and_location_permission_are_explicit(self):
|
||||||
|
miner_id = uuid4()
|
||||||
|
site_id = uuid4()
|
||||||
|
room_id = uuid4()
|
||||||
|
position_id = uuid4()
|
||||||
|
self.service.search_result = [
|
||||||
|
InventoryMinerSummary(
|
||||||
|
id=miner_id,
|
||||||
|
asset_id="FLEET-001",
|
||||||
|
model="M50",
|
||||||
|
miner_sn="SN-001",
|
||||||
|
lifecycle_status="active",
|
||||||
|
runtime_status="online",
|
||||||
|
site_id=site_id,
|
||||||
|
site_name="North Site",
|
||||||
|
room_id=room_id,
|
||||||
|
room_name="Room A",
|
||||||
|
location_position_id=position_id,
|
||||||
|
rack_label="Rack 1",
|
||||||
|
shelf_label="Shelf 2",
|
||||||
|
position_index=3,
|
||||||
|
observed_at=datetime.now(timezone.utc),
|
||||||
|
hashrate_ths=120.0,
|
||||||
|
power_w=3400.0,
|
||||||
|
chip_temp_max_c=72.0,
|
||||||
|
hash_stable=True,
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
updated_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
self.service.location_options = {
|
||||||
|
"sites": [{"id": site_id, "name": "North Site"}],
|
||||||
|
"rooms": [{"id": room_id, "site_id": site_id, "name": "Room A"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
self.authenticate(("miners.view", "locations.view"))
|
||||||
|
response = self.client.get("/api/inventory/miners")
|
||||||
|
data = response.json()["data"][0]
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(data["miner_sn"], "SN-001")
|
||||||
|
self.assertEqual(data["location_position_id"], str(position_id))
|
||||||
|
self.assertEqual(data["hashrate_ths"], 120.0)
|
||||||
|
self.assertTrue(self.service.include_location_details)
|
||||||
|
self.assertEqual(
|
||||||
|
response.json()["meta"]["location_options"]["sites"][0]["name"],
|
||||||
|
"North Site",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.authenticate(("miners.view",))
|
||||||
|
self.service.search_result = [miner_summary(miner_id)]
|
||||||
|
response = self.client.get("/api/inventory/miners")
|
||||||
|
data = response.json()["data"][0]
|
||||||
|
self.assertIsNone(data["site_id"])
|
||||||
|
self.assertIsNone(data["room_id"])
|
||||||
|
self.assertIsNone(data["location_position_id"])
|
||||||
|
self.assertEqual(response.json()["meta"]["location_options"], {"sites": [], "rooms": []})
|
||||||
|
self.assertFalse(self.service.include_location_details)
|
||||||
|
|
||||||
|
def test_location_filters_require_locations_view(self):
|
||||||
|
self.authenticate(("miners.view",))
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/inventory/miners", params={"site_id": str(uuid4())}
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertIsNone(self.service.search_filters)
|
||||||
|
|
||||||
def test_empty_list_returns_successful_empty_collection(self):
|
def test_empty_list_returns_successful_empty_collection(self):
|
||||||
self.authenticate()
|
self.authenticate()
|
||||||
|
|
||||||
@@ -432,6 +507,11 @@ class InventoryRouterTests(unittest.TestCase):
|
|||||||
("query", "M50"),
|
("query", "M50"),
|
||||||
("asset_id", "ASSET-001"),
|
("asset_id", "ASSET-001"),
|
||||||
("lifecycle_status", "spare"),
|
("lifecycle_status", "spare"),
|
||||||
|
("model", "M50"),
|
||||||
|
("runtime_status", "offline"),
|
||||||
|
("repair", "active"),
|
||||||
|
("operational_state", "repair"),
|
||||||
|
("problems", "true"),
|
||||||
("tags", "Priority"),
|
("tags", "Priority"),
|
||||||
("tags", "Repair"),
|
("tags", "Repair"),
|
||||||
("limit", "25"),
|
("limit", "25"),
|
||||||
@@ -445,6 +525,11 @@ class InventoryRouterTests(unittest.TestCase):
|
|||||||
self.assertEqual(filters.query, "M50")
|
self.assertEqual(filters.query, "M50")
|
||||||
self.assertEqual(filters.asset_id, "ASSET-001")
|
self.assertEqual(filters.asset_id, "ASSET-001")
|
||||||
self.assertEqual(filters.lifecycle_status, "spare")
|
self.assertEqual(filters.lifecycle_status, "spare")
|
||||||
|
self.assertEqual(filters.model, "M50")
|
||||||
|
self.assertEqual(filters.runtime_status, "offline")
|
||||||
|
self.assertEqual(filters.repair, "active")
|
||||||
|
self.assertEqual(filters.operational_state, "repair")
|
||||||
|
self.assertTrue(filters.problems)
|
||||||
self.assertEqual(filters.tags, ["Priority", "Repair"])
|
self.assertEqual(filters.tags, ["Priority", "Repair"])
|
||||||
self.assertEqual(filters.limit, 25)
|
self.assertEqual(filters.limit, 25)
|
||||||
self.assertEqual(filters.offset, 50)
|
self.assertEqual(filters.offset, 50)
|
||||||
|
|||||||
Reference in new issue
Block a user