feat: add Fleet operations view
This commit is contained in:
1 parent
c6d01df31c
commit
786a1ab910
12 files changed
+1062
-148
No files matched your search
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user