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 = """
+
+
+ Total miners
+ -
+
+
Runtime
+
+ - Online
- -
+ - Offline
- -
+ - Unknown
- -
+
+
Lifecycle
+
+ - In repair
- -
+ - Spare
- -
+ - Retired
- -
+
+
+ Runtime and lifecycle are separate dimensions and may overlap.
+
+
+ """ if can_view_miners else ""
+ repairs = """
+
+
+ Active repair records
+ -
+
+
+ """ if can_view_repairs else ""
+ mapped_metrics = """
+
This overview includes only the '
+ 'operational areas available to your account.
'
+ if 0 < available < 3 else ""
)
return f"""
-
-
-
-
-
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.
+
+
+ Dashboard unavailable
+ The operational overview could not be loaded.
+
+
+
+ No operational sections available
+ Your account can open Dashboard but has no additional overview permissions.
+
+
+
+
Waiting for current summaries
+ {partial}
+
+
{fleet}{repairs}{locations}
"""
@@ -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."