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