feat: add Fleet operations view

This commit is contained in:
lussierj committed 2026-08-11 17:37:36 +00:00
1 parent c6d01df31c
commit 786a1ab910
12 files changed
+1062 -148

No files matched your search

+46 -1
View File
@@ -17,16 +17,36 @@ class InventoryTag(BaseModel):
updated_at: datetime
class InventoryActiveRepair(BaseModel):
id: UUID
status: str
reason: str
class InventoryMinerSummary(BaseModel):
id: UUID
asset_id: str | None = None
current_mac: str | None = None
current_ip: str | None = None
model: str | None = None
miner_sn: str | None = None
lifecycle_status: LifecycleStatus
runtime_status: str
site_id: UUID | None = None
site_name: str | None = None
room_id: UUID | None = None
room_name: str | None = None
location_position_id: UUID | None = None
rack_label: str | None = None
shelf_label: str | None = None
position_label: str | None = None
position_index: int | None = None
observed_at: datetime | None = None
hashrate_ths: float | None = None
power_w: float | None = None
chip_temp_max_c: float | None = None
hash_stable: bool | None = None
active_repair: InventoryActiveRepair | None = None
tags: list[InventoryTag] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
@@ -35,7 +55,6 @@ class InventoryMinerSummary(BaseModel):
class InventoryMinerDetail(InventoryMinerSummary):
hostname: str | None = None
miner_sn: str | None = None
control_board_sn: str | None = None
psu_serial: str | None = None
firmware_version: str | None = None
@@ -99,15 +118,41 @@ class InventorySearchFilters(BaseModel):
query: str | None = None
asset_id: str | None = None
lifecycle_status: str | None = None
model: str | None = None
runtime_status: str | None = None
repair: Literal["active", "none"] | None = None
operational_state: Literal[
"online", "offline", "unknown", "unstable", "repair", "spare", "retired"
] | None = None
problems: bool = False
site_id: UUID | None = None
room_id: UUID | None = None
tags: list[str] = Field(default_factory=list)
limit: int = Field(default=100, ge=1, le=500)
offset: int = Field(default=0, ge=0)
class InventorySiteOption(BaseModel):
id: UUID
name: str
class InventoryRoomOption(BaseModel):
id: UUID
site_id: UUID
name: str
class InventoryLocationOptions(BaseModel):
sites: list[InventorySiteOption] = Field(default_factory=list)
rooms: list[InventoryRoomOption] = Field(default_factory=list)
class InventoryListMeta(BaseModel):
limit: int
offset: int
returned: int
location_options: InventoryLocationOptions = Field(default_factory=InventoryLocationOptions)
class InventoryMinerListResponse(BaseModel):
+179 -11
View File
@@ -59,6 +59,101 @@ MINER_SELECT = f"""
) tag_data ON true
"""
ACTIVE_REPAIR_STATUSES = (
"OPEN",
"IN_PROGRESS",
"WAITING_FOR_PARTS",
"READY_FOR_TEST",
)
OPERATIONAL_STATE_SQL = """
CASE
WHEN active_repair.id IS NOT NULL OR miners.lifecycle_status = 'in_repair'
THEN 'repair'
WHEN miners.lifecycle_status = 'retired' THEN 'retired'
WHEN miners.lifecycle_status = 'spare' THEN 'spare'
WHEN lower(trim(miners.status)) = 'offline' THEN 'offline'
WHEN latest.hash_stable = false THEN 'unstable'
WHEN lower(trim(miners.status)) = 'online' THEN 'online'
ELSE 'unknown'
END
"""
FLEET_SELECT = f"""
SELECT
miners.id, miners.asset_tag, miners.current_mac,
host(miners.current_ip) AS current_ip, miners.model, miners.miner_sn,
miners.lifecycle_status, miners.status, miners.created_at,
miners.updated_at, miners.last_seen_at, tag_data.tags,
latest.observed_at, latest.hashrate_ths, latest.power_w,
latest.chip_temp_max_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,
{{location_fields}}
FROM miners
LEFT JOIN LATERAL (
SELECT COALESCE(
jsonb_agg(
jsonb_build_object(
'id', tags.id, 'name', tags.name,
'created_at', tags.created_at, 'updated_at', tags.updated_at
) ORDER BY lower({TAG_TRIMMED_NAME_SQL}), tags.id
), '[]'::jsonb
) AS tags
FROM miner_tags JOIN tags ON tags.id = miner_tags.tag_id
WHERE miner_tags.miner_id = miners.id
) tag_data ON true
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,
snapshots.hash_stable
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
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
{{location_join}}
"""
LOCATION_FIELDS = """
mapped.site_id, mapped.site_name, mapped.room_id, mapped.room_name,
mapped.location_position_id, mapped.rack_label, mapped.shelf_label,
mapped.position_label, mapped.position_index
"""
REDACTED_LOCATION_FIELDS = """
NULL::uuid AS site_id, NULL::text AS site_name,
NULL::uuid AS room_id, NULL::text AS room_name,
NULL::uuid AS location_position_id, NULL::text AS rack_label,
NULL::text AS shelf_label, NULL::text AS position_label,
NULL::integer AS position_index
"""
LOCATION_JOIN = """
LEFT JOIN LATERAL (
SELECT 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.id AS location_position_id, lp.rack_name AS rack_label,
lp.shelf_name AS shelf_label, lp.position_label, lp.position_index
FROM location_positions lp
LEFT JOIN rooms ON rooms.id = lp.room_id
LEFT JOIN sites ON sites.id = rooms.site_id
WHERE lp.miner_id = miners.id
ORDER BY lp.id
LIMIT 1
) mapped ON true
"""
MINER_WRITE_COLUMNS = {
"site_id",
"room_id",
@@ -184,8 +279,18 @@ class InventoryRepository:
(asset_id,),
)
def list_miners(self, *, limit: int = 100, offset: int = 0) -> list[dict]:
return self.search_miners(limit=limit, offset=offset)
def list_miners(
self,
*,
limit: int = 100,
offset: int = 0,
include_location_details: bool = False,
) -> list[dict]:
return self.search_miners(
limit=limit,
offset=offset,
include_location_details=include_location_details,
)
def search_miners(
self,
@@ -193,12 +298,20 @@ class InventoryRepository:
query: str | None = None,
asset_id: str | None = None,
lifecycle_status: str | None = None,
model: str | None = None,
runtime_status: str | None = None,
repair: str | None = None,
operational_state: str | None = None,
problems: bool = False,
site_id: UUID | None = None,
room_id: UUID | None = None,
tag_names: Sequence[str] = (),
limit: int = 100,
offset: int = 0,
include_location_details: bool = False,
) -> list[dict]:
conditions: list[str] = []
params: list[Any] = []
params: list[Any] = [list(ACTIVE_REPAIR_STATUSES)]
if query:
pattern = f"%{query}%"
@@ -217,15 +330,36 @@ class InventoryRepository:
"""
)
params.extend([pattern] * 8)
if asset_id:
conditions.append("lower(trim(miners.asset_tag)) = lower(trim(%s))")
params.append(asset_id)
if lifecycle_status:
conditions.append("miners.lifecycle_status = %s")
params.append(lifecycle_status)
if model:
conditions.append("lower(trim(miners.model)) = lower(trim(%s))")
params.append(model)
if runtime_status:
conditions.append("lower(trim(miners.status)) = lower(trim(%s))")
params.append(runtime_status)
if repair == "active":
conditions.append("active_repair.id IS NOT NULL")
elif repair == "none":
conditions.append("active_repair.id IS NULL")
if operational_state:
conditions.append(f"({OPERATIONAL_STATE_SQL}) = %s")
params.append(operational_state)
if problems:
conditions.append(
f"({OPERATIONAL_STATE_SQL}) = ANY(%s)"
)
params.append(["offline", "unknown", "unstable", "repair"])
if site_id:
conditions.append("mapped.site_id = %s")
params.append(site_id)
if room_id:
conditions.append("mapped.room_id = %s")
params.append(room_id)
for tag_name in tag_names:
conditions.append(
f"""
@@ -241,6 +375,14 @@ class InventoryRepository:
)
params.append(tag_name)
location_fields = (
LOCATION_FIELDS if include_location_details else REDACTED_LOCATION_FIELDS
)
location_join = LOCATION_JOIN if include_location_details else ""
select_sql = FLEET_SELECT.format(
location_fields=location_fields,
location_join=location_join,
)
where_sql = " AND ".join(conditions) if conditions else "true"
params.extend([limit, offset])
@@ -248,18 +390,44 @@ class InventoryRepository:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"""
{MINER_SELECT}
{select_sql}
WHERE {where_sql}
ORDER BY
lower(trim(miners.asset_tag)) NULLS LAST,
miners.created_at,
miners.id
ORDER BY lower(trim(miners.asset_tag)) NULLS LAST,
miners.created_at, miners.id
LIMIT %s OFFSET %s
""",
params,
)
return [dict(row) for row in cur.fetchall()]
def list_location_filter_options(self) -> dict[str, list[dict]]:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT sites.id, sites.name
FROM location_positions lp
JOIN miners ON miners.id = lp.miner_id
JOIN rooms ON rooms.id = lp.room_id
JOIN sites ON sites.id = rooms.site_id
GROUP BY sites.id, sites.name
ORDER BY lower(sites.name), sites.id
"""
)
sites = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT rooms.id, rooms.site_id, rooms.name
FROM location_positions lp
JOIN miners ON miners.id = lp.miner_id
JOIN rooms ON rooms.id = lp.room_id
GROUP BY rooms.id, rooms.site_id, rooms.name
ORDER BY lower(rooms.name), rooms.id
"""
)
rooms = [dict(row) for row in cur.fetchall()]
return {"sites": sites, "rooms": rooms}
def create_miner(self, values: Mapping[str, Any]) -> dict:
clean_values = {
key: value for key, value in values.items() if key in MINER_WRITE_COLUMNS
+18 -3
View File
@@ -5,7 +5,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.identity import require_permission
from app.identity import require_permission, user_has_permission
from app.inventory.dependencies import get_inventory_service
from app.inventory.exceptions import (
DuplicateAssetID,
@@ -16,6 +16,7 @@ from app.inventory.models import (
InventoryAssetIDHistoryResponse,
InventoryCreateRequest,
InventoryListMeta,
InventoryLocationOptions,
InventoryMetadataUpdateRequest,
InventoryMinerDetailResponse,
InventoryMinerListResponse,
@@ -102,21 +103,35 @@ def list_inventory_miners(
service: InventoryServiceDependency,
current_user: InventoryReadUser,
) -> InventoryMinerListResponse:
del current_user
include_location_details = user_has_permission(current_user, "locations.view")
if (filters.site_id or filters.room_id) and not include_location_details:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Location viewing permission is required for location filters",
)
try:
miners = service.search_miners(filters)
miners = service.search_miners(
filters,
include_location_details=include_location_details,
)
except InventoryValidationError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
location_options = InventoryLocationOptions()
if include_location_details:
location_options = InventoryLocationOptions.model_validate(
service.list_location_filter_options()
)
return InventoryMinerListResponse(
data=miners,
meta=InventoryListMeta(
limit=filters.limit,
offset=filters.offset,
returned=len(miners),
location_options=location_options,
),
)
+38 -1
View File
@@ -79,16 +79,37 @@ class InventoryService:
@classmethod
def _summary_model(cls, row: dict) -> InventoryMinerSummary:
active_repair = None
if row.get("active_repair_id") is not None:
active_repair = {
"id": row["active_repair_id"],
"status": row["active_repair_status"],
"reason": row["active_repair_reason"],
}
return InventoryMinerSummary(
id=row["id"],
asset_id=row.get("asset_tag"),
current_mac=row.get("current_mac"),
current_ip=row.get("current_ip"),
model=row.get("model"),
miner_sn=row.get("miner_sn"),
lifecycle_status=row["lifecycle_status"],
runtime_status=row["status"],
site_id=row.get("site_id"),
site_name=row.get("site_name"),
room_id=row.get("room_id"),
room_name=row.get("room_name"),
location_position_id=row.get("location_position_id"),
rack_label=row.get("rack_label"),
shelf_label=row.get("shelf_label"),
position_label=row.get("position_label"),
position_index=row.get("position_index"),
observed_at=row.get("observed_at"),
hashrate_ths=row.get("hashrate_ths"),
power_w=row.get("power_w"),
chip_temp_max_c=row.get("chip_temp_max_c"),
hash_stable=row.get("hash_stable"),
active_repair=active_repair,
tags=[cls._tag_model(tag) for tag in row.get("tags") or []],
created_at=row["created_at"],
updated_at=row["updated_at"],
@@ -101,7 +122,6 @@ class InventoryService:
return InventoryMinerDetail(
**summary.model_dump(),
hostname=row.get("hostname"),
miner_sn=row.get("miner_sn"),
control_board_sn=row.get("control_board_sn"),
psu_serial=row.get("power_sn"),
firmware_version=row.get("firmware_version"),
@@ -140,12 +160,18 @@ class InventoryService:
def search_miners(
self,
filters: InventorySearchFilters,
*,
include_location_details: bool = False,
) -> list[InventoryMinerSummary]:
lifecycle_status = None
if filters.lifecycle_status:
lifecycle_status = self.validate_lifecycle_status(
filters.lifecycle_status
)
if (filters.site_id or filters.room_id) and not include_location_details:
raise InventoryValidationError(
"Location filters require location viewing permission"
)
asset_id = None
if filters.asset_id:
asset_id = self.normalize_asset_id(filters.asset_id)
@@ -158,12 +184,23 @@ class InventoryService:
query=self._clean_optional_text(filters.query),
asset_id=asset_id,
lifecycle_status=lifecycle_status,
model=self._clean_optional_text(filters.model),
runtime_status=self._clean_optional_text(filters.runtime_status),
repair=filters.repair,
operational_state=filters.operational_state,
problems=filters.problems,
site_id=filters.site_id,
room_id=filters.room_id,
tag_names=tag_names,
limit=filters.limit,
offset=filters.offset,
include_location_details=include_location_details,
)
]
def list_location_filter_options(self) -> dict[str, list[dict]]:
return self.repository.list_location_filter_options()
def create_miner(
self,
request: InventoryCreateRequest,