From b8b741ccee806ee85562461490ad5d5ff07eabe2 Mon Sep 17 00:00:00 2001 From: JC Date: Mon, 17 Aug 2026 17:11:40 +0000 Subject: [PATCH] feat: add operational Dashboard --- backend/app/dashboard/__init__.py | 1 + backend/app/dashboard/dependencies.py | 14 + backend/app/dashboard/models.py | 32 +++ backend/app/dashboard/router.py | 40 +++ backend/app/dashboard/service.py | 64 +++++ backend/app/frontend/router.py | 9 + backend/app/frontend/static/app.css | 129 +++++++++ backend/app/frontend/static/dashboard.js | 155 ++++++++++ backend/app/frontend/views.py | 152 ++++++---- backend/app/inventory/models.py | 18 ++ backend/app/inventory/repository.py | 64 +++++ backend/app/inventory/service.py | 20 ++ backend/app/locations/models.py | 10 + backend/app/locations/repository.py | 28 ++ backend/app/locations/service.py | 8 + backend/app/main.py | 13 +- backend/app/repairs/center_repository.py | 28 ++ backend/app/repairs/center_service.py | 6 + backend/app/repairs/models.py | 5 + backend/tests/test_dashboard.py | 347 +++++++++++++++++++++++ backend/tests/test_frontend_shell.py | 89 +++++- backend/tests/test_mine_map.py | 41 ++- scripts/test-dashboard.sh | 64 +++++ 23 files changed, 1278 insertions(+), 59 deletions(-) create mode 100644 backend/app/dashboard/__init__.py create mode 100644 backend/app/dashboard/dependencies.py create mode 100644 backend/app/dashboard/models.py create mode 100644 backend/app/dashboard/router.py create mode 100644 backend/app/dashboard/service.py create mode 100644 backend/app/frontend/static/dashboard.js create mode 100644 backend/tests/test_dashboard.py create mode 100755 scripts/test-dashboard.sh diff --git a/backend/app/dashboard/__init__.py b/backend/app/dashboard/__init__.py new file mode 100644 index 0000000..988f3f4 --- /dev/null +++ b/backend/app/dashboard/__init__.py @@ -0,0 +1 @@ +"""Permission-aware operational Dashboard read model.""" diff --git a/backend/app/dashboard/dependencies.py b/backend/app/dashboard/dependencies.py new file mode 100644 index 0000000..9fa3da4 --- /dev/null +++ b/backend/app/dashboard/dependencies.py @@ -0,0 +1,14 @@ +"""Dependency construction for the Dashboard composition service.""" + +from app.dashboard.service import DashboardService +from app.inventory.dependencies import get_inventory_service +from app.locations.dependencies import get_location_map_service +from app.repairs.dependencies import get_repair_center_service + + +def get_dashboard_service() -> DashboardService: + return DashboardService( + inventory_service=get_inventory_service(), + repair_center_service=get_repair_center_service(), + location_service=get_location_map_service(), + ) diff --git a/backend/app/dashboard/models.py b/backend/app/dashboard/models.py new file mode 100644 index 0000000..6b85138 --- /dev/null +++ b/backend/app/dashboard/models.py @@ -0,0 +1,32 @@ +"""Typed contracts for the operational Dashboard overview.""" + +from datetime import datetime + +from pydantic import BaseModel, Field + +from app.inventory.models import InventoryFleetSummary +from app.locations.models import LocationSummary +from app.repairs.models import ActiveRepairSummary + + +class DashboardAccess(BaseModel): + can_view_miners: bool = False + can_view_repairs: bool = False + can_view_locations: bool = False + + +class DashboardOverviewData(BaseModel): + fleet: InventoryFleetSummary | None = None + repairs: ActiveRepairSummary | None = None + locations: LocationSummary | None = None + generated_at: datetime + + +class DashboardOverviewMeta(BaseModel): + available_sections: list[str] = Field(default_factory=list) + + +class DashboardOverviewResponse(BaseModel): + data: DashboardOverviewData + meta: DashboardOverviewMeta = Field(default_factory=DashboardOverviewMeta) + error: None = None diff --git a/backend/app/dashboard/router.py b/backend/app/dashboard/router.py new file mode 100644 index 0000000..5d13892 --- /dev/null +++ b/backend/app/dashboard/router.py @@ -0,0 +1,40 @@ +"""HTTP boundary for the operational Dashboard overview.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Response + +from app.dashboard.dependencies import get_dashboard_service +from app.dashboard.models import ( + DashboardAccess, + DashboardOverviewResponse, +) +from app.dashboard.service import DashboardService +from app.identity import require_permission, user_has_permission + + +router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) +DashboardUser = Annotated[dict, Depends(require_permission("dashboard.view"))] +DashboardServiceDependency = Annotated[ + DashboardService, + Depends(get_dashboard_service), +] + + +@router.get("/overview", response_model=DashboardOverviewResponse) +def get_dashboard_overview( + current_user: DashboardUser, + service: DashboardServiceDependency, + response: Response, +) -> DashboardOverviewResponse: + response.headers["Cache-Control"] = "no-store" + return service.get_overview( + DashboardAccess( + can_view_miners=user_has_permission(current_user, "miners.view"), + can_view_repairs=user_has_permission(current_user, "repairs.view"), + can_view_locations=user_has_permission( + current_user, + "locations.view", + ), + ) + ) diff --git a/backend/app/dashboard/service.py b/backend/app/dashboard/service.py new file mode 100644 index 0000000..c344623 --- /dev/null +++ b/backend/app/dashboard/service.py @@ -0,0 +1,64 @@ +"""Composition service for permission-aware Dashboard summaries.""" + +from datetime import datetime, timezone + +from app.dashboard.models import ( + DashboardAccess, + DashboardOverviewData, + DashboardOverviewMeta, + DashboardOverviewResponse, +) +from app.inventory.service import InventoryService +from app.locations.service import LocationMapService +from app.repairs.center_service import RepairCenterService + + +class DashboardService: + """Coordinate domain-owned summaries without introducing new rules.""" + + def __init__( + self, + inventory_service: InventoryService, + repair_center_service: RepairCenterService, + location_service: LocationMapService, + ): + self.inventory_service = inventory_service + self.repair_center_service = repair_center_service + self.location_service = location_service + + def get_overview(self, access: DashboardAccess) -> DashboardOverviewResponse: + fleet = ( + self.inventory_service.summarize_fleet() + if access.can_view_miners + else None + ) + repairs = ( + self.repair_center_service.summarize_active_repairs() + if access.can_view_repairs + else None + ) + locations = ( + self.location_service.summarize_locations( + include_miner_counts=access.can_view_miners + ) + if access.can_view_locations + else None + ) + available_sections = [ + name + for name, value in ( + ("fleet", fleet), + ("repairs", repairs), + ("locations", locations), + ) + if value is not None + ] + return DashboardOverviewResponse( + data=DashboardOverviewData( + fleet=fleet, + repairs=repairs, + locations=locations, + generated_at=datetime.now(timezone.utc), + ), + meta=DashboardOverviewMeta(available_sections=available_sections), + ) diff --git a/backend/app/frontend/router.py b/backend/app/frontend/router.py index 74599ab..557fa5e 100644 --- a/backend/app/frontend/router.py +++ b/backend/app/frontend/router.py @@ -45,6 +45,15 @@ def frontend_script() -> Response: ) +@router.get("/assets/minerops-dashboard.js", include_in_schema=False) +def dashboard_script() -> Response: + return Response( + (STATIC_ROOT / "dashboard.js").read_text(encoding="utf-8"), + media_type="text/javascript", + headers={"Cache-Control": "no-store"}, + ) + + @router.get("/assets/minerops-mine-map.js", include_in_schema=False) def mine_map_script() -> Response: return Response( diff --git a/backend/app/frontend/static/app.css b/backend/app/frontend/static/app.css index d486f5e..dd10772 100644 --- a/backend/app/frontend/static/app.css +++ b/backend/app/frontend/static/app.css @@ -1489,3 +1489,132 @@ button.map-position:hover { .repair-search, .repair-filter-actions { grid-column: auto; } .repair-table td { grid-template-columns: 90px minmax(0, 1fr); } } + +/* Dashboard */ +.dashboard-status-band { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.dashboard-status-band p { margin: 0; } +.dashboard-permission-note { color: var(--muted); font-size: 12px; } + +.dashboard-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.dashboard-section { + min-width: 0; + border: 1px solid var(--line); + border-radius: 6px; + padding: 16px; + background: var(--surface); +} + +.dashboard-section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.dashboard-section-head .eyebrow { margin: 0 0 3px; } +.dashboard-section h2 { margin: 0; font-size: 19px; } +.dashboard-section h3 { margin: 0 0 8px; font-size: 14px; } +.dashboard-section .object-link { white-space: nowrap; } + +.dashboard-total { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + border-left: 4px solid var(--accent); + padding: 9px 11px; + background: #f6f8fa; +} + +.dashboard-total span { color: var(--muted); font-weight: 700; } +.dashboard-total strong { font-size: 25px; } + +.dashboard-dimension-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; + margin-top: 16px; +} + +.dashboard-count-list, +.dashboard-status-list { + display: grid; + gap: 0; + margin: 0; +} + +.dashboard-count-list > div, +.dashboard-status-list > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 36px; + border-top: 1px solid #e3e7eb; +} + +.dashboard-count-list dt, +.dashboard-status-list dt { color: #40505f; } +.dashboard-count-list dd, +.dashboard-status-list dd { margin: 0; font-weight: 800; } +.dashboard-status-list { margin-top: 10px; } +.dashboard-status-list a { font-weight: 700; } +.dashboard-boundary { margin: 13px 0 0; color: var(--muted); font-size: 12px; } + +.attention-list { display: grid; gap: 7px; } +.attention-list a { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 42px; + border: 1px solid var(--line); + border-left-width: 4px; + border-radius: 5px; + padding: 8px 11px; + background: #fff; + text-decoration: none; +} + +.attention-list a[data-state="offline"] { border-left-color: var(--danger); } +.attention-list a[data-state="unknown"] { border-left-color: #596675; } +.attention-list a[data-state="unstable"] { border-left-color: var(--warn); } +.attention-list a[data-state="repair"] { border-left-color: #6b4ca5; } +.attention-list span { font-weight: 750; } +.attention-list strong { font-size: 18px; } + +.dashboard-location-counts { + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 18px; +} + +@media (max-width: 900px) { + .dashboard-grid { grid-template-columns: 1fr; } +} + +@media (max-width: 560px) { + .dashboard-status-band, + .dashboard-section-head { align-items: flex-start; flex-direction: column; } + .dashboard-dimension-grid { grid-template-columns: 1fr; } + .dashboard-location-counts { grid-template-columns: 1fr; } + .dashboard-section { padding: 13px; } + .dashboard-section .object-link { white-space: normal; } +} + +@media (prefers-reduced-motion: reduce) { + .dashboard-section *, + .attention-list * { scroll-behavior: auto; } +} diff --git a/backend/app/frontend/static/dashboard.js b/backend/app/frontend/static/dashboard.js new file mode 100644 index 0000000..bb7e8db --- /dev/null +++ b/backend/app/frontend/static/dashboard.js @@ -0,0 +1,155 @@ +(function () { + "use strict"; + + if (document.body.dataset.view !== "dashboard") return; + + const ids = [ + "dashboardLoading", "dashboardError", "dashboardErrorDetail", + "dashboardRetry", "dashboardEmpty", "dashboardContent", + "dashboardLoadStatus", "dashboardFleetTotal", "dashboardRuntimeOnline", + "dashboardRuntimeOffline", "dashboardRuntimeUnknown", + "dashboardLifecycleRepair", "dashboardLifecycleSpare", + "dashboardLifecycleRetired", "dashboardAttentionOffline", + "dashboardAttentionUnknown", "dashboardAttentionUnstable", + "dashboardAttentionRepair", "dashboardActiveRepairs", + "dashboardRepairStatuses", "dashboardSites", "dashboardRooms", + "dashboardPositions", "dashboardOccupied", "dashboardEmptyPositions", + "dashboardMappedMiners", "dashboardUnmappedMiners" + ]; + const el = {}; + for (const id of ids) el[id] = document.getElementById(id); + let controller = null; + + const STATUS_LABELS = { + OPEN: "Open", + IN_PROGRESS: "In progress", + WAITING_FOR_PARTS: "Waiting for parts", + READY_FOR_TEST: "Ready for test", + open: "Open", + diagnosing: "Diagnosing", + waiting_parts: "Waiting parts", + in_progress: "In progress", + testing: "Testing", + ready_to_shelf: "Ready to shelf", + needs_followup: "Needs follow-up", + repair: "In repair", + unknown: "Unknown" + }; + + function setCount(node, value) { + if (node) node.textContent = Number.isInteger(value) ? String(value) : "Unavailable"; + } + + function show(name) { + el.dashboardLoading.hidden = name !== "loading"; + el.dashboardError.hidden = name !== "error"; + el.dashboardEmpty.hidden = name !== "empty"; + el.dashboardContent.hidden = name !== "ready"; + } + + function statusLabel(value) { + if (Object.prototype.hasOwnProperty.call(STATUS_LABELS, value)) { + return STATUS_LABELS[value]; + } + return String(value || "Unknown").replaceAll("_", " "); + } + + function renderFleet(fleet) { + if (!fleet || !el.dashboardFleetTotal) return; + setCount(el.dashboardFleetTotal, fleet.total); + setCount(el.dashboardRuntimeOnline, fleet.runtime_online); + setCount(el.dashboardRuntimeOffline, fleet.runtime_offline); + setCount(el.dashboardRuntimeUnknown, fleet.runtime_unknown); + setCount(el.dashboardLifecycleRepair, fleet.lifecycle_in_repair); + setCount(el.dashboardLifecycleSpare, fleet.lifecycle_spare); + setCount(el.dashboardLifecycleRetired, fleet.lifecycle_retired); + const attention = fleet.attention || {}; + setCount(el.dashboardAttentionOffline, attention.offline); + setCount(el.dashboardAttentionUnknown, attention.unknown); + setCount(el.dashboardAttentionUnstable, attention.unstable); + setCount(el.dashboardAttentionRepair, attention.repair); + } + + function renderRepairs(repairs) { + if (!repairs || !el.dashboardActiveRepairs) return; + setCount(el.dashboardActiveRepairs, repairs.active); + const entries = Object.entries(repairs.status_counts || {}).sort(function (left, right) { + return statusLabel(left[0]).localeCompare(statusLabel(right[0])); + }); + el.dashboardRepairStatuses.replaceChildren(); + if (!entries.length) { + const row = document.createElement("div"); + const term = document.createElement("dt"); + const value = document.createElement("dd"); + term.textContent = "No active repair statuses"; + value.textContent = "0"; + row.append(term, value); + el.dashboardRepairStatuses.appendChild(row); + return; + } + for (const [status, count] of entries) { + const row = document.createElement("div"); + const term = document.createElement("dt"); + const link = document.createElement("a"); + const value = document.createElement("dd"); + const params = new URLSearchParams({ scope: "active", status: status }); + link.href = "/operations/repairs?" + params.toString(); + link.textContent = statusLabel(status); + term.appendChild(link); + value.textContent = Number.isInteger(count) ? String(count) : "Unavailable"; + row.append(term, value); + el.dashboardRepairStatuses.appendChild(row); + } + } + + function renderLocations(locations) { + if (!locations || !el.dashboardSites) return; + setCount(el.dashboardSites, locations.sites); + setCount(el.dashboardRooms, locations.rooms); + setCount(el.dashboardPositions, locations.positions); + setCount(el.dashboardOccupied, locations.occupied_positions); + setCount(el.dashboardEmptyPositions, locations.empty_positions); + setCount(el.dashboardMappedMiners, locations.mapped_miners); + setCount(el.dashboardUnmappedMiners, locations.unmapped_miners); + } + + async function loadOverview() { + if (controller) controller.abort(); + controller = new AbortController(); + show("loading"); + try { + const response = await fetch("/api/dashboard/overview", { + credentials: "same-origin", + headers: { Accept: "application/json" }, + signal: controller.signal + }); + if (response.status === 401) { + window.location.replace("/login?next=" + encodeURIComponent("/dashboard")); + return; + } + if (!response.ok) throw new Error("The overview service returned " + response.status + "."); + const payload = await response.json(); + const data = payload && payload.data ? payload.data : {}; + const available = payload && payload.meta ? payload.meta.available_sections || [] : []; + if (!available.length) { + show("empty"); + return; + } + renderFleet(data.fleet); + renderRepairs(data.repairs); + renderLocations(data.locations); + const generated = new Date(data.generated_at); + el.dashboardLoadStatus.textContent = Number.isNaN(generated.valueOf()) + ? "Operational summary loaded" + : "Operational summary generated " + generated.toLocaleString(); + show("ready"); + } catch (error) { + if (error.name === "AbortError") return; + el.dashboardErrorDetail.textContent = error.message || "The operational overview could not be loaded."; + show("error"); + } + } + + el.dashboardRetry.addEventListener("click", loadOverview); + loadOverview(); +}()); diff --git a/backend/app/frontend/views.py b/backend/app/frontend/views.py index 6ac084a..e994280 100644 --- a/backend/app/frontend/views.py +++ b/backend/app/frontend/views.py @@ -13,7 +13,7 @@ NAVIGATION = ( PAGE_DETAILS = { "dashboard": ( - "Fleet overview", + "Operational overview", "Dashboard", "A concise starting point for operational state and recent activity.", ), @@ -35,46 +35,104 @@ PAGE_DETAILS = { } -def _dashboard_content() -> str: - metrics = ( - ("Site health", "--", "Live health summary not yet connected"), - ("Total miners", "--", "Inventory summary not yet connected"), - ("Miners in repair", "--", "Repair summary not yet connected"), - ("Current hashrate", "-- TH/s", "Telemetry not yet connected"), - ("Current power", "-- kW", "Telemetry not yet connected"), - ) - metric_html = "".join( - "
" - f"{escape(label)}" - f"{escape(value)}" - f"{escape(state)}" - "
" - for label, value, state in metrics +def _dashboard_content( + can_view_miners: bool, + can_view_repairs: bool, + can_view_locations: bool, +) -> str: + fleet = """ +
+

Assets

+

Fleet overview

+ Open Fleet
+
Total miners + -
+
+

Runtime

+
+
Online
-
+
Offline
-
+
Unknown
-
+
+

Lifecycle

+
+
In repair
-
+
Spare
-
+
Retired
-
+
+
+

Runtime and lifecycle are separate dimensions and may overlap.

+
+
+

Current state

+

Needs attention

+ +
+ """ if can_view_miners else "" + repairs = """ +
+

Operations

+

Repairs

+ Open Repair Center
+
Active repair records + -
+
+
+ """ if can_view_repairs else "" + mapped_metrics = """ +
Mapped miners
-
+
Unmapped miners
-
+ """ if can_view_miners else "" + locations = f""" +
+

Spatial operations

+

Locations

+ Open Mine Map
+
+
Sites
-
+
Rooms
-
+
Positions
-
+
Occupied
-
+
Empty
-
+ {mapped_metrics} +
+
+ """ if can_view_locations else "" + available = sum((can_view_miners, can_view_repairs, can_view_locations)) + partial = ( + '

This overview includes only the ' + 'operational areas available to your account.

' + if 0 < available < 3 else "" ) return f""" -
- {metric_html} -
-
-
-
-
-

Recent activity

-

Operational events will appear here when - the dashboard activity feed is connected.

-
-
-
- No activity feed connected - No events are fabricated for this placeholder. -
-
- +
+ Loading operational overview + Requesting current fleet, repair, and location summaries. +
+ + + """ @@ -345,14 +403,6 @@ def _miners_content(can_view_locations: bool) -> str: """ -CONTENT = { - "dashboard": _dashboard_content, - "map": _map_content, - "repairs": _repairs_content, - "miners": _miners_content, -} - - def render_app_page( view: str, current_user: dict, @@ -395,7 +445,11 @@ def render_app_page( has_permission("locations.view"), has_permission("miners.view") ) if view == "repairs" - else CONTENT[view]() + else _dashboard_content( + has_permission("miners.view"), + has_permission("repairs.view"), + has_permission("locations.view"), + ) ) page_script = ( '' @@ -404,6 +458,8 @@ def render_app_page( if view == "miners" else '' if view == "repairs" + else '' + if view == "dashboard" else "" ) return f""" diff --git a/backend/app/inventory/models.py b/backend/app/inventory/models.py index eb7b8a5..de278d0 100644 --- a/backend/app/inventory/models.py +++ b/backend/app/inventory/models.py @@ -171,3 +171,21 @@ class InventoryAssetIDHistoryResponse(BaseModel): data: list[InventoryAssetIDHistoryEntry] meta: dict[str, object] = Field(default_factory=dict) error: None = None + + +class InventoryFleetAttentionSummary(BaseModel): + offline: int + unknown: int + unstable: int + repair: int + + +class InventoryFleetSummary(BaseModel): + total: int + runtime_online: int + runtime_offline: int + runtime_unknown: int + lifecycle_in_repair: int + lifecycle_spare: int + lifecycle_retired: int + attention: InventoryFleetAttentionSummary diff --git a/backend/app/inventory/repository.py b/backend/app/inventory/repository.py index 5e3421b..991883d 100644 --- a/backend/app/inventory/repository.py +++ b/backend/app/inventory/repository.py @@ -292,6 +292,70 @@ class InventoryRepository: include_location_details=include_location_details, ) + def summarize_fleet(self) -> dict[str, Any]: + """Return exact fleet-wide counts using Inventory state semantics.""" + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + f""" + WITH fleet_state AS ( + SELECT miners.status, miners.lifecycle_status, + ({OPERATIONAL_STATE_SQL}) AS operational_state + FROM miners + LEFT JOIN LATERAL ( + SELECT snapshots.hash_stable + FROM miner_status_snapshots AS snapshots + WHERE snapshots.miner_id = miners.id + ORDER BY snapshots.observed_at DESC, snapshots.id DESC + LIMIT 1 + ) AS latest ON true + LEFT JOIN LATERAL ( + SELECT repairs.id + FROM repairs + WHERE repairs.miner_id = miners.id + AND repairs.status = ANY(%s) + ORDER BY repairs.opened_at, repairs.id + LIMIT 1 + ) AS active_repair ON true + ) + SELECT count(*)::integer AS total, + count(*) FILTER ( + WHERE lower(trim(status)) = 'online' + )::integer AS runtime_online, + count(*) FILTER ( + WHERE lower(trim(status)) = 'offline' + )::integer AS runtime_offline, + count(*) FILTER ( + WHERE lower(trim(status)) NOT IN ('online', 'offline') + OR status IS NULL + )::integer AS runtime_unknown, + count(*) FILTER ( + WHERE lifecycle_status = 'in_repair' + )::integer AS lifecycle_in_repair, + count(*) FILTER ( + WHERE lifecycle_status = 'spare' + )::integer AS lifecycle_spare, + count(*) FILTER ( + WHERE lifecycle_status = 'retired' + )::integer AS lifecycle_retired, + count(*) FILTER ( + WHERE operational_state = 'offline' + )::integer AS attention_offline, + count(*) FILTER ( + WHERE operational_state = 'unknown' + )::integer AS attention_unknown, + count(*) FILTER ( + WHERE operational_state = 'unstable' + )::integer AS attention_unstable, + count(*) FILTER ( + WHERE operational_state = 'repair' + )::integer AS attention_repair + FROM fleet_state + """, + (list(ACTIVE_REPAIR_STATUSES),), + ) + return dict(cur.fetchone()) + def search_miners( self, *, diff --git a/backend/app/inventory/service.py b/backend/app/inventory/service.py index 73dc960..c70175e 100644 --- a/backend/app/inventory/service.py +++ b/backend/app/inventory/service.py @@ -14,6 +14,8 @@ from app.inventory.exceptions import ( from app.inventory.models import ( InventoryAssetIDHistoryEntry, InventoryCreateRequest, + InventoryFleetAttentionSummary, + InventoryFleetSummary, InventoryMetadataUpdateRequest, InventoryMinerDetail, InventoryMinerSummary, @@ -157,6 +159,24 @@ class InventoryService: for row in self.repository.list_miners(limit=limit, offset=offset) ] + def summarize_fleet(self) -> InventoryFleetSummary: + row = self.repository.summarize_fleet() + return InventoryFleetSummary( + total=row["total"], + runtime_online=row["runtime_online"], + runtime_offline=row["runtime_offline"], + runtime_unknown=row["runtime_unknown"], + lifecycle_in_repair=row["lifecycle_in_repair"], + lifecycle_spare=row["lifecycle_spare"], + lifecycle_retired=row["lifecycle_retired"], + attention=InventoryFleetAttentionSummary( + offline=row["attention_offline"], + unknown=row["attention_unknown"], + unstable=row["attention_unstable"], + repair=row["attention_repair"], + ), + ) + def search_miners( self, filters: InventorySearchFilters, diff --git a/backend/app/locations/models.py b/backend/app/locations/models.py index 934b55e..b078f89 100644 --- a/backend/app/locations/models.py +++ b/backend/app/locations/models.py @@ -116,3 +116,13 @@ class MineMapResponse(BaseModel): data: MineMapData meta: MineMapMeta error: None = None + + +class LocationSummary(BaseModel): + sites: int + rooms: int + positions: int + occupied_positions: int + empty_positions: int + mapped_miners: int | None = None + unmapped_miners: int | None = None diff --git a/backend/app/locations/repository.py b/backend/app/locations/repository.py index e9681fb..20d17b4 100644 --- a/backend/app/locations/repository.py +++ b/backend/app/locations/repository.py @@ -201,3 +201,31 @@ class LocationMapRepository: "positions": positions, "virtual_miners": virtual_miners, } + + def summarize_locations(self, *, include_miner_counts: bool) -> dict[str, Any]: + miner_counts = ( + "count(DISTINCT lp.miner_id)::integer AS mapped_miners, " + "(SELECT count(*)::integer FROM miners WHERE NOT EXISTS (" + "SELECT 1 FROM location_positions assigned " + "WHERE assigned.miner_id = miners.id)) AS unmapped_miners" + if include_miner_counts + else "NULL::integer AS mapped_miners, NULL::integer AS unmapped_miners" + ) + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + f""" + SELECT (SELECT count(*)::integer FROM sites) AS sites, + (SELECT count(*)::integer FROM rooms) AS rooms, + count(lp.id)::integer AS positions, + count(lp.id) FILTER ( + WHERE lp.miner_id IS NOT NULL + )::integer AS occupied_positions, + count(lp.id) FILTER ( + WHERE lp.miner_id IS NULL + )::integer AS empty_positions, + {miner_counts} + FROM location_positions AS lp + """ + ) + return dict(cur.fetchone()) diff --git a/backend/app/locations/service.py b/backend/app/locations/service.py index 6c80028..9858242 100644 --- a/backend/app/locations/service.py +++ b/backend/app/locations/service.py @@ -5,6 +5,7 @@ from typing import Any from uuid import UUID from app.locations.models import ( + LocationSummary, MapActiveRepair, MapPosition, MapRoom, @@ -85,3 +86,10 @@ class LocationMapService: miner_details_included=include_miner_details, ), ) + + def summarize_locations(self, *, include_miner_counts: bool) -> LocationSummary: + return LocationSummary.model_validate( + self.repository.summarize_locations( + include_miner_counts=include_miner_counts + ) + ) diff --git a/backend/app/main.py b/backend/app/main.py index 63e144d..b6bc3bd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,6 +11,7 @@ from fastapi import Request, Depends, FastAPI, Header, HTTPException, Query, sta from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError from app.config import settings +from app.dashboard.router import router as dashboard_router from app.identity import ( COOKIE_TOKEN_HINT, SESSION_COOKIE, @@ -20,7 +21,10 @@ from app.identity import ( user_has_permission, router as identity_router, ) -from app.frontend.router import router as frontend_router +from app.frontend.router import ( + dashboard_page as internal_alpha_dashboard_page, + router as frontend_router, +) from app.inventory.router import router as inventory_router from app.locations.router import router as locations_router from app.components.router import router as components_router @@ -55,6 +59,7 @@ app.include_router(frontend_router) app.include_router(inventory_router) app.include_router(components_router) app.include_router(locations_router) +app.include_router(dashboard_router) install_identity_middleware(app) @@ -33444,6 +33449,9 @@ async def minerops_inject_repair_polish_assets(request, call_next): try: path = request.url.path or "" + if request.scope.get("endpoint") is internal_alpha_dashboard_page: + return response + if path not in ("/dashboard", "/locations", "/repairs"): return response @@ -33799,6 +33807,9 @@ async def minerops_inject_dashboard_repair_summary(request, call_next): response = await call_next(request) try: + if request.scope.get("endpoint") is internal_alpha_dashboard_page: + return response + path = request.url.path or "" if path != "/dashboard": diff --git a/backend/app/repairs/center_repository.py b/backend/app/repairs/center_repository.py index fb090bc..3b49dd2 100644 --- a/backend/app/repairs/center_repository.py +++ b/backend/app/repairs/center_repository.py @@ -314,3 +314,31 @@ class RepairCenterRepository: finally: connection.close() return {"items": items, "summary": summary, "location_options": options} + + def summarize_active_repairs(self) -> dict[str, Any]: + """Return exact active counts without loading Repair Center rows.""" + ctes = self._ctes(show_miners=False, show_locations=False) + connection = self._connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + f""" + {ctes}, active_rows AS ( + SELECT repair_status FROM candidates WHERE active + ) + SELECT count(*)::integer AS active, + COALESCE((SELECT jsonb_object_agg( + status_rows.repair_status, + status_rows.status_count + ) FROM ( + SELECT repair_status, + count(*)::integer AS status_count + FROM active_rows + GROUP BY repair_status + ) AS status_rows), '{{}}'::jsonb) AS status_counts + FROM active_rows + """ + ) + return dict(cur.fetchone()) + finally: + connection.close() diff --git a/backend/app/repairs/center_service.py b/backend/app/repairs/center_service.py index 00ad594..d49ba64 100644 --- a/backend/app/repairs/center_service.py +++ b/backend/app/repairs/center_service.py @@ -2,6 +2,7 @@ from app.repairs.center_repository import RepairCenterRepository from app.repairs.models import ( + ActiveRepairSummary, RepairCenterAccess, RepairCenterCapabilities, RepairCenterFilters, @@ -94,3 +95,8 @@ class RepairCenterService: location_options=location_options, ), ) + + def summarize_active_repairs(self) -> ActiveRepairSummary: + return ActiveRepairSummary.model_validate( + self.repository.summarize_active_repairs() + ) diff --git a/backend/app/repairs/models.py b/backend/app/repairs/models.py index c370c9f..7959cac 100644 --- a/backend/app/repairs/models.py +++ b/backend/app/repairs/models.py @@ -389,6 +389,11 @@ class RepairCenterSummary(BaseModel): error_counts: dict[str, int] = Field(default_factory=dict) +class ActiveRepairSummary(BaseModel): + active: int + status_counts: dict[str, int] = Field(default_factory=dict) + + class RepairCenterLocationOption(BaseModel): id: UUID name: str diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py new file mode 100644 index 0000000..a4006d3 --- /dev/null +++ b/backend/tests/test_dashboard.py @@ -0,0 +1,347 @@ +import ast +import unittest +from datetime import datetime, timezone +from pathlib import Path +from uuid import uuid4 + +import psycopg +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.config import settings +from app.dashboard.dependencies import get_dashboard_service +from app.dashboard.models import ( + DashboardAccess, + DashboardOverviewData, + DashboardOverviewResponse, +) +from app.dashboard.router import router +from app.dashboard.service import DashboardService +from app.identity import get_current_user +from app.inventory.models import ( + InventoryFleetAttentionSummary, + InventoryFleetSummary, +) +from app.inventory.repository import InventoryRepository +from app.locations.models import LocationSummary +from app.locations.repository import LocationMapRepository +from app.repairs.center_repository import RepairCenterRepository +from app.repairs.models import ActiveRepairSummary + + +NOW = datetime(2026, 8, 11, 12, 0, tzinfo=timezone.utc) +APP_ROOT = Path(__file__).parents[1] / "app" + + +def fleet_summary() -> InventoryFleetSummary: + return InventoryFleetSummary( + total=12, + runtime_online=7, + runtime_offline=3, + runtime_unknown=2, + lifecycle_in_repair=2, + lifecycle_spare=1, + lifecycle_retired=1, + attention=InventoryFleetAttentionSummary( + offline=2, + unknown=1, + unstable=1, + repair=2, + ), + ) + + +class FakeInventoryService: + def __init__(self): + self.calls = 0 + + def summarize_fleet(self): + self.calls += 1 + return fleet_summary() + + +class FakeRepairService: + def __init__(self): + self.calls = 0 + + def summarize_active_repairs(self): + self.calls += 1 + return ActiveRepairSummary( + active=3, + status_counts={"OPEN": 2, "waiting_parts": 1}, + ) + + +class FakeLocationService: + def __init__(self): + self.calls = [] + + def summarize_locations(self, *, include_miner_counts): + self.calls.append(include_miner_counts) + return LocationSummary( + sites=2, + rooms=4, + positions=20, + occupied_positions=12, + empty_positions=8, + mapped_miners=12 if include_miner_counts else None, + unmapped_miners=0 if include_miner_counts else None, + ) + + +class DashboardServiceTests(unittest.TestCase): + def setUp(self): + self.inventory = FakeInventoryService() + self.repairs = FakeRepairService() + self.locations = FakeLocationService() + self.service = DashboardService( + self.inventory, + self.repairs, + self.locations, + ) + + def test_composes_each_authorized_domain_once(self): + result = self.service.get_overview( + DashboardAccess( + can_view_miners=True, + can_view_repairs=True, + can_view_locations=True, + ) + ) + self.assertEqual(result.data.fleet.total, 12) + self.assertEqual(result.data.repairs.active, 3) + self.assertEqual(result.data.locations.mapped_miners, 12) + self.assertEqual(self.inventory.calls, 1) + self.assertEqual(self.repairs.calls, 1) + self.assertEqual(self.locations.calls, [True]) + self.assertEqual( + result.meta.available_sections, + ["fleet", "repairs", "locations"], + ) + + def test_unauthorized_domains_are_not_called_or_returned(self): + result = self.service.get_overview(DashboardAccess()) + self.assertIsNone(result.data.fleet) + self.assertIsNone(result.data.repairs) + self.assertIsNone(result.data.locations) + self.assertEqual(result.meta.available_sections, []) + self.assertEqual(self.inventory.calls, 0) + self.assertEqual(self.repairs.calls, 0) + self.assertEqual(self.locations.calls, []) + + def test_location_only_summary_omits_miner_placement_counts(self): + result = self.service.get_overview( + DashboardAccess(can_view_locations=True) + ) + self.assertEqual(self.locations.calls, [False]) + self.assertIsNone(result.data.locations.mapped_miners) + self.assertIsNone(result.data.locations.unmapped_miners) + self.assertIsNone(result.data.fleet) + + +class StubDashboardService: + def __init__(self): + self.access = None + + def get_overview(self, access): + self.access = access + return DashboardOverviewResponse( + data=DashboardOverviewData( + fleet=fleet_summary() if access.can_view_miners else None, + repairs=( + ActiveRepairSummary(active=1, status_counts={"OPEN": 1}) + if access.can_view_repairs else None + ), + locations=( + LocationSummary( + sites=1, + rooms=1, + positions=2, + occupied_positions=1, + empty_positions=1, + mapped_miners=1 if access.can_view_miners else None, + unmapped_miners=11 if access.can_view_miners else None, + ) + if access.can_view_locations else None + ), + generated_at=NOW, + ), + meta={ + "available_sections": [ + name + for name, allowed in ( + ("fleet", access.can_view_miners), + ("repairs", access.can_view_repairs), + ("locations", access.can_view_locations), + ) + if allowed + ] + }, + ) + + +class DashboardRouterTests(unittest.TestCase): + def setUp(self): + self.service = StubDashboardService() + self.app = FastAPI() + self.app.include_router(router) + self.app.dependency_overrides[get_dashboard_service] = lambda: self.service + self.client = TestClient(self.app) + + def tearDown(self): + self.client.close() + + def authenticate(self, permissions): + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(uuid4()), + "role": "Custom", + "permissions": list(permissions), + } + + def test_requires_authentication_and_dashboard_permission(self): + self.assertEqual(self.client.get("/api/dashboard/overview").status_code, 401) + self.authenticate(("miners.view",)) + self.assertEqual(self.client.get("/api/dashboard/overview").status_code, 403) + + def test_returns_one_typed_permission_aware_envelope(self): + self.authenticate(("dashboard.view", "miners.view", "locations.view")) + response = self.client.get("/api/dashboard/overview") + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["data"]["fleet"]["total"], 12) + self.assertIsNone(payload["data"]["repairs"]) + self.assertEqual(payload["data"]["locations"]["mapped_miners"], 1) + self.assertEqual( + payload["meta"]["available_sections"], + ["fleet", "locations"], + ) + self.assertEqual(response.headers["cache-control"], "no-store") + self.assertFalse(self.service.access.can_view_repairs) + + def test_location_only_response_contains_no_miner_counts(self): + self.authenticate(("dashboard.view", "locations.view")) + payload = self.client.get("/api/dashboard/overview").json() + self.assertIsNone(payload["data"]["fleet"]) + self.assertIsNone(payload["data"]["locations"]["mapped_miners"]) + self.assertIsNone(payload["data"]["locations"]["unmapped_miners"]) + + +class DashboardRepositoryIntegrationTests(unittest.TestCase): + def setUp(self): + self.connection = psycopg.connect( + host=settings.postgres_host, + port=settings.postgres_port, + dbname=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + ) + + def tearDown(self): + self.connection.rollback() + self.connection.close() + + def test_fleet_counts_are_exact_unbounded_and_preserve_overlap(self): + repository = InventoryRepository(connection=self.connection) + before = repository.summarize_fleet() + rows = [] + states = ( + ("offline", "in_repair"), + ("online", "spare"), + ("maintenance", "retired"), + ("online", "active"), + ("offline", "active"), + ("unknown", "active"), + ) + for runtime, lifecycle in states: + rows.append((uuid4(), runtime, lifecycle)) + bulk = [(uuid4(), "online", "active") for _ in range(501)] + with self.connection.cursor() as cur: + cur.executemany( + "INSERT INTO miners (id, status, lifecycle_status, first_seen_at, last_seen_at) " + "VALUES (%s, %s, %s, NULL, NULL)", + [*rows, *bulk], + ) + cur.execute( + "INSERT INTO miner_status_snapshots (miner_id, hash_stable) VALUES (%s, false)", + (rows[3][0],), + ) + after = repository.summarize_fleet() + self.assertEqual(after["total"] - before["total"], 507) + self.assertEqual(after["runtime_online"] - before["runtime_online"], 503) + self.assertEqual(after["runtime_offline"] - before["runtime_offline"], 2) + self.assertEqual(after["runtime_unknown"] - before["runtime_unknown"], 2) + self.assertEqual(after["lifecycle_in_repair"] - before["lifecycle_in_repair"], 1) + self.assertEqual(after["lifecycle_spare"] - before["lifecycle_spare"], 1) + self.assertEqual(after["lifecycle_retired"] - before["lifecycle_retired"], 1) + self.assertEqual(after["attention_repair"] - before["attention_repair"], 1) + self.assertEqual(after["attention_offline"] - before["attention_offline"], 1) + self.assertEqual(after["attention_unknown"] - before["attention_unknown"], 1) + self.assertEqual(after["attention_unstable"] - before["attention_unstable"], 1) + + def test_location_summary_uses_current_positions_not_virtual_map_subset(self): + repository = LocationMapRepository(connection=self.connection) + before = repository.summarize_locations(include_miner_counts=True) + site_id, room_id = uuid4(), uuid4() + mapped_id, unmapped_id = uuid4(), uuid4() + with self.connection.cursor() as cur: + cur.execute("INSERT INTO sites (id, name) VALUES (%s, %s)", (site_id, str(site_id))) + cur.execute( + "INSERT INTO rooms (id, site_id, name) VALUES (%s, %s, %s)", + (room_id, site_id, str(room_id)), + ) + cur.executemany( + "INSERT INTO miners (id, lifecycle_status, first_seen_at, last_seen_at) " + "VALUES (%s, 'active', NULL, NULL)", + [(mapped_id,), (unmapped_id,)], + ) + cur.execute( + "INSERT INTO location_positions (room_id, room_name, rack_name, shelf_name, " + "position_index, miner_id) VALUES (%s, 'Room', 'A', '1', 1, %s), " + "(%s, 'Room', 'A', '1', 2, NULL)", + (room_id, mapped_id, room_id), + ) + after = repository.summarize_locations(include_miner_counts=True) + self.assertEqual(after["sites"] - before["sites"], 1) + self.assertEqual(after["rooms"] - before["rooms"], 1) + self.assertEqual(after["positions"] - before["positions"], 2) + self.assertEqual(after["occupied_positions"] - before["occupied_positions"], 1) + self.assertEqual(after["empty_positions"] - before["empty_positions"], 1) + self.assertEqual(after["mapped_miners"] - before["mapped_miners"], 1) + self.assertEqual(after["unmapped_miners"] - before["unmapped_miners"], 1) + + def test_repair_summary_executes_without_loading_queue_items(self): + summary = RepairCenterRepository().summarize_active_repairs() + self.assertIn("active", summary) + self.assertIn("status_counts", summary) + + +class DashboardArchitectureTests(unittest.TestCase): + def test_router_and_service_have_no_sql_or_database_driver_imports(self): + for relative in ("dashboard/router.py", "dashboard/service.py"): + source = (APP_ROOT / relative).read_text(encoding="utf-8") + modules = [] + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Import): + modules.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + modules.append(node.module) + self.assertFalse( + any(name.startswith(("psycopg", "sqlalchemy", "asyncpg")) for name in modules) + ) + for keyword in ("SELECT ", "INSERT ", "UPDATE ", "DELETE "): + self.assertNotIn(keyword, source.upper()) + + def test_dashboard_frontend_has_one_read_only_request_and_safe_dom_writes(self): + source = (APP_ROOT / "frontend" / "static" / "dashboard.js").read_text( + encoding="utf-8" + ) + self.assertEqual(source.count('fetch("/api/dashboard/overview"'), 1) + self.assertNotIn("innerHTML", source) + self.assertNotIn("setInterval", source) + self.assertNotIn('method: "POST"', source) + self.assertNotIn('method: "PATCH"', source) + self.assertNotIn('method: "DELETE"', source) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_frontend_shell.py b/backend/tests/test_frontend_shell.py index 8e40376..a18d1f7 100644 --- a/backend/tests/test_frontend_shell.py +++ b/backend/tests/test_frontend_shell.py @@ -132,13 +132,41 @@ class FrontendShellTests(unittest.TestCase): def test_dashboard_does_not_fabricate_operational_values(self): self.authenticate() response = self.client.get("/dashboard") - self.assertIn( - "Live health summary not yet connected", - response.text, - ) - self.assertIn("No events are fabricated", response.text) + for value in ( + 'id="dashboardFleetTotal"', + 'id="dashboardActiveRepairs"', + 'id="dashboardSites"', + "/assets/minerops-dashboard.js", + "Runtime and lifecycle are separate dimensions", + ): + self.assertIn(value, response.text) + self.assertIn("Loading operational overview", response.text) + self.assertNotIn("Live health summary not yet connected", response.text) self.assertNotIn("100 TH/s", response.text) + def test_dashboard_html_omits_unauthorized_sections_and_links(self): + self.authenticate(permissions=("dashboard.view", "locations.view")) + response = self.client.get("/dashboard") + self.assertIn("Locations", response.text) + self.assertIn("Open Mine Map", response.text) + self.assertNotIn("Fleet overview", response.text) + self.assertNotIn("Needs attention", response.text) + self.assertNotIn("Open Fleet", response.text) + self.assertNotIn("Active repair records", response.text) + self.assertNotIn("Open Repair Center", response.text) + self.assertNotIn("Mapped miners", response.text) + self.assertNotIn("Unmapped miners", response.text) + + def test_dashboard_script_dom_contract_matches_authorized_page(self): + self.authenticate() + response = self.client.get("/dashboard") + javascript = (STATIC_ROOT / "dashboard.js").read_text(encoding="utf-8") + declared_ids = re.search(r"const ids = \[(.*?)\];", javascript, re.DOTALL) + self.assertIsNotNone(declared_ids) + required = set(re.findall(r'"([A-Za-z][A-Za-z0-9]+)"', declared_ids.group(1))) + rendered = set(re.findall(r'id="([A-Za-z][A-Za-z0-9]+)"', response.text)) + self.assertEqual(required - rendered, set()) + def test_map_is_a_structured_operational_view(self): self.authenticate() response = self.client.get("/monitoring/map") @@ -230,6 +258,7 @@ class FrontendShellTests(unittest.TestCase): def test_live_app_prefers_new_shell_and_preserves_legacy_pages(self): from app.main import app + from app.frontend.router import dashboard_page first_handlers = {} for route in app.routes: @@ -250,6 +279,55 @@ class FrontendShellTests(unittest.TestCase): with self.subTest(legacy_path=path): self.assertIn(path, first_handlers) + dashboard_handlers = [ + route.endpoint + for route in app.routes + if route.path == "/dashboard" and "GET" in (route.methods or set()) + ] + self.assertIs(dashboard_handlers[0], dashboard_page) + self.assertEqual(dashboard_handlers.count(dashboard_page), 1) + + def test_live_dashboard_excludes_legacy_dashboard_injections(self): + from app.main import app + + current_user = { + "id": "00000000-0000-0000-0000-000000000654", + "username": "dashboard-reader", + "display_name": "Dashboard Reader", + "role": "Read Only", + "permissions": [ + "dashboard.view", + "miners.view", + "locations.view", + "repairs.view", + ], + } + app.dependency_overrides[get_current_user] = lambda: current_user + try: + with patch( + "app.identity.request_user_or_none", + return_value=current_user, + ): + client = TestClient(app) + try: + response = client.get("/dashboard") + finally: + client.close() + finally: + app.dependency_overrides.pop(get_current_user, None) + + self.assertEqual(response.status_code, 200) + self.assertIn("Dashboard | MinerOps", response.text) + self.assertIn('id="dashboardFleetTotal"', response.text) + self.assertEqual(response.text.count('id="dashboardFleetTotal"'), 1) + self.assertNotIn("/assets/minerops-repair-quick-nav.js", response.text) + self.assertNotIn( + "/assets/minerops-dashboard-repair-summary.js", + response.text, + ) + self.assertNotIn("mineropsDashboardRepairSummary", response.text) + self.assertNotIn("Repair Summary", response.text) + def test_safe_next_path_accepts_only_protected_local_pages(self): valid = ( @@ -365,6 +443,7 @@ class FrontendShellTests(unittest.TestCase): for path in ( "/assets/minerops-app.css", "/assets/minerops-app.js", + "/assets/minerops-dashboard.js", "/assets/minerops-mine-map.js", "/assets/minerops-fleet.js", ): diff --git a/backend/tests/test_mine_map.py b/backend/tests/test_mine_map.py index dc0f56b..5a3fed0 100644 --- a/backend/tests/test_mine_map.py +++ b/backend/tests/test_mine_map.py @@ -358,11 +358,42 @@ class MineMapFrontendAuditTests(unittest.TestCase): def test_map_repository_uses_constant_set_based_queries(self): source = (Path(__file__).parents[1] / "app" / "locations" / "repository.py").read_text(encoding="utf-8") - self.assertEqual(source.count("cur.execute("), 4) - self.assertEqual(source.count("LEFT JOIN LATERAL"), 2) - self.assertIn("ORDER BY snapshots.observed_at DESC, snapshots.id DESC", source) - self.assertIn("ORDER BY repairs.opened_at, repairs.id", source) - self.assertNotIn("for miner", source) + + read_map_source = source.split(" def read_map(", 1)[1].split( + " def summarize_locations(", 1 + )[0] + + latest_telemetry_sql = source.split( + 'LATEST_TELEMETRY_SQL = """', 1 + )[1].split('"""', 1)[0] + + active_repair_sql = source.split( + 'ACTIVE_REPAIR_SQL = """', 1 + )[1].split('"""', 1)[0] + + self.assertEqual(read_map_source.count("cur.execute("), 4) + self.assertEqual( + read_map_source.count("{LATEST_TELEMETRY_SQL}"), + 2, + ) + self.assertEqual( + read_map_source.count("{ACTIVE_REPAIR_SQL}"), + 2, + ) + + self.assertIn("LEFT JOIN LATERAL", latest_telemetry_sql) + self.assertIn("LEFT JOIN LATERAL", active_repair_sql) + + self.assertIn( + "ORDER BY snapshots.observed_at DESC, snapshots.id DESC", + latest_telemetry_sql, + ) + self.assertIn( + "ORDER BY repairs.opened_at, repairs.id", + active_repair_sql, + ) + + self.assertNotIn("for miner", read_map_source) if __name__ == "__main__": diff --git a/scripts/test-dashboard.sh b/scripts/test-dashboard.sh new file mode 100755 index 0000000..5feca7e --- /dev/null +++ b/scripts/test-dashboard.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" + +project_name="${MINEROPS_DASHBOARD_TEST_PROJECT:-minerops-dashboard-$$}" +compose=( + docker compose + --project-name "${project_name}" + --env-file .env.dev + -f compose.dev.yaml +) + +cleanup() { + status=$? + if (( status != 0 )); then + echo + echo "Dashboard tests failed. Recent PostgreSQL logs:" + "${compose[@]}" logs --no-color --tail 120 postgres || true + fi + "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + exit "${status}" +} + +trap cleanup EXIT + +if [[ ! -f .env.dev ]]; then + echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2 + exit 1 +fi + +echo "Starting disposable Dashboard database..." +"${compose[@]}" up --detach postgres +for attempt in $(seq 1 60); do + if "${compose[@]}" exec -T postgres sh -ec \ + 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1; then + break + fi + if (( attempt == 60 )); then + echo "PostgreSQL did not become ready." >&2 + exit 1 + fi + sleep 1 +done + +echo "Building Dashboard test image..." +"${compose[@]}" build backend + +echo "Migrating disposable database..." +"${compose[@]}" run --rm --no-deps backend alembic upgrade head + +echo "Running Dashboard and affected-domain focused tests..." +"${compose[@]}" run --rm --no-deps \ + -v "${repo_root}/backend/tests:/app/tests:ro" \ + backend python -m unittest -v \ + tests.test_dashboard \ + tests.test_frontend_shell \ + tests.test_inventory_domain \ + tests.test_inventory_router \ + tests.test_mine_map \ + tests.test_repair_center + +echo "Dashboard focused tests passed."