diff --git a/backend/app/frontend/router.py b/backend/app/frontend/router.py index cc4b054..03dcd6f 100644 --- a/backend/app/frontend/router.py +++ b/backend/app/frontend/router.py @@ -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) def dashboard_page(current_user: DashboardUser) -> str: return render_page("dashboard", current_user) diff --git a/backend/app/frontend/static/app.css b/backend/app/frontend/static/app.css index 276747a..a7ddbaa 100644 --- a/backend/app/frontend/static/app.css +++ b/backend/app/frontend/static/app.css @@ -1246,3 +1246,114 @@ button.map-position:hover { .map-search { grid-column: auto; } .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); } +} diff --git a/backend/app/frontend/static/app.js b/backend/app/frontend/static/app.js index d9aac49..3ce7594 100644 --- a/backend/app/frontend/static/app.js +++ b/backend/app/frontend/static/app.js @@ -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(); if (view === "repairs") loadRepairs(); - if (view === "miners") setupMiners(); })(); diff --git a/backend/app/frontend/static/fleet.js b/backend/app/frontend/static/fleet.js new file mode 100644 index 0000000..c98a056 --- /dev/null +++ b/backend/app/frontend/static/fleet.js @@ -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(); +}()); diff --git a/backend/app/frontend/views.py b/backend/app/frontend/views.py index b7ac33c..0913af3 100644 --- a/backend/app/frontend/views.py +++ b/backend/app/frontend/views.py @@ -222,33 +222,72 @@ def _repairs_content() -> str: """ -def _miners_content() -> str: +def _miners_content(can_view_locations: bool) -> str: + location_filters = """ +
Real inventory records. Search matches the - fields supported by the Inventory API.
-Current miner identity, operating state, telemetry, and repair context.