feat: add Mine Map v1
This commit is contained in:
1 parent
548051899e
commit
60a831950c
16 files changed
+2027
-163
No files matched your search
@@ -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"]
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
Reference in new issue
Block a user