feat: add operational Dashboard

This commit is contained in:
lussierj committed 2026-08-17 17:11:40 +00:00
1 parent 4a1959ecd6
commit b8b741ccee
23 files changed
+1278 -59

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Permission-aware operational Dashboard read model."""
+14
View File
@@ -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(),
)
+32
View File
@@ -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
+40
View File
@@ -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",
),
)
)
+64
View File
@@ -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),
)
+9
View File
@@ -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(
+129
View File
@@ -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; }
}
+155
View File
@@ -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();
}());
+104 -48
View File
@@ -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(
"<article class='metric'>"
f"<span class='metric-label'>{escape(label)}</span>"
f"<strong class='metric-value'>{escape(value)}</strong>"
f"<span class='metric-state'>{escape(state)}</span>"
"</article>"
for label, value, state in metrics
def _dashboard_content(
can_view_miners: bool,
can_view_repairs: bool,
can_view_locations: bool,
) -> str:
fleet = """
<section class="dashboard-section dashboard-fleet" aria-labelledby="fleetOverviewTitle">
<div class="dashboard-section-head"><div><p class="eyebrow">Assets</p>
<h2 id="fleetOverviewTitle">Fleet overview</h2></div>
<a class="object-link" href="/assets/miners">Open Fleet</a></div>
<div class="dashboard-total"><span>Total miners</span>
<strong id="dashboardFleetTotal">-</strong></div>
<div class="dashboard-dimension-grid">
<section aria-labelledby="runtimeTitle"><h3 id="runtimeTitle">Runtime</h3>
<dl class="dashboard-count-list">
<div><dt>Online</dt><dd id="dashboardRuntimeOnline">-</dd></div>
<div><dt>Offline</dt><dd id="dashboardRuntimeOffline">-</dd></div>
<div><dt>Unknown</dt><dd id="dashboardRuntimeUnknown">-</dd></div>
</dl></section>
<section aria-labelledby="lifecycleTitle"><h3 id="lifecycleTitle">Lifecycle</h3>
<dl class="dashboard-count-list">
<div><dt>In repair</dt><dd id="dashboardLifecycleRepair">-</dd></div>
<div><dt>Spare</dt><dd id="dashboardLifecycleSpare">-</dd></div>
<div><dt>Retired</dt><dd id="dashboardLifecycleRetired">-</dd></div>
</dl></section>
</div>
<p class="dashboard-boundary">Runtime and lifecycle are separate dimensions and may overlap.</p>
</section>
<section class="dashboard-section dashboard-attention" aria-labelledby="attentionTitle">
<div class="dashboard-section-head"><div><p class="eyebrow">Current state</p>
<h2 id="attentionTitle">Needs attention</h2></div></div>
<nav class="attention-list" aria-label="Fleet conditions requiring attention">
<a href="/assets/miners?operational_state=offline" data-state="offline">
<span>Offline</span><strong id="dashboardAttentionOffline">-</strong></a>
<a href="/assets/miners?operational_state=unknown" data-state="unknown">
<span>Unknown</span><strong id="dashboardAttentionUnknown">-</strong></a>
<a href="/assets/miners?operational_state=unstable" data-state="unstable">
<span>Hash unstable</span><strong id="dashboardAttentionUnstable">-</strong></a>
<a href="/assets/miners?operational_state=repair" data-state="repair">
<span>In repair</span><strong id="dashboardAttentionRepair">-</strong></a>
</nav>
</section>
""" if can_view_miners else ""
repairs = """
<section class="dashboard-section" aria-labelledby="dashboardRepairsTitle">
<div class="dashboard-section-head"><div><p class="eyebrow">Operations</p>
<h2 id="dashboardRepairsTitle">Repairs</h2></div>
<a class="object-link" href="/operations/repairs?scope=active">Open Repair Center</a></div>
<div class="dashboard-total"><span>Active repair records</span>
<strong id="dashboardActiveRepairs">-</strong></div>
<dl class="dashboard-status-list" id="dashboardRepairStatuses"></dl>
</section>
""" if can_view_repairs else ""
mapped_metrics = """
<div><dt>Mapped miners</dt><dd id="dashboardMappedMiners">-</dd></div>
<div><dt>Unmapped miners</dt><dd id="dashboardUnmappedMiners">-</dd></div>
""" if can_view_miners else ""
locations = f"""
<section class="dashboard-section" aria-labelledby="dashboardLocationsTitle">
<div class="dashboard-section-head"><div><p class="eyebrow">Spatial operations</p>
<h2 id="dashboardLocationsTitle">Locations</h2></div>
<a class="object-link" href="/monitoring/map">Open Mine Map</a></div>
<dl class="dashboard-count-list dashboard-location-counts">
<div><dt>Sites</dt><dd id="dashboardSites">-</dd></div>
<div><dt>Rooms</dt><dd id="dashboardRooms">-</dd></div>
<div><dt>Positions</dt><dd id="dashboardPositions">-</dd></div>
<div><dt>Occupied</dt><dd id="dashboardOccupied">-</dd></div>
<div><dt>Empty</dt><dd id="dashboardEmptyPositions">-</dd></div>
{mapped_metrics}
</dl>
</section>
""" if can_view_locations else ""
available = sum((can_view_miners, can_view_repairs, can_view_locations))
partial = (
'<p class="dashboard-permission-note">This overview includes only the '
'operational areas available to your account.</p>'
if 0 < available < 3 else ""
)
return f"""
<section class="metrics" aria-label="Fleet summary placeholders">
{metric_html}
</section>
<div class="content-grid">
<section class="panel" aria-labelledby="activityTitle">
<div class="panel-head">
<div>
<h2 id="activityTitle">Recent activity</h2>
<p class="panel-copy">Operational events will appear here when
the dashboard activity feed is connected.</p>
</div>
</div>
<div class="state-message">
<strong>No activity feed connected</strong>
<span>No events are fabricated for this placeholder.</span>
</div>
</section>
<aside class="panel" aria-labelledby="attentionTitle">
<h2 id="attentionTitle">Data boundary</h2>
<p class="panel-copy">Summary cards intentionally show unavailable
values until live APIs are connected and freshness can be represented
accurately.</p>
</aside>
<div class="state-message" id="dashboardLoading" role="status">
<strong>Loading operational overview</strong>
<span>Requesting current fleet, repair, and location summaries.</span>
</div>
<div class="state-message" id="dashboardError" data-kind="error" role="alert" hidden>
<strong>Dashboard unavailable</strong>
<span id="dashboardErrorDetail">The operational overview could not be loaded.</span>
<button class="command-button" id="dashboardRetry" type="button">Retry</button>
</div>
<div class="state-message" id="dashboardEmpty" hidden>
<strong>No operational sections available</strong>
<span>Your account can open Dashboard but has no additional overview permissions.</span>
</div>
<div id="dashboardContent" hidden>
<div class="dashboard-status-band">
<p id="dashboardLoadStatus" class="freshness" aria-live="polite">Waiting for current summaries</p>
{partial}
</div>
<div class="dashboard-grid">{fleet}{repairs}{locations}</div>
</div>
"""
@@ -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 = (
'<script src="/assets/minerops-mine-map.js" defer></script>'
@@ -404,6 +458,8 @@ def render_app_page(
if view == "miners"
else '<script src="/assets/minerops-repair-center.js" defer></script>'
if view == "repairs"
else '<script src="/assets/minerops-dashboard.js" defer></script>'
if view == "dashboard"
else ""
)
return f"""<!doctype html>
+18
View File
@@ -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
+64
View File
@@ -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,
*,
+20
View File
@@ -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,
+10
View File
@@ -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
+28
View File
@@ -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())
+8
View File
@@ -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
)
)
+12 -1
View File
@@ -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":
+28
View File
@@ -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()
+6
View File
@@ -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()
)
+5
View File
@@ -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
+347
View File
@@ -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()
+84 -5
View File
@@ -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("<title>Dashboard | MinerOps</title>", 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",
):
+36 -5
View File
@@ -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__":