feat: add Repair Center operations view
This commit is contained in:
1 parent
786a1ab910
commit
4a1959ecd6
14 files changed
+1963
-338
No files matched your search
+97
-116
@@ -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.");
|
||||
|
||||
Reference in new issue
Block a user