feat: add Repair Center operations view

This commit is contained in:
lussierj committed 2026-08-11 20:17:16 +00:00
1 parent 786a1ab910
commit 4a1959ecd6
14 files changed
+1963 -338

No files matched your search

+97 -116
View File
@@ -7,7 +7,7 @@ import psycopg
import redis
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import Request, Depends, FastAPI, Header, HTTPException, status
from fastapi import Request, Depends, FastAPI, Header, HTTPException, Query, status
from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError
from app.config import settings
@@ -17,6 +17,7 @@ from app.identity import (
get_current_user,
install_identity_middleware,
require_permission,
user_has_permission,
router as identity_router,
)
from app.frontend.router import router as frontend_router
@@ -30,11 +31,17 @@ from app.repairs.exceptions import (
RepairMinerNotFound,
)
from app.repairs.models import (
RepairCenterAccess,
RepairCenterFilters,
RepairCenterScope,
RepairMinerLifecycle,
ControlBoardReplacementReason,
ControlBoardReplacementRequest,
RemovedBoardDisposition,
)
from app.repairs.repository import RepairRepository
from app.repairs.center_repository import RepairCenterRepository
from app.repairs.center_service import RepairCenterService
from app.repairs.service import RepairService
from app.repairs.router import (
control_board_router,
@@ -31732,122 +31739,72 @@ def minerops_repair_allowed_status(value):
@app.get("/api/repairs/queue")
def minerops_repair_queue_api(
query: str | None = Query(default=None, max_length=255),
scope: RepairCenterScope = "active",
status_filter: str | None = Query(default=None, alias="status", max_length=64),
model: str | None = Query(default=None, max_length=255),
lifecycle_status: RepairMinerLifecycle | None = None,
site_id: UUID | None = None,
room_id: UUID | None = None,
limit: int = Query(default=250, ge=1, le=250),
offset: int = Query(default=0, ge=0),
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
ensure_maintenance_tables()
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
WITH latest_repair_event AS (
SELECT DISTINCT ON (miner_id)
id::text AS event_id,
miner_id,
event_status,
event_title,
description,
event_at,
created_at
FROM miner_maintenance_events
WHERE event_type = 'repair'
ORDER BY miner_id, event_at DESC, created_at DESC
),
event_counts AS (
SELECT
miner_id,
COUNT(*) AS repair_event_count,
COUNT(*) FILTER (
WHERE event_status IN (
'open',
'diagnosing',
'waiting_parts',
'in_progress',
'testing',
'needs_followup'
)
) AS open_repair_event_count
FROM miner_maintenance_events
WHERE event_type = 'repair'
GROUP BY miner_id
)
SELECT
m.id::text AS miner_id,
m.current_mac,
host(m.current_ip) AS current_ip,
COALESCE(m.miner_type, m.model) AS model,
m.miner_sn,
m.power_sn,
m.status AS miner_status,
m.created_at AS miner_created_at,
m.updated_at AS miner_updated_at,
m.manual_details,
mls.asset_state,
mls.state_notes,
mls.updated_at AS state_updated_at,
lre.event_id AS latest_event_id,
lre.event_status AS latest_event_status,
lre.event_title AS latest_event_title,
lre.description AS latest_event_description,
lre.event_at AS latest_event_at,
COALESCE(ec.repair_event_count, 0) AS repair_event_count,
COALESCE(ec.open_repair_event_count, 0) AS open_repair_event_count,
m.manual_details -> 'office_repair_import' ->> 'reported_error' AS reported_error,
m.manual_details -> 'office_repair_import' ->> 'reported_hashrate_ths' AS reported_hashrate_ths,
m.manual_details -> 'office_repair_import' ->> 'reported_voltage' AS reported_voltage,
m.manual_details -> 'office_repair_import' ->> 'location_note' AS location_note,
m.manual_details -> 'office_repair_import' ->> 'source' AS import_source
FROM miner_location_states mls
JOIN miners m ON m.id = mls.miner_id
LEFT JOIN latest_repair_event lre ON lre.miner_id = m.id
LEFT JOIN event_counts ec ON ec.miner_id = m.id
WHERE mls.asset_state = 'repair'
ORDER BY
COALESCE(mls.updated_at, m.updated_at) DESC,
m.current_mac;
"""
)
rows = [dict(row) for row in cur.fetchall()]
status_counts = {}
model_counts = {}
error_counts = {}
for row in rows:
status = row.get("latest_event_status") or "unknown"
model = row.get("model") or "unknown"
error = row.get("reported_error") or "No reported error"
status_counts[status] = status_counts.get(status, 0) + 1
model_counts[model] = model_counts.get(model, 0) + 1
error_counts[error] = error_counts.get(error, 0) + 1
return api_response(
data={
"items": rows,
"summary": {
"total": len(rows),
"status_counts": status_counts,
"model_counts": model_counts,
"error_counts": error_counts,
},
}
can_view_miners = user_has_permission(current_user, "miners.view")
can_view_locations = user_has_permission(current_user, "locations.view")
if (site_id or room_id) and not can_view_locations:
raise HTTPException(
status_code=403,
detail="Location viewing permission is required for location filters",
)
if model and not can_view_miners:
raise HTTPException(
status_code=403,
detail="Miner viewing permission is required for model filters",
)
filters = RepairCenterFilters(
query=query,
scope=scope,
status=status_filter,
model=model,
lifecycle_status=lifecycle_status,
site_id=site_id,
room_id=room_id,
limit=limit,
offset=offset,
)
access = RepairCenterAccess(
can_read_repairs=user_has_permission(current_user, "repairs.read"),
can_view_miners=can_view_miners,
can_view_locations=can_view_locations,
can_complete_repairs=user_has_permission(current_user, "repairs.complete"),
can_manage_parts=user_has_permission(current_user, "repairs.parts.manage"),
can_manage_legacy=user_has_permission(current_user, "repairs.manage"),
)
result = RepairCenterService(RepairCenterRepository()).list_repair_center(
filters, access
)
return result.model_dump(mode="json")
finally:
if conn is not None:
conn.close()
def minerops_repair_reject_modular_mutation(cur, miner_id: str) -> None:
cur.execute(
"""
SELECT 1
FROM repairs
WHERE miner_id = %s::uuid
AND status IN ('OPEN', 'IN_PROGRESS', 'WAITING_FOR_PARTS', 'READY_FOR_TEST')
LIMIT 1
""",
(miner_id,),
)
if cur.fetchone():
raise HTTPException(
status_code=409,
detail="This miner has an active modular repair; use the modular repair workflow",
)
@app.post("/api/repairs/{miner_id}/note")
def minerops_repair_add_note_api(
miner_id: str,
@@ -31874,7 +31831,8 @@ def minerops_repair_add_note_api(
SELECT id::text, current_mac
FROM miners
WHERE id = %s::uuid
LIMIT 1;
LIMIT 1
FOR UPDATE;
""",
(miner_id,),
)
@@ -31884,6 +31842,8 @@ def minerops_repair_add_note_api(
if not miner:
raise HTTPException(status_code=404, detail="Miner not found")
minerops_repair_reject_modular_mutation(cur, miner_id)
cur.execute(
"""
INSERT INTO miner_maintenance_events (
@@ -31966,7 +31926,8 @@ def minerops_repair_update_status_api(
SELECT id::text
FROM miners
WHERE id = %s::uuid
LIMIT 1;
LIMIT 1
FOR UPDATE;
""",
(miner_id,),
)
@@ -31976,6 +31937,8 @@ def minerops_repair_update_status_api(
if not miner:
raise HTTPException(status_code=404, detail="Miner not found")
minerops_repair_reject_modular_mutation(cur, miner_id)
cur.execute(
"""
SELECT id::text
@@ -32121,7 +32084,8 @@ def minerops_repair_update_state_api(
SELECT id::text, current_mac
FROM miners
WHERE id = %s::uuid
LIMIT 1;
LIMIT 1
FOR UPDATE;
""",
(miner_id,),
)
@@ -32131,6 +32095,8 @@ def minerops_repair_update_state_api(
if not miner:
raise HTTPException(status_code=404, detail="Miner not found")
minerops_repair_reject_modular_mutation(cur, miner_id)
cur.execute(
"""
UPDATE location_positions
@@ -32937,8 +32903,23 @@ MINEROPS_REPAIR_QUEUE_HTML = r"""
async function loadRepairs() {
try {
const data = await apiGet("/api/repairs/queue");
repairItems = data.items || [];
updateMetrics(data.summary || {});
repairItems = (data.items || []).filter(function (item) {
return !item.source || item.source === "legacy";
});
const legacySummary = {
total: repairItems.length,
status_counts: {},
model_counts: {}
};
for (const item of repairItems) {
const status = item.latest_event_status || item.asset_state || "unknown";
const model = item.model || "unknown";
legacySummary.status_counts[status] =
(legacySummary.status_counts[status] || 0) + 1;
legacySummary.model_counts[model] =
(legacySummary.model_counts[model] || 0) + 1;
}
updateMetrics(legacySummary);
buildModelFilter();
renderTable();
showStatus("ok", "Repair Queue loaded. " + repairItems.length + " miners in repair.");