feat: add Mine Map v1

This commit is contained in:
lussierj committed 2026-08-10 19:55:20 +00:00
1 parent 548051899e
commit 60a831950c
16 files changed
+2027 -163

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
"""Mine Map location read domain."""
from app.locations.models import MineMapResponse
from app.locations.repository import LocationMapRepository
from app.locations.service import LocationMapService
__all__ = ["LocationMapRepository", "LocationMapService", "MineMapResponse"]
+8
View File
@@ -0,0 +1,8 @@
"""Dependency construction for the location read domain."""
from app.locations.repository import LocationMapRepository
from app.locations.service import LocationMapService
def get_location_map_service() -> LocationMapService:
return LocationMapService(LocationMapRepository())
+118
View File
@@ -0,0 +1,118 @@
"""Typed contracts for the read-only Mine Map."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel
class MapSite(BaseModel):
id: UUID
name: str
class MapRoom(BaseModel):
id: UUID
site_id: UUID
site_name: str
name: str
sort_order: int
class MapActiveRepair(BaseModel):
id: UUID
status: str
reason: str
class MapPosition(BaseModel):
location_position_id: UUID
site_id: UUID | None = None
site_name: str | None = None
room_id: UUID | None = None
room_name: str
rack_name: str
shelf_name: str
shelf_index: int | None = None
position_index: int
position_label: str | None = None
position_kind: str
position_status: str
occupied: bool
expected_mac: str | None = None
equipment_label: str | None = None
notes: str | None = None
source_file: str | None = None
source_cell: str | None = None
conflict_reason: str | None = None
miner_id: UUID | None = None
asset_id: str | None = None
current_mac: str | None = None
current_ip: str | None = None
miner_status: str | None = None
hostname: str | None = None
miner_sn: str | None = None
power_sn: str | None = None
model: str | None = None
miner_type: str | None = None
firmware_version: str | None = None
lifecycle_status: str | None = None
last_seen_at: datetime | None = None
asset_state: str | None = None
observed_at: datetime | None = None
hashrate_ths: float | None = None
power_w: float | None = None
chip_temp_max_c: float | None = None
chip_temp_avg_c: float | None = None
hash_stable: bool | None = None
pool_rejected_percent: float | None = None
active_repair: MapActiveRepair | None = None
class MapVirtualMiner(BaseModel):
miner_id: UUID | None = None
site_id: UUID | None = None
room_id: UUID | None = None
asset_id: str | None = None
asset_state: str
current_mac: str | None = None
current_ip: str | None = None
miner_status: str | None = None
status: str | None = None
hostname: str | None = None
miner_sn: str | None = None
power_sn: str | None = None
model: str | None = None
miner_type: str | None = None
firmware_version: str | None = None
lifecycle_status: str | None = None
last_seen_at: datetime | None = None
observed_at: datetime | None = None
hashrate_ths: float | None = None
power_w: float | None = None
chip_temp_max_c: float | None = None
chip_temp_avg_c: float | None = None
hash_stable: bool | None = None
active_repair: MapActiveRepair | None = None
class MineMapData(BaseModel):
sites: list[MapSite]
rooms: list[MapRoom]
positions: list[MapPosition]
virtual_miners: list[MapVirtualMiner]
class MineMapMeta(BaseModel):
generated_at: datetime
site_id: UUID | None = None
room_id: UUID | None = None
position_count: int
virtual_miner_count: int
miner_details_included: bool
class MineMapResponse(BaseModel):
data: MineMapData
meta: MineMapMeta
error: None = None
+203
View File
@@ -0,0 +1,203 @@
"""Set-based PostgreSQL reads for Mine Map v1."""
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from typing import Any
from uuid import UUID
import psycopg
from psycopg.rows import dict_row
from app.config import settings
ConnectionFactory = Callable[[], psycopg.Connection]
ACTIVE_REPAIR_STATUSES = (
"OPEN",
"IN_PROGRESS",
"WAITING_FOR_PARTS",
"READY_FOR_TEST",
)
def default_connection_factory() -> psycopg.Connection:
return psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
connect_timeout=5,
)
LATEST_TELEMETRY_SQL = """
LEFT JOIN LATERAL (
SELECT snapshots.observed_at,
ROUND((snapshots.mhs_av / 1000000.0)::numeric, 2)::float
AS hashrate_ths,
ROUND(snapshots.power_w::numeric, 2)::float AS power_w,
ROUND(snapshots.chip_temp_max_c::numeric, 2)::float
AS chip_temp_max_c,
ROUND(snapshots.chip_temp_avg_c::numeric, 2)::float
AS chip_temp_avg_c,
snapshots.hash_stable, snapshots.pool_rejected_percent
FROM miner_status_snapshots snapshots
WHERE snapshots.miner_id = miners.id
ORDER BY snapshots.observed_at DESC, snapshots.id DESC
LIMIT 1
) latest ON true
"""
ACTIVE_REPAIR_SQL = """
LEFT JOIN LATERAL (
SELECT repairs.id, repairs.status, repairs.reason
FROM repairs
WHERE repairs.miner_id = miners.id AND repairs.status = ANY(%s)
ORDER BY repairs.opened_at, repairs.id
LIMIT 1
) active_repair ON true
"""
class LocationMapRepository:
"""Own all persistence used by the Mine Map read model."""
def __init__(
self,
connection_factory: ConnectionFactory | None = None,
*,
connection: psycopg.Connection | None = None,
):
self._connection_factory = connection_factory or default_connection_factory
self._connection = connection
@contextmanager
def _connection_scope(self) -> Iterator[psycopg.Connection]:
if self._connection is not None:
yield self._connection
return
connection = self._connection_factory()
try:
yield connection
finally:
connection.close()
def read_map(
self,
*,
site_id: UUID | None = None,
room_id: UUID | None = None,
) -> dict[str, list[dict[str, Any]]]:
position_where = []
position_params: list[Any] = [list(ACTIVE_REPAIR_STATUSES)]
virtual_where = [
"COALESCE(mls.asset_state, 'waiting_placement') = ANY(%s)",
"NOT EXISTS (SELECT 1 FROM location_positions assigned "
"WHERE assigned.miner_id = miners.id)",
]
virtual_params: list[Any] = [
list(ACTIVE_REPAIR_STATUSES),
["waiting_placement", "storage", "repair", "decommissioned"],
]
if site_id is not None:
position_where.append("rooms.site_id = %s")
position_params.append(site_id)
virtual_where.append("miners.site_id = %s")
virtual_params.append(site_id)
if room_id is not None:
position_where.append("lp.room_id = %s")
position_params.append(room_id)
virtual_where.append("miners.room_id = %s")
virtual_params.append(room_id)
position_filter = (
"WHERE " + " AND ".join(position_where) if position_where else ""
)
virtual_filter = "WHERE " + " AND ".join(virtual_where)
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute("SELECT id, name FROM sites ORDER BY lower(name), id")
sites = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT rooms.id, rooms.site_id, sites.name AS site_name,
rooms.name, rooms.sort_order
FROM rooms JOIN sites ON sites.id = rooms.site_id
ORDER BY lower(sites.name), rooms.sort_order,
lower(rooms.name), rooms.id
"""
)
rooms = [dict(row) for row in cur.fetchall()]
cur.execute(
f"""
SELECT lp.id AS location_position_id, sites.id AS site_id,
sites.name AS site_name, rooms.id AS room_id,
COALESCE(rooms.name, lp.room_name) AS room_name,
lp.rack_name, lp.shelf_name, lp.shelf_index,
lp.position_index, lp.position_label, lp.position_kind,
lp.position_status, (miners.id IS NOT NULL) AS occupied,
lp.expected_mac, lp.equipment_label, lp.notes,
lp.source_file, lp.source_cell, lp.conflict_reason,
miners.id AS miner_id, miners.asset_tag AS asset_id,
miners.current_mac, host(miners.current_ip) AS current_ip,
miners.status AS miner_status, miners.hostname,
miners.miner_sn, miners.power_sn, miners.model,
miners.miner_type, miners.firmware_version,
miners.lifecycle_status, miners.last_seen_at,
COALESCE(mls.asset_state, 'unassigned') AS asset_state,
latest.observed_at, latest.hashrate_ths, latest.power_w,
latest.chip_temp_max_c, latest.chip_temp_avg_c,
latest.hash_stable, latest.pool_rejected_percent,
active_repair.id AS active_repair_id,
active_repair.status AS active_repair_status,
active_repair.reason AS active_repair_reason
FROM location_positions lp
LEFT JOIN rooms ON rooms.id = lp.room_id
LEFT JOIN sites ON sites.id = rooms.site_id
LEFT JOIN miners ON miners.id = lp.miner_id
LEFT JOIN miner_location_states mls ON mls.miner_id = miners.id
{LATEST_TELEMETRY_SQL}
{ACTIVE_REPAIR_SQL}
{position_filter}
ORDER BY lower(sites.name) NULLS LAST,
rooms.sort_order NULLS LAST,
lower(COALESCE(rooms.name, lp.room_name)),
lower(lp.rack_name), lp.shelf_index NULLS LAST,
lower(lp.shelf_name), lp.position_index, lp.id
""",
position_params,
)
positions = [dict(row) for row in cur.fetchall()]
cur.execute(
f"""
SELECT miners.id AS miner_id, miners.site_id, miners.room_id,
miners.asset_tag AS asset_id,
COALESCE(mls.asset_state, 'waiting_placement') AS asset_state,
miners.current_mac, host(miners.current_ip) AS current_ip,
miners.status AS miner_status, miners.status, miners.hostname,
miners.miner_sn, miners.power_sn, miners.model,
miners.miner_type, miners.firmware_version,
miners.lifecycle_status, miners.last_seen_at,
latest.observed_at, latest.hashrate_ths, latest.power_w,
latest.chip_temp_max_c, latest.chip_temp_avg_c,
latest.hash_stable,
active_repair.id AS active_repair_id,
active_repair.status AS active_repair_status,
active_repair.reason AS active_repair_reason
FROM miners
LEFT JOIN miner_location_states mls ON mls.miner_id = miners.id
{LATEST_TELEMETRY_SQL}
{ACTIVE_REPAIR_SQL}
{virtual_filter}
ORDER BY asset_state, lower(miners.asset_tag), miners.id
""",
virtual_params,
)
virtual_miners = [dict(row) for row in cur.fetchall()]
return {
"sites": sites,
"rooms": rooms,
"positions": positions,
"virtual_miners": virtual_miners,
}
+30
View File
@@ -0,0 +1,30 @@
"""HTTP boundary for the read-only Mine Map API."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from app.identity import require_permission, user_has_permission
from app.locations.dependencies import get_location_map_service
from app.locations.models import MineMapResponse
from app.locations.service import LocationMapService
router = APIRouter(prefix="/api/location", tags=["locations"])
MapUser = Annotated[dict, Depends(require_permission("locations.view"))]
MapService = Annotated[LocationMapService, Depends(get_location_map_service)]
@router.get("/map", response_model=MineMapResponse)
def get_mine_map(
current_user: MapUser,
service: MapService,
site_id: Annotated[UUID | None, Query()] = None,
room_id: Annotated[UUID | None, Query()] = None,
) -> MineMapResponse:
return service.get_map(
site_id=site_id,
room_id=room_id,
include_miner_details=user_has_permission(current_user, "miners.view"),
)
+87
View File
@@ -0,0 +1,87 @@
"""Mine Map read-model composition independent of HTTP."""
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from app.locations.models import (
MapActiveRepair,
MapPosition,
MapRoom,
MapSite,
MapVirtualMiner,
MineMapData,
MineMapMeta,
MineMapResponse,
)
from app.locations.repository import LocationMapRepository
MINER_FIELDS = {
"miner_id", "asset_id", "current_mac", "expected_mac", "current_ip", "miner_status",
"status",
"hostname", "miner_sn", "power_sn", "model", "miner_type",
"firmware_version", "lifecycle_status", "last_seen_at", "asset_state",
"observed_at", "hashrate_ths", "power_w", "chip_temp_max_c",
"chip_temp_avg_c", "hash_stable", "pool_rejected_percent",
}
class LocationMapService:
def __init__(self, repository: LocationMapRepository):
self.repository = repository
@staticmethod
def _repair(row: dict[str, Any]) -> MapActiveRepair | None:
repair_id = row.pop("active_repair_id", None)
status = row.pop("active_repair_status", None)
reason = row.pop("active_repair_reason", None)
if repair_id is None:
return None
return MapActiveRepair(id=repair_id, status=status, reason=reason)
@classmethod
def _redact(cls, row: dict[str, Any]) -> dict[str, Any]:
redacted = dict(row)
for field in MINER_FIELDS:
redacted[field] = None
redacted["active_repair_id"] = None
redacted["active_repair_status"] = None
redacted["active_repair_reason"] = None
return redacted
def get_map(
self,
*,
site_id: UUID | None = None,
room_id: UUID | None = None,
include_miner_details: bool,
) -> MineMapResponse:
rows = self.repository.read_map(site_id=site_id, room_id=room_id)
positions = []
for source in rows["positions"]:
row = dict(source) if include_miner_details else self._redact(source)
row["active_repair"] = self._repair(row)
positions.append(MapPosition.model_validate(row))
virtual_miners = []
if include_miner_details:
for source in rows["virtual_miners"]:
row = dict(source)
row["active_repair"] = self._repair(row)
virtual_miners.append(MapVirtualMiner.model_validate(row))
return MineMapResponse(
data=MineMapData(
sites=[MapSite.model_validate(row) for row in rows["sites"]],
rooms=[MapRoom.model_validate(row) for row in rows["rooms"]],
positions=positions,
virtual_miners=virtual_miners,
),
meta=MineMapMeta(
generated_at=datetime.now(timezone.utc),
site_id=site_id,
room_id=room_id,
position_count=len(positions),
virtual_miner_count=len(virtual_miners),
miner_details_included=include_miner_details,
),
)