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 = """ +
+
+
+
+ """ if can_view_locations else "" return f""" -
+
-
-

Miner inventory

-

Real inventory records. Search matches the - fields supported by the Inventory API.

-
- Up to 100 results +

Fleet

+

Current miner identity, operating state, telemetry, and repair context.

+ Loading current fleet
-
""" @@ -297,11 +336,15 @@ def render_app_page( content = ( _map_content(has_permission("locations.manage")) if view == "map" + else _miners_content(has_permission("locations.view")) + if view == "miners" else CONTENT[view]() ) - map_script = ( + page_script = ( '' if view == "map" + else '' + if view == "miners" else "" ) return f""" @@ -313,7 +356,8 @@ def render_app_page( + data-can-view-miners="{str(can_view_miners).lower()}" + data-can-view-locations="{str(has_permission("locations.view")).lower()}">
@@ -358,6 +402,6 @@ def render_app_page(
- {map_script} + {page_script} """ diff --git a/backend/app/inventory/models.py b/backend/app/inventory/models.py index 2c5204e..eb7b8a5 100644 --- a/backend/app/inventory/models.py +++ b/backend/app/inventory/models.py @@ -17,16 +17,36 @@ class InventoryTag(BaseModel): updated_at: datetime +class InventoryActiveRepair(BaseModel): + id: UUID + status: str + reason: str + + class InventoryMinerSummary(BaseModel): id: UUID asset_id: str | None = None current_mac: str | None = None current_ip: str | None = None model: str | None = None + miner_sn: str | None = None lifecycle_status: LifecycleStatus runtime_status: str site_id: UUID | None = None + site_name: str | 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) created_at: datetime updated_at: datetime @@ -35,7 +55,6 @@ class InventoryMinerSummary(BaseModel): class InventoryMinerDetail(InventoryMinerSummary): hostname: str | None = None - miner_sn: str | None = None control_board_sn: str | None = None psu_serial: str | None = None firmware_version: str | None = None @@ -99,15 +118,41 @@ class InventorySearchFilters(BaseModel): query: str | None = None asset_id: 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) limit: int = Field(default=100, ge=1, le=500) 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): limit: int offset: int returned: int + location_options: InventoryLocationOptions = Field(default_factory=InventoryLocationOptions) class InventoryMinerListResponse(BaseModel): diff --git a/backend/app/inventory/repository.py b/backend/app/inventory/repository.py index c1ec443..5e3421b 100644 --- a/backend/app/inventory/repository.py +++ b/backend/app/inventory/repository.py @@ -59,6 +59,101 @@ MINER_SELECT = f""" ) 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 = { "site_id", "room_id", @@ -184,8 +279,18 @@ class InventoryRepository: (asset_id,), ) - def list_miners(self, *, limit: int = 100, offset: int = 0) -> list[dict]: - return self.search_miners(limit=limit, offset=offset) + def list_miners( + 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( self, @@ -193,12 +298,20 @@ class InventoryRepository: query: str | None = None, asset_id: 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] = (), limit: int = 100, offset: int = 0, + include_location_details: bool = False, ) -> list[dict]: conditions: list[str] = [] - params: list[Any] = [] + params: list[Any] = [list(ACTIVE_REPAIR_STATUSES)] if query: pattern = f"%{query}%" @@ -217,15 +330,36 @@ class InventoryRepository: """ ) params.extend([pattern] * 8) - if asset_id: conditions.append("lower(trim(miners.asset_tag)) = lower(trim(%s))") params.append(asset_id) - if lifecycle_status: conditions.append("miners.lifecycle_status = %s") 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: conditions.append( f""" @@ -241,6 +375,14 @@ class InventoryRepository: ) 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" params.extend([limit, offset]) @@ -248,18 +390,44 @@ class InventoryRepository: with connection.cursor(row_factory=dict_row) as cur: cur.execute( f""" - {MINER_SELECT} + {select_sql} WHERE {where_sql} - ORDER BY - lower(trim(miners.asset_tag)) NULLS LAST, - miners.created_at, - miners.id + ORDER BY lower(trim(miners.asset_tag)) NULLS LAST, + miners.created_at, miners.id LIMIT %s OFFSET %s """, params, ) 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: clean_values = { key: value for key, value in values.items() if key in MINER_WRITE_COLUMNS diff --git a/backend/app/inventory/router.py b/backend/app/inventory/router.py index 980cf22..5c3fafe 100644 --- a/backend/app/inventory/router.py +++ b/backend/app/inventory/router.py @@ -5,7 +5,7 @@ from uuid import UUID 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.exceptions import ( DuplicateAssetID, @@ -16,6 +16,7 @@ from app.inventory.models import ( InventoryAssetIDHistoryResponse, InventoryCreateRequest, InventoryListMeta, + InventoryLocationOptions, InventoryMetadataUpdateRequest, InventoryMinerDetailResponse, InventoryMinerListResponse, @@ -102,21 +103,35 @@ def list_inventory_miners( service: InventoryServiceDependency, current_user: InventoryReadUser, ) -> 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: - miners = service.search_miners(filters) + miners = service.search_miners( + filters, + include_location_details=include_location_details, + ) except InventoryValidationError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc), ) from exc + location_options = InventoryLocationOptions() + if include_location_details: + location_options = InventoryLocationOptions.model_validate( + service.list_location_filter_options() + ) return InventoryMinerListResponse( data=miners, meta=InventoryListMeta( limit=filters.limit, offset=filters.offset, returned=len(miners), + location_options=location_options, ), ) diff --git a/backend/app/inventory/service.py b/backend/app/inventory/service.py index 2237811..73dc960 100644 --- a/backend/app/inventory/service.py +++ b/backend/app/inventory/service.py @@ -79,16 +79,37 @@ class InventoryService: @classmethod 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( id=row["id"], asset_id=row.get("asset_tag"), current_mac=row.get("current_mac"), current_ip=row.get("current_ip"), model=row.get("model"), + miner_sn=row.get("miner_sn"), lifecycle_status=row["lifecycle_status"], runtime_status=row["status"], site_id=row.get("site_id"), + site_name=row.get("site_name"), 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 []], created_at=row["created_at"], updated_at=row["updated_at"], @@ -101,7 +122,6 @@ class InventoryService: return InventoryMinerDetail( **summary.model_dump(), hostname=row.get("hostname"), - miner_sn=row.get("miner_sn"), control_board_sn=row.get("control_board_sn"), psu_serial=row.get("power_sn"), firmware_version=row.get("firmware_version"), @@ -140,12 +160,18 @@ class InventoryService: def search_miners( self, filters: InventorySearchFilters, + *, + include_location_details: bool = False, ) -> list[InventoryMinerSummary]: lifecycle_status = None if filters.lifecycle_status: lifecycle_status = self.validate_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 if 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), asset_id=asset_id, 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, limit=filters.limit, 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( self, request: InventoryCreateRequest, diff --git a/backend/tests/test_frontend_shell.py b/backend/tests/test_frontend_shell.py index 79780c6..2cb81fe 100644 --- a/backend/tests/test_frontend_shell.py +++ b/backend/tests/test_frontend_shell.py @@ -363,6 +363,7 @@ class FrontendShellTests(unittest.TestCase): "/assets/minerops-app.css", "/assets/minerops-app.js", "/assets/minerops-mine-map.js", + "/assets/minerops-fleet.js", ): with self.subTest(path=path): response = self.client.get(path) @@ -405,12 +406,76 @@ class FrontendShellTests(unittest.TestCase): self.assertIn("element.textContent = values.text", javascript) self.assertIn("cell.textContent = text(content)", javascript) self.assertIn("encodeURIComponent(repair.miner_id)", javascript) - self.assertIn("encodeURIComponent(miner.id)", javascript) self.assertIn("new AbortController()", javascript) self.assertIn("invalid repair response", javascript) - self.assertIn("invalid inventory response", 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"', + '', '', + '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( + '', + 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): css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8") self.assertNotIn("animation:", css) diff --git a/backend/tests/test_inventory_domain.py b/backend/tests/test_inventory_domain.py index 091c091..4df887a 100644 --- a/backend/tests/test_inventory_domain.py +++ b/backend/tests/test_inventory_domain.py @@ -45,6 +45,21 @@ def inventory_row(**overrides): "created_at": now, "updated_at": now, "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) return row @@ -56,6 +71,7 @@ class FakeInventoryRepository: self.created_values = None self.asset_id_history = [] self.update_error = None + self.search_kwargs = None @contextmanager def transaction(self): @@ -89,6 +105,7 @@ class FakeInventoryRepository: return list(self.rows.values())[offset : offset + limit] def search_miners(self, **kwargs): + self.search_kwargs = kwargs rows = list(self.rows.values()) lifecycle = kwargs.get("lifecycle_status") if lifecycle: @@ -416,6 +433,54 @@ class InventoryServiceTests(unittest.TestCase): ) 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): with self.assertRaises(InventoryValidationError): self.service.search_miners( @@ -485,6 +550,22 @@ class InventoryRepositoryIntegrationTests(unittest.TestCase): 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( miner_id, {"lifecycle_status": "spare", "notes": "Bench stock"}, diff --git a/backend/tests/test_inventory_router.py b/backend/tests/test_inventory_router.py index d4e2228..c307036 100644 --- a/backend/tests/test_inventory_router.py +++ b/backend/tests/test_inventory_router.py @@ -43,16 +43,24 @@ class StubInventoryService: self.history_result: list[InventoryAssetIDHistoryEntry] = [] self.history_error: Exception | None = None self.history_miner_id: UUID | None = None + self.include_location_details = False + self.location_options = {"sites": [], "rooms": []} def search_miners( self, filters: InventorySearchFilters, + *, + include_location_details: bool = False, ) -> list[InventoryMinerSummary]: self.search_filters = filters + self.include_location_details = include_location_details if self.search_error is not None: raise self.search_error return self.search_result + def list_location_filter_options(self): + return self.location_options + def create_miner( self, request: InventoryCreateRequest, @@ -110,10 +118,10 @@ def miner_summary(miner_id: UUID | None = None) -> InventoryMinerSummary: 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( - **summary.model_dump(), - miner_sn="MINER-001", + **values, control_board_sn="CB-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["meta"], - {"limit": 100, "offset": 0, "returned": 1}, + {"limit": 100, "offset": 0, "returned": 1, + "location_options": {"sites": [], "rooms": []}}, ) 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): self.authenticate() @@ -432,6 +507,11 @@ class InventoryRouterTests(unittest.TestCase): ("query", "M50"), ("asset_id", "ASSET-001"), ("lifecycle_status", "spare"), + ("model", "M50"), + ("runtime_status", "offline"), + ("repair", "active"), + ("operational_state", "repair"), + ("problems", "true"), ("tags", "Priority"), ("tags", "Repair"), ("limit", "25"), @@ -445,6 +525,11 @@ class InventoryRouterTests(unittest.TestCase): self.assertEqual(filters.query, "M50") self.assertEqual(filters.asset_id, "ASSET-001") 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.limit, 25) self.assertEqual(filters.offset, 50)
Operational