feat: add miner detail view

This commit is contained in:
lussierj committed 2026-08-17 18:43:20 +00:00
1 parent b8b741ccee
commit 19fc29c72c
22 files changed
+1882 -10

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Permission-aware Miner Detail read model."""
+14
View File
@@ -0,0 +1,14 @@
"""Dependency construction for the Miner Detail composition service."""
from app.inventory.dependencies import get_inventory_service
from app.locations.dependencies import get_location_map_service
from app.miner_detail.service import MinerDetailService
from app.repairs.dependencies import get_repair_center_service
def get_miner_detail_service() -> MinerDetailService:
return MinerDetailService(
inventory_service=get_inventory_service(),
location_service=get_location_map_service(),
repair_service=get_repair_center_service(),
)
+76
View File
@@ -0,0 +1,76 @@
"""Typed contracts for the Internal Alpha Miner Detail overview."""
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, Field
from app.inventory.models import InventoryMinerDetail
from app.locations.models import MinerLocationDetail
OperationalState = Literal[
"online", "offline", "unknown", "unstable", "repair", "spare", "retired"
]
class MinerDetailAccess(BaseModel):
can_view_locations: bool = False
can_read_repairs: bool = False
can_view_repairs: bool = False
can_read_components: bool = False
class MinerDetailLocationSection(BaseModel):
available: bool
restricted: bool
location: MinerLocationDetail | None = None
class MinerDetailRepairEpisode(BaseModel):
repair_id: UUID | None = None
source: Literal["modular", "legacy"]
active: bool
status: str
reason: str | None = None
opened_at: datetime | None = None
closed_at: datetime | None = None
latest_context: str | None = None
latest_context_at: datetime | None = None
parts_count: int | None = None
parts_summary: list[str] = Field(default_factory=list)
class MinerDetailRepairSection(BaseModel):
available: bool
restricted: bool
active_repair: MinerDetailRepairEpisode | None = None
episodes: list[MinerDetailRepairEpisode] = Field(default_factory=list)
class MinerDetailCapabilities(BaseModel):
can_view_map: bool = False
can_open_repair_center: bool = False
can_read_repair_history: bool = False
can_read_components: bool = False
class MinerDetailOverviewData(BaseModel):
miner: InventoryMinerDetail
operational_state: OperationalState
location: MinerDetailLocationSection
repair: MinerDetailRepairSection
capabilities: MinerDetailCapabilities
class MinerDetailOverviewMeta(BaseModel):
generated_at: datetime
database_query_count: int = Field(ge=1, le=3)
class MinerDetailOverviewResponse(BaseModel):
data: MinerDetailOverviewData
meta: MinerDetailOverviewMeta
error: None = None
+59
View File
@@ -0,0 +1,59 @@
"""HTTP boundary for the Internal Alpha Miner Detail overview."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Response, status
from app.identity import require_permission, user_has_permission
from app.inventory.exceptions import MinerNotFound
from app.miner_detail.dependencies import get_miner_detail_service
from app.miner_detail.models import (
MinerDetailAccess,
MinerDetailOverviewResponse,
)
from app.miner_detail.service import MinerDetailService
router = APIRouter(prefix="/api/miners", tags=["miners"])
MinerDetailUser = Annotated[dict, Depends(require_permission("miners.view"))]
MinerDetailServiceDependency = Annotated[
MinerDetailService,
Depends(get_miner_detail_service),
]
@router.get(
"/{miner_id}/overview",
response_model=MinerDetailOverviewResponse,
)
def get_miner_detail_overview(
miner_id: UUID,
current_user: MinerDetailUser,
service: MinerDetailServiceDependency,
response: Response,
) -> MinerDetailOverviewResponse:
response.headers["Cache-Control"] = "no-store"
try:
return service.get_overview(
miner_id,
MinerDetailAccess(
can_view_locations=user_has_permission(
current_user, "locations.view"
),
can_read_repairs=user_has_permission(
current_user, "repairs.read"
),
can_view_repairs=user_has_permission(
current_user, "repairs.view"
),
can_read_components=user_has_permission(
current_user, "components.read"
),
),
)
except MinerNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Miner not found",
) from exc
+152
View File
@@ -0,0 +1,152 @@
"""Composition service for the Internal Alpha Miner Detail overview."""
from datetime import datetime, timezone
from math import isfinite
from uuid import UUID
from app.inventory.models import InventoryMinerDetail
from app.inventory.service import InventoryService
from app.locations.service import LocationMapService
from app.miner_detail.models import (
MinerDetailAccess,
MinerDetailCapabilities,
MinerDetailLocationSection,
MinerDetailOverviewData,
MinerDetailOverviewMeta,
MinerDetailOverviewResponse,
MinerDetailRepairEpisode,
MinerDetailRepairSection,
OperationalState,
)
from app.repairs.center_service import RepairCenterService
from app.repairs.models import RepairCenterAccess, RepairCenterItem
class MinerDetailService:
"""Coordinate domain-owned current projections without transport logic."""
def __init__(
self,
inventory_service: InventoryService,
location_service: LocationMapService,
repair_service: RepairCenterService,
):
self.inventory_service = inventory_service
self.location_service = location_service
self.repair_service = repair_service
@staticmethod
def _episode(item: RepairCenterItem) -> MinerDetailRepairEpisode:
return MinerDetailRepairEpisode(
repair_id=item.repair_id,
source=item.source,
active=item.active,
status=item.repair_status,
reason=item.reason,
opened_at=item.opened_at,
closed_at=item.closed_at,
latest_context=item.latest_context,
latest_context_at=item.latest_context_at,
parts_count=item.parts_count,
parts_summary=item.parts_summary,
)
@staticmethod
def _operational_state(
miner: InventoryMinerDetail,
active_repair: MinerDetailRepairEpisode | None,
) -> OperationalState:
if active_repair is not None or miner.lifecycle_status == "in_repair":
return "repair"
if miner.lifecycle_status == "retired":
return "retired"
if miner.lifecycle_status == "spare":
return "spare"
runtime = (miner.runtime_status or "").strip().lower()
if runtime == "offline":
return "offline"
if miner.hash_stable is False:
return "unstable"
if runtime == "online":
return "online"
return "unknown"
def get_overview(
self,
miner_id: UUID,
access: MinerDetailAccess,
) -> MinerDetailOverviewResponse:
miner = self.inventory_service.get_current_miner(miner_id)
# Location is owned by the permission-aware section below. Keep the
# shared projection from leaking location identifiers. Non-finite
# telemetry is not a factual display value and remains unavailable.
redacted = {
field: None
for field in (
"site_id", "site_name", "room_id", "room_name",
"location_position_id", "rack_label", "shelf_label",
"position_label", "position_index",
)
}
for field in ("hashrate_ths", "power_w", "chip_temp_max_c"):
value = getattr(miner, field)
if value is not None and not isfinite(value):
redacted[field] = None
miner = miner.model_copy(
update=redacted
)
location = (
self.location_service.get_miner_location(miner_id)
if access.can_view_locations
else None
)
repair_items = (
self.repair_service.list_miner_repairs(
miner_id,
RepairCenterAccess(
can_read_repairs=True,
can_view_miners=True,
),
)
if access.can_read_repairs
else []
)
episodes = [self._episode(item) for item in repair_items]
active_repair = next(
(
episode for episode in episodes
if episode.active and episode.source == "modular"
),
None,
)
query_count = 1 + int(access.can_view_locations) + int(access.can_read_repairs)
return MinerDetailOverviewResponse(
data=MinerDetailOverviewData(
miner=miner,
operational_state=self._operational_state(miner, active_repair),
location=MinerDetailLocationSection(
available=access.can_view_locations,
restricted=not access.can_view_locations,
location=location,
),
repair=MinerDetailRepairSection(
available=access.can_read_repairs,
restricted=not access.can_read_repairs,
active_repair=active_repair,
episodes=episodes,
),
capabilities=MinerDetailCapabilities(
can_view_map=bool(
access.can_view_locations and location is not None
and location.site_id and location.room_id
),
can_open_repair_center=access.can_view_repairs,
can_read_repair_history=access.can_read_repairs,
can_read_components=access.can_read_components,
),
),
meta=MinerDetailOverviewMeta(
generated_at=datetime.now(timezone.utc),
database_query_count=query_count,
),
)