feat: add operational Dashboard
This commit is contained in:
1 parent
4a1959ecd6
commit
b8b741ccee
23 files changed
+1278
-59
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
"""Permission-aware operational Dashboard read model."""
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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)
|
@router.get("/assets/minerops-mine-map.js", include_in_schema=False)
|
||||||
def mine_map_script() -> Response:
|
def mine_map_script() -> Response:
|
||||||
return Response(
|
return Response(
|
||||||
|
|||||||
@@ -1489,3 +1489,132 @@ button.map-position:hover {
|
|||||||
.repair-search, .repair-filter-actions { grid-column: auto; }
|
.repair-search, .repair-filter-actions { grid-column: auto; }
|
||||||
.repair-table td { grid-template-columns: 90px minmax(0, 1fr); }
|
.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; }
|
||||||
|
}
|
||||||
@@ -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
@@ -13,7 +13,7 @@ NAVIGATION = (
|
|||||||
|
|
||||||
PAGE_DETAILS = {
|
PAGE_DETAILS = {
|
||||||
"dashboard": (
|
"dashboard": (
|
||||||
"Fleet overview",
|
"Operational overview",
|
||||||
"Dashboard",
|
"Dashboard",
|
||||||
"A concise starting point for operational state and recent activity.",
|
"A concise starting point for operational state and recent activity.",
|
||||||
),
|
),
|
||||||
@@ -35,46 +35,104 @@ PAGE_DETAILS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _dashboard_content() -> str:
|
def _dashboard_content(
|
||||||
metrics = (
|
can_view_miners: bool,
|
||||||
("Site health", "--", "Live health summary not yet connected"),
|
can_view_repairs: bool,
|
||||||
("Total miners", "--", "Inventory summary not yet connected"),
|
can_view_locations: bool,
|
||||||
("Miners in repair", "--", "Repair summary not yet connected"),
|
) -> str:
|
||||||
("Current hashrate", "-- TH/s", "Telemetry not yet connected"),
|
fleet = """
|
||||||
("Current power", "-- kW", "Telemetry not yet connected"),
|
<section class="dashboard-section dashboard-fleet" aria-labelledby="fleetOverviewTitle">
|
||||||
)
|
<div class="dashboard-section-head"><div><p class="eyebrow">Assets</p>
|
||||||
metric_html = "".join(
|
<h2 id="fleetOverviewTitle">Fleet overview</h2></div>
|
||||||
"<article class='metric'>"
|
<a class="object-link" href="/assets/miners">Open Fleet</a></div>
|
||||||
f"<span class='metric-label'>{escape(label)}</span>"
|
<div class="dashboard-total"><span>Total miners</span>
|
||||||
f"<strong class='metric-value'>{escape(value)}</strong>"
|
<strong id="dashboardFleetTotal">-</strong></div>
|
||||||
f"<span class='metric-state'>{escape(state)}</span>"
|
<div class="dashboard-dimension-grid">
|
||||||
"</article>"
|
<section aria-labelledby="runtimeTitle"><h3 id="runtimeTitle">Runtime</h3>
|
||||||
for label, value, state in metrics
|
<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"""
|
return f"""
|
||||||
<section class="metrics" aria-label="Fleet summary placeholders">
|
<div class="state-message" id="dashboardLoading" role="status">
|
||||||
{metric_html}
|
<strong>Loading operational overview</strong>
|
||||||
</section>
|
<span>Requesting current fleet, repair, and location summaries.</span>
|
||||||
<div class="content-grid">
|
</div>
|
||||||
<section class="panel" aria-labelledby="activityTitle">
|
<div class="state-message" id="dashboardError" data-kind="error" role="alert" hidden>
|
||||||
<div class="panel-head">
|
<strong>Dashboard unavailable</strong>
|
||||||
<div>
|
<span id="dashboardErrorDetail">The operational overview could not be loaded.</span>
|
||||||
<h2 id="activityTitle">Recent activity</h2>
|
<button class="command-button" id="dashboardRetry" type="button">Retry</button>
|
||||||
<p class="panel-copy">Operational events will appear here when
|
</div>
|
||||||
the dashboard activity feed is connected.</p>
|
<div class="state-message" id="dashboardEmpty" hidden>
|
||||||
</div>
|
<strong>No operational sections available</strong>
|
||||||
</div>
|
<span>Your account can open Dashboard but has no additional overview permissions.</span>
|
||||||
<div class="state-message">
|
</div>
|
||||||
<strong>No activity feed connected</strong>
|
<div id="dashboardContent" hidden>
|
||||||
<span>No events are fabricated for this placeholder.</span>
|
<div class="dashboard-status-band">
|
||||||
</div>
|
<p id="dashboardLoadStatus" class="freshness" aria-live="polite">Waiting for current summaries</p>
|
||||||
</section>
|
{partial}
|
||||||
<aside class="panel" aria-labelledby="attentionTitle">
|
</div>
|
||||||
<h2 id="attentionTitle">Data boundary</h2>
|
<div class="dashboard-grid">{fleet}{repairs}{locations}</div>
|
||||||
<p class="panel-copy">Summary cards intentionally show unavailable
|
|
||||||
values until live APIs are connected and freshness can be represented
|
|
||||||
accurately.</p>
|
|
||||||
</aside>
|
|
||||||
</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(
|
def render_app_page(
|
||||||
view: str,
|
view: str,
|
||||||
current_user: dict,
|
current_user: dict,
|
||||||
@@ -395,7 +445,11 @@ def render_app_page(
|
|||||||
has_permission("locations.view"), has_permission("miners.view")
|
has_permission("locations.view"), has_permission("miners.view")
|
||||||
)
|
)
|
||||||
if view == "repairs"
|
if view == "repairs"
|
||||||
else CONTENT[view]()
|
else _dashboard_content(
|
||||||
|
has_permission("miners.view"),
|
||||||
|
has_permission("repairs.view"),
|
||||||
|
has_permission("locations.view"),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
page_script = (
|
page_script = (
|
||||||
'<script src="/assets/minerops-mine-map.js" defer></script>'
|
'<script src="/assets/minerops-mine-map.js" defer></script>'
|
||||||
@@ -404,6 +458,8 @@ def render_app_page(
|
|||||||
if view == "miners"
|
if view == "miners"
|
||||||
else '<script src="/assets/minerops-repair-center.js" defer></script>'
|
else '<script src="/assets/minerops-repair-center.js" defer></script>'
|
||||||
if view == "repairs"
|
if view == "repairs"
|
||||||
|
else '<script src="/assets/minerops-dashboard.js" defer></script>'
|
||||||
|
if view == "dashboard"
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
return f"""<!doctype html>
|
return f"""<!doctype html>
|
||||||
|
|||||||
@@ -171,3 +171,21 @@ class InventoryAssetIDHistoryResponse(BaseModel):
|
|||||||
data: list[InventoryAssetIDHistoryEntry]
|
data: list[InventoryAssetIDHistoryEntry]
|
||||||
meta: dict[str, object] = Field(default_factory=dict)
|
meta: dict[str, object] = Field(default_factory=dict)
|
||||||
error: None = None
|
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
|
||||||
@@ -292,6 +292,70 @@ class InventoryRepository:
|
|||||||
include_location_details=include_location_details,
|
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(
|
def search_miners(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ from app.inventory.exceptions import (
|
|||||||
from app.inventory.models import (
|
from app.inventory.models import (
|
||||||
InventoryAssetIDHistoryEntry,
|
InventoryAssetIDHistoryEntry,
|
||||||
InventoryCreateRequest,
|
InventoryCreateRequest,
|
||||||
|
InventoryFleetAttentionSummary,
|
||||||
|
InventoryFleetSummary,
|
||||||
InventoryMetadataUpdateRequest,
|
InventoryMetadataUpdateRequest,
|
||||||
InventoryMinerDetail,
|
InventoryMinerDetail,
|
||||||
InventoryMinerSummary,
|
InventoryMinerSummary,
|
||||||
@@ -157,6 +159,24 @@ class InventoryService:
|
|||||||
for row in self.repository.list_miners(limit=limit, offset=offset)
|
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(
|
def search_miners(
|
||||||
self,
|
self,
|
||||||
filters: InventorySearchFilters,
|
filters: InventorySearchFilters,
|
||||||
|
|||||||
@@ -116,3 +116,13 @@ class MineMapResponse(BaseModel):
|
|||||||
data: MineMapData
|
data: MineMapData
|
||||||
meta: MineMapMeta
|
meta: MineMapMeta
|
||||||
error: None = None
|
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
|
||||||
@@ -201,3 +201,31 @@ class LocationMapRepository:
|
|||||||
"positions": positions,
|
"positions": positions,
|
||||||
"virtual_miners": virtual_miners,
|
"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())
|
||||||
@@ -5,6 +5,7 @@ from typing import Any
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from app.locations.models import (
|
from app.locations.models import (
|
||||||
|
LocationSummary,
|
||||||
MapActiveRepair,
|
MapActiveRepair,
|
||||||
MapPosition,
|
MapPosition,
|
||||||
MapRoom,
|
MapRoom,
|
||||||
@@ -85,3 +86,10 @@ class LocationMapService:
|
|||||||
miner_details_included=include_miner_details,
|
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
@@ -11,6 +11,7 @@ from fastapi import Request, Depends, FastAPI, Header, HTTPException, Query, sta
|
|||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.dashboard.router import router as dashboard_router
|
||||||
from app.identity import (
|
from app.identity import (
|
||||||
COOKIE_TOKEN_HINT,
|
COOKIE_TOKEN_HINT,
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
@@ -20,7 +21,10 @@ from app.identity import (
|
|||||||
user_has_permission,
|
user_has_permission,
|
||||||
router as identity_router,
|
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.inventory.router import router as inventory_router
|
||||||
from app.locations.router import router as locations_router
|
from app.locations.router import router as locations_router
|
||||||
from app.components.router import router as components_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(inventory_router)
|
||||||
app.include_router(components_router)
|
app.include_router(components_router)
|
||||||
app.include_router(locations_router)
|
app.include_router(locations_router)
|
||||||
|
app.include_router(dashboard_router)
|
||||||
install_identity_middleware(app)
|
install_identity_middleware(app)
|
||||||
|
|
||||||
|
|
||||||
@@ -33444,6 +33449,9 @@ async def minerops_inject_repair_polish_assets(request, call_next):
|
|||||||
try:
|
try:
|
||||||
path = request.url.path or ""
|
path = request.url.path or ""
|
||||||
|
|
||||||
|
if request.scope.get("endpoint") is internal_alpha_dashboard_page:
|
||||||
|
return response
|
||||||
|
|
||||||
if path not in ("/dashboard", "/locations", "/repairs"):
|
if path not in ("/dashboard", "/locations", "/repairs"):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -33799,6 +33807,9 @@ async def minerops_inject_dashboard_repair_summary(request, call_next):
|
|||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if request.scope.get("endpoint") is internal_alpha_dashboard_page:
|
||||||
|
return response
|
||||||
|
|
||||||
path = request.url.path or ""
|
path = request.url.path or ""
|
||||||
|
|
||||||
if path != "/dashboard":
|
if path != "/dashboard":
|
||||||
|
|||||||
@@ -314,3 +314,31 @@ class RepairCenterRepository:
|
|||||||
finally:
|
finally:
|
||||||
connection.close()
|
connection.close()
|
||||||
return {"items": items, "summary": summary, "location_options": options}
|
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()
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from app.repairs.center_repository import RepairCenterRepository
|
from app.repairs.center_repository import RepairCenterRepository
|
||||||
from app.repairs.models import (
|
from app.repairs.models import (
|
||||||
|
ActiveRepairSummary,
|
||||||
RepairCenterAccess,
|
RepairCenterAccess,
|
||||||
RepairCenterCapabilities,
|
RepairCenterCapabilities,
|
||||||
RepairCenterFilters,
|
RepairCenterFilters,
|
||||||
@@ -94,3 +95,8 @@ class RepairCenterService:
|
|||||||
location_options=location_options,
|
location_options=location_options,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def summarize_active_repairs(self) -> ActiveRepairSummary:
|
||||||
|
return ActiveRepairSummary.model_validate(
|
||||||
|
self.repository.summarize_active_repairs()
|
||||||
|
)
|
||||||
@@ -389,6 +389,11 @@ class RepairCenterSummary(BaseModel):
|
|||||||
error_counts: dict[str, int] = Field(default_factory=dict)
|
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):
|
class RepairCenterLocationOption(BaseModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
name: str
|
name: str
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -132,13 +132,41 @@ class FrontendShellTests(unittest.TestCase):
|
|||||||
def test_dashboard_does_not_fabricate_operational_values(self):
|
def test_dashboard_does_not_fabricate_operational_values(self):
|
||||||
self.authenticate()
|
self.authenticate()
|
||||||
response = self.client.get("/dashboard")
|
response = self.client.get("/dashboard")
|
||||||
self.assertIn(
|
for value in (
|
||||||
"Live health summary not yet connected",
|
'id="dashboardFleetTotal"',
|
||||||
response.text,
|
'id="dashboardActiveRepairs"',
|
||||||
)
|
'id="dashboardSites"',
|
||||||
self.assertIn("No events are fabricated", response.text)
|
"/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)
|
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):
|
def test_map_is_a_structured_operational_view(self):
|
||||||
self.authenticate()
|
self.authenticate()
|
||||||
response = self.client.get("/monitoring/map")
|
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):
|
def test_live_app_prefers_new_shell_and_preserves_legacy_pages(self):
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
from app.frontend.router import dashboard_page
|
||||||
|
|
||||||
first_handlers = {}
|
first_handlers = {}
|
||||||
for route in app.routes:
|
for route in app.routes:
|
||||||
@@ -250,6 +279,55 @@ class FrontendShellTests(unittest.TestCase):
|
|||||||
with self.subTest(legacy_path=path):
|
with self.subTest(legacy_path=path):
|
||||||
self.assertIn(path, first_handlers)
|
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):
|
def test_safe_next_path_accepts_only_protected_local_pages(self):
|
||||||
valid = (
|
valid = (
|
||||||
@@ -365,6 +443,7 @@ class FrontendShellTests(unittest.TestCase):
|
|||||||
for path in (
|
for path in (
|
||||||
"/assets/minerops-app.css",
|
"/assets/minerops-app.css",
|
||||||
"/assets/minerops-app.js",
|
"/assets/minerops-app.js",
|
||||||
|
"/assets/minerops-dashboard.js",
|
||||||
"/assets/minerops-mine-map.js",
|
"/assets/minerops-mine-map.js",
|
||||||
"/assets/minerops-fleet.js",
|
"/assets/minerops-fleet.js",
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -358,11 +358,42 @@ class MineMapFrontendAuditTests(unittest.TestCase):
|
|||||||
def test_map_repository_uses_constant_set_based_queries(self):
|
def test_map_repository_uses_constant_set_based_queries(self):
|
||||||
source = (Path(__file__).parents[1] / "app" / "locations" /
|
source = (Path(__file__).parents[1] / "app" / "locations" /
|
||||||
"repository.py").read_text(encoding="utf-8")
|
"repository.py").read_text(encoding="utf-8")
|
||||||
self.assertEqual(source.count("cur.execute("), 4)
|
|
||||||
self.assertEqual(source.count("LEFT JOIN LATERAL"), 2)
|
read_map_source = source.split(" def read_map(", 1)[1].split(
|
||||||
self.assertIn("ORDER BY snapshots.observed_at DESC, snapshots.id DESC", source)
|
" def summarize_locations(", 1
|
||||||
self.assertIn("ORDER BY repairs.opened_at, repairs.id", source)
|
)[0]
|
||||||
self.assertNotIn("for miner", source)
|
|
||||||
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Executable
+64
@@ -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."
|
||||||
Reference in new issue
Block a user