from datetime import datetime, timedelta, timezone from typing import Any from uuid import UUID import jwt import psycopg import redis from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError from fastapi import Request, Depends, FastAPI, Header, HTTPException, Query, status from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError from app.config import settings from app.agent_commands.dependencies import require_agent_identity from app.agent_commands.router import router as agent_commands_router from app.agent_zones.dependencies import get_agent_zone_service from app.agent_zones.models import MinerZoneAssociationResult from app.agent_zones.service import AgentZoneService from app.agent_zones.router import router as agent_zones_router from app.agent_enrollment.router import router as agent_enrollment_router from app.miner_control.router import router as miner_control_router from app.dashboard.router import router as dashboard_router from app.identity import ( COOKIE_TOKEN_HINT, SESSION_COOKIE, get_current_user, install_identity_middleware, require_permission, user_has_permission, router as identity_router, ) from app.frontend.router import ( dashboard_page as internal_alpha_dashboard_page, miner_detail_page as internal_alpha_miner_detail_page, router as frontend_router, ) from app.inventory.router import router as inventory_router from app.locations.router import router as locations_router from app.components.router import router as components_router from app.miner_detail.router import router as miner_detail_router from app.monitoring.router import router as monitoring_router from app.monitoring.frontend import router as monitoring_frontend_router from app.system_health.frontend import router as system_health_frontend_router from app.system_health.router import router as system_health_router from app.repairs.exceptions import ( ControlBoardReplacementActorRequired, InvalidMacAddress, RepairConflict, 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, router as repairs_router, ) app = FastAPI(title=settings.app_name) app.include_router(identity_router) app.include_router(frontend_router) app.include_router(inventory_router) app.include_router(miner_control_router) app.include_router(agent_commands_router) app.include_router(agent_zones_router) app.include_router(agent_enrollment_router) app.include_router(components_router) app.include_router(locations_router) app.include_router(dashboard_router) app.include_router(miner_detail_router) app.include_router(monitoring_frontend_router) app.include_router(monitoring_router) app.include_router(system_health_frontend_router) app.include_router(system_health_router) install_identity_middleware(app) def api_response(data: Any = None, meta: dict | None = None, error: Any = None) -> dict: return { "data": data, "meta": meta or {}, "error": error, } def json_safe(value: Any) -> Any: if isinstance(value, dict): return {key: json_safe(item) for key, item in value.items()} if isinstance(value, list): return [json_safe(item) for item in value] if isinstance(value, datetime): return value.isoformat() return value def get_db_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, ) def check_database() -> str: conn = None try: conn = get_db_connection() with conn.cursor() as cur: cur.execute("SELECT 1;") result = cur.fetchone() if result and result[0] == 1: return "ok" return "error" except Exception as exc: return f"error: {exc}" finally: if conn is not None: conn.close() def check_redis() -> str: try: client = redis.Redis.from_url(settings.redis_url, socket_connect_timeout=5) if client.ping(): return "ok" return "error" except Exception as exc: return f"error: {exc}" @app.get("/api/health") def health_check(): database_status = check_database() redis_status = check_redis() overall_status = "ok" if database_status != "ok" or redis_status != "ok": overall_status = "degraded" return api_response( data={ "status": overall_status, "app": settings.app_name, "environment": settings.app_env, "database": database_status, "redis": redis_status, } ) class CreateRoomRequest(BaseModel): site_id: str | None = None name: str = Field(min_length=1, max_length=120) description: str | None = None sort_order: int = 0 class UpdateRoomRequest(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=120) description: str | None = None sort_order: int | None = None @app.get("/api/sites") def list_sites(current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id::text, name, description, created_at, updated_at FROM sites ORDER BY name; """ ) sites = [dict(row) for row in cur.fetchall()] return api_response(data=sites) finally: if conn is not None: conn.close() @app.get("/api/rooms") def list_rooms(current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT rooms.id::text, rooms.site_id::text, sites.name AS site_name, rooms.name, rooms.description, rooms.sort_order, rooms.created_at, rooms.updated_at FROM rooms JOIN sites ON sites.id = rooms.site_id ORDER BY rooms.sort_order, rooms.name; """ ) rooms = [dict(row) for row in cur.fetchall()] return api_response(data=rooms) finally: if conn is not None: conn.close() @app.post("/api/rooms") def create_room(request: CreateRoomRequest, current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() conn.autocommit = False with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: site_id = request.site_id if site_id is None: cur.execute("SELECT id::text FROM sites ORDER BY name LIMIT 1;") site = cur.fetchone() if not site: raise HTTPException(status_code=500, detail="No site exists") site_id = site["id"] cur.execute( """ INSERT INTO rooms (site_id, name, description, sort_order) VALUES (%s, %s, %s, %s) RETURNING id::text, site_id::text, name, description, sort_order, created_at, updated_at; """, ( site_id, request.name.strip(), request.description, request.sort_order, ), ) room = cur.fetchone() cur.execute( """ INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json) VALUES (%s, 'room.created', 'room', %s, %s); """, ( current_user["id"], room["id"], psycopg.types.json.Jsonb( { "name": room["name"], "site_id": room["site_id"], "sort_order": room["sort_order"], } ), ), ) conn.commit() return api_response(data=dict(room)) except HTTPException: if conn is not None: conn.rollback() raise except Exception as exc: if conn is not None: conn.rollback() raise HTTPException(status_code=500, detail=str(exc)) finally: if conn is not None: conn.close() @app.patch("/api/rooms/{room_id}") def update_room(room_id: str, request: UpdateRoomRequest, current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() conn.autocommit = False with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id::text, name, description, sort_order FROM rooms WHERE id = %s; """, (room_id,), ) old_room = cur.fetchone() if not old_room: raise HTTPException(status_code=404, detail="Room not found") new_name = request.name.strip() if request.name is not None else old_room["name"] new_description = request.description if request.description is not None else old_room["description"] new_sort_order = request.sort_order if request.sort_order is not None else old_room["sort_order"] cur.execute( """ UPDATE rooms SET name = %s, description = %s, sort_order = %s, updated_at = now() WHERE id = %s RETURNING id::text, site_id::text, name, description, sort_order, created_at, updated_at; """, (new_name, new_description, new_sort_order, room_id), ) room = cur.fetchone() cur.execute( """ INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json, new_value_json) VALUES (%s, 'room.updated', 'room', %s, %s, %s); """, ( current_user["id"], room["id"], psycopg.types.json.Jsonb(json_safe(dict(old_room))), psycopg.types.json.Jsonb(json_safe(dict(room))), ), ) conn.commit() return api_response(data=dict(room)) except HTTPException: if conn is not None: conn.rollback() raise except Exception as exc: if conn is not None: conn.rollback() raise HTTPException(status_code=500, detail=str(exc)) finally: if conn is not None: conn.close() @app.delete("/api/rooms/{room_id}") def delete_room(room_id: str, current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() conn.autocommit = False with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id::text, name, description, sort_order FROM rooms WHERE id = %s; """, (room_id,), ) room = cur.fetchone() if not room: raise HTTPException(status_code=404, detail="Room not found") cur.execute("DELETE FROM rooms WHERE id = %s;", (room_id,)) cur.execute( """ INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json) VALUES (%s, 'room.deleted', 'room', %s, %s); """, ( current_user["id"], room["id"], psycopg.types.json.Jsonb(json_safe(dict(room))), ), ) conn.commit() return api_response(data={"deleted": True, "room_id": room_id}) except HTTPException: if conn is not None: conn.rollback() raise except Exception as exc: if conn is not None: conn.rollback() raise HTTPException(status_code=500, detail=str(exc)) finally: if conn is not None: conn.close() class CreateRoomRequest(BaseModel): site_id: str | None = None name: str = Field(min_length=1, max_length=120) description: str | None = None sort_order: int = 0 class UpdateRoomRequest(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=120) description: str | None = None sort_order: int | None = None @app.get("/api/sites") def list_sites(current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id::text, name, description, created_at, updated_at FROM sites ORDER BY name; """ ) sites = [dict(row) for row in cur.fetchall()] return api_response(data=sites) finally: if conn is not None: conn.close() @app.get("/api/rooms") def list_rooms(current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT rooms.id::text, rooms.site_id::text, sites.name AS site_name, rooms.name, rooms.description, rooms.sort_order, rooms.created_at, rooms.updated_at FROM rooms JOIN sites ON sites.id = rooms.site_id ORDER BY rooms.sort_order, rooms.name; """ ) rooms = [dict(row) for row in cur.fetchall()] return api_response(data=rooms) finally: if conn is not None: conn.close() @app.post("/api/rooms") def create_room(request: CreateRoomRequest, current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() conn.autocommit = False with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: site_id = request.site_id if site_id is None: cur.execute("SELECT id::text FROM sites ORDER BY name LIMIT 1;") site = cur.fetchone() if not site: raise HTTPException(status_code=500, detail="No site exists") site_id = site["id"] cur.execute( """ INSERT INTO rooms (site_id, name, description, sort_order) VALUES (%s, %s, %s, %s) RETURNING id::text, site_id::text, name, description, sort_order, created_at, updated_at; """, ( site_id, request.name.strip(), request.description, request.sort_order, ), ) room = cur.fetchone() cur.execute( """ INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json) VALUES (%s, 'room.created', 'room', %s, %s); """, ( current_user["id"], room["id"], psycopg.types.json.Jsonb( { "name": room["name"], "site_id": room["site_id"], "sort_order": room["sort_order"], } ), ), ) conn.commit() return api_response(data=dict(room)) except HTTPException: if conn is not None: conn.rollback() raise except Exception as exc: if conn is not None: conn.rollback() raise HTTPException(status_code=500, detail=str(exc)) finally: if conn is not None: conn.close() @app.patch("/api/rooms/{room_id}") def update_room(room_id: str, request: UpdateRoomRequest, current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() conn.autocommit = False with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id::text, name, description, sort_order FROM rooms WHERE id = %s; """, (room_id,), ) old_room = cur.fetchone() if not old_room: raise HTTPException(status_code=404, detail="Room not found") new_name = request.name.strip() if request.name is not None else old_room["name"] new_description = request.description if request.description is not None else old_room["description"] new_sort_order = request.sort_order if request.sort_order is not None else old_room["sort_order"] cur.execute( """ UPDATE rooms SET name = %s, description = %s, sort_order = %s, updated_at = now() WHERE id = %s RETURNING id::text, site_id::text, name, description, sort_order, created_at, updated_at; """, (new_name, new_description, new_sort_order, room_id), ) room = cur.fetchone() cur.execute( """ INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json, new_value_json) VALUES (%s, 'room.updated', 'room', %s, %s, %s); """, ( current_user["id"], room["id"], psycopg.types.json.Jsonb(json_safe(dict(old_room))), psycopg.types.json.Jsonb(json_safe(dict(room))), ), ) conn.commit() return api_response(data=dict(room)) except HTTPException: if conn is not None: conn.rollback() raise except Exception as exc: if conn is not None: conn.rollback() raise HTTPException(status_code=500, detail=str(exc)) finally: if conn is not None: conn.close() @app.delete("/api/rooms/{room_id}") def delete_room(room_id: str, current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() conn.autocommit = False with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id::text, name, description, sort_order FROM rooms WHERE id = %s; """, (room_id,), ) room = cur.fetchone() if not room: raise HTTPException(status_code=404, detail="Room not found") cur.execute("DELETE FROM rooms WHERE id = %s;", (room_id,)) cur.execute( """ INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json) VALUES (%s, 'room.deleted', 'room', %s, %s); """, ( current_user["id"], room["id"], psycopg.types.json.Jsonb(json_safe(dict(room))), ), ) conn.commit() return api_response(data={"deleted": True, "room_id": room_id}) except HTTPException: if conn is not None: conn.rollback() raise except Exception as exc: if conn is not None: conn.rollback() raise HTTPException(status_code=500, detail=str(exc)) finally: if conn is not None: conn.close() import ipaddress class CreateScanRangeRequest(BaseModel): room_id: str | None = None name: str = Field(min_length=1, max_length=120) range_type: str = "cidr" cidr: str | None = None start_ip: str | None = None end_ip: str | None = None enabled: bool = True scan_interval_seconds: int = Field(default=900, ge=60, le=86400) timeout_ms: int = Field(default=3000, ge=500, le=30000) retry_limit: int = Field(default=1, ge=0, le=5) parallelism_limit: int = Field(default=10, ge=1, le=100) stagger_ms: int = Field(default=250, ge=0, le=10000) api_port: int = Field(default=4028, ge=1, le=65535) class UpdateScanRangeRequest(BaseModel): room_id: str | None = None name: str | None = Field(default=None, min_length=1, max_length=120) range_type: str | None = None cidr: str | None = None start_ip: str | None = None end_ip: str | None = None enabled: bool | None = None scan_interval_seconds: int | None = Field(default=None, ge=60, le=86400) timeout_ms: int | None = Field(default=None, ge=500, le=30000) retry_limit: int | None = Field(default=None, ge=0, le=5) parallelism_limit: int | None = Field(default=None, ge=1, le=100) stagger_ms: int | None = Field(default=None, ge=0, le=10000) api_port: int | None = Field(default=None, ge=1, le=65535) def validate_scan_range_input(range_type: str, cidr: str | None, start_ip: str | None, end_ip: str | None) -> None: if range_type not in ("cidr", "start_end"): raise HTTPException(status_code=422, detail="range_type must be 'cidr' or 'start_end'") if range_type == "cidr": if not cidr: raise HTTPException(status_code=422, detail="cidr is required when range_type is cidr") try: ipaddress.ip_network(cidr, strict=False) except ValueError as exc: raise HTTPException(status_code=422, detail=f"Invalid CIDR range: {exc}") if range_type == "start_end": if not start_ip or not end_ip: raise HTTPException(status_code=422, detail="start_ip and end_ip are required when range_type is start_end") try: start = ipaddress.ip_address(start_ip) end = ipaddress.ip_address(end_ip) except ValueError as exc: raise HTTPException(status_code=422, detail=f"Invalid IP address: {exc}") if start.version != end.version: raise HTTPException(status_code=422, detail="start_ip and end_ip must use the same IP version") if int(start) > int(end): raise HTTPException(status_code=422, detail="start_ip must be lower than or equal to end_ip") @app.get("/api/scan-ranges") def list_scan_ranges(current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT scan_ranges.id::text, scan_ranges.room_id::text, rooms.name AS room_name, scan_ranges.name, scan_ranges.range_type, scan_ranges.cidr, host(scan_ranges.start_ip) AS start_ip, host(scan_ranges.end_ip) AS end_ip, scan_ranges.enabled, scan_ranges.scan_interval_seconds, scan_ranges.timeout_ms, scan_ranges.retry_limit, scan_ranges.parallelism_limit, scan_ranges.stagger_ms, scan_ranges.api_port, scan_ranges.last_scan_at, scan_ranges.next_scan_at, scan_ranges.created_at, scan_ranges.updated_at FROM scan_ranges LEFT JOIN rooms ON rooms.id = scan_ranges.room_id ORDER BY rooms.sort_order, scan_ranges.name; """ ) ranges = [dict(row) for row in cur.fetchall()] return api_response(data=ranges) finally: if conn is not None: conn.close() @app.post("/api/scan-ranges") def create_scan_range(request: CreateScanRangeRequest, current_user: dict = Depends(get_current_user)): validate_scan_range_input(request.range_type, request.cidr, request.start_ip, request.end_ip) conn = None try: conn = get_db_connection() conn.autocommit = False with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ INSERT INTO scan_ranges ( room_id, name, range_type, cidr, start_ip, end_ip, enabled, scan_interval_seconds, timeout_ms, retry_limit, parallelism_limit, stagger_ms, api_port, created_by_user_id, next_scan_at ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, now()) RETURNING id::text, room_id::text, name, range_type, cidr, host(start_ip) AS start_ip, host(end_ip) AS end_ip, enabled, scan_interval_seconds, timeout_ms, retry_limit, parallelism_limit, stagger_ms, api_port, last_scan_at, next_scan_at, created_at, updated_at; """, ( request.room_id, request.name.strip(), request.range_type, request.cidr, request.start_ip, request.end_ip, request.enabled, request.scan_interval_seconds, request.timeout_ms, request.retry_limit, request.parallelism_limit, request.stagger_ms, request.api_port, current_user["id"], ), ) scan_range = cur.fetchone() cur.execute( """ INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json) VALUES (%s, 'scan_range.created', 'scan_range', %s, %s); """, ( current_user["id"], scan_range["id"], psycopg.types.json.Jsonb(json_safe(dict(scan_range))), ), ) conn.commit() return api_response(data=dict(scan_range)) except HTTPException: if conn is not None: conn.rollback() raise except Exception as exc: if conn is not None: conn.rollback() raise HTTPException(status_code=500, detail=str(exc)) finally: if conn is not None: conn.close() @app.patch("/api/scan-ranges/{scan_range_id}") def update_scan_range(scan_range_id: str, request: UpdateScanRangeRequest, current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() conn.autocommit = False with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id::text, room_id::text, name, range_type, cidr, host(start_ip) AS start_ip, host(end_ip) AS end_ip, enabled, scan_interval_seconds, timeout_ms, retry_limit, parallelism_limit, stagger_ms, api_port FROM scan_ranges WHERE id = %s; """, (scan_range_id,), ) old_range = cur.fetchone() if not old_range: raise HTTPException(status_code=404, detail="Scan range not found") new_room_id = request.room_id if request.room_id is not None else old_range["room_id"] new_name = request.name.strip() if request.name is not None else old_range["name"] new_range_type = request.range_type if request.range_type is not None else old_range["range_type"] new_cidr = request.cidr if request.cidr is not None else old_range["cidr"] new_start_ip = request.start_ip if request.start_ip is not None else old_range["start_ip"] new_end_ip = request.end_ip if request.end_ip is not None else old_range["end_ip"] new_enabled = request.enabled if request.enabled is not None else old_range["enabled"] new_scan_interval_seconds = request.scan_interval_seconds if request.scan_interval_seconds is not None else old_range["scan_interval_seconds"] new_timeout_ms = request.timeout_ms if request.timeout_ms is not None else old_range["timeout_ms"] new_retry_limit = request.retry_limit if request.retry_limit is not None else old_range["retry_limit"] new_parallelism_limit = request.parallelism_limit if request.parallelism_limit is not None else old_range["parallelism_limit"] new_stagger_ms = request.stagger_ms if request.stagger_ms is not None else old_range["stagger_ms"] new_api_port = request.api_port if request.api_port is not None else old_range["api_port"] validate_scan_range_input(new_range_type, new_cidr, new_start_ip, new_end_ip) cur.execute( """ UPDATE scan_ranges SET room_id = %s, name = %s, range_type = %s, cidr = %s, start_ip = %s, end_ip = %s, enabled = %s, scan_interval_seconds = %s, timeout_ms = %s, retry_limit = %s, parallelism_limit = %s, stagger_ms = %s, api_port = %s, updated_at = now() WHERE id = %s RETURNING id::text, room_id::text, name, range_type, cidr, host(start_ip) AS start_ip, host(end_ip) AS end_ip, enabled, scan_interval_seconds, timeout_ms, retry_limit, parallelism_limit, stagger_ms, api_port, last_scan_at, next_scan_at, created_at, updated_at; """, ( new_room_id, new_name, new_range_type, new_cidr, new_start_ip, new_end_ip, new_enabled, new_scan_interval_seconds, new_timeout_ms, new_retry_limit, new_parallelism_limit, new_stagger_ms, new_api_port, scan_range_id, ), ) scan_range = cur.fetchone() cur.execute( """ INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json, new_value_json) VALUES (%s, 'scan_range.updated', 'scan_range', %s, %s, %s); """, ( current_user["id"], scan_range["id"], psycopg.types.json.Jsonb(json_safe(dict(old_range))), psycopg.types.json.Jsonb(json_safe(dict(scan_range))), ), ) conn.commit() return api_response(data=dict(scan_range)) except HTTPException: if conn is not None: conn.rollback() raise except Exception as exc: if conn is not None: conn.rollback() raise HTTPException(status_code=500, detail=str(exc)) finally: if conn is not None: conn.close() @app.delete("/api/scan-ranges/{scan_range_id}") def delete_scan_range(scan_range_id: str, current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() conn.autocommit = False with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id::text, name, range_type, cidr, host(start_ip) AS start_ip, end_ip::text FROM scan_ranges WHERE id = %s; """, (scan_range_id,), ) old_range = cur.fetchone() if not old_range: raise HTTPException(status_code=404, detail="Scan range not found") cur.execute("DELETE FROM scan_ranges WHERE id = %s;", (scan_range_id,)) cur.execute( """ INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json) VALUES (%s, 'scan_range.deleted', 'scan_range', %s, %s); """, ( current_user["id"], old_range["id"], psycopg.types.json.Jsonb(json_safe(dict(old_range))), ), ) conn.commit() return api_response(data={"deleted": True, "scan_range_id": scan_range_id}) except HTTPException: if conn is not None: conn.rollback() raise except Exception as exc: if conn is not None: conn.rollback() raise HTTPException(status_code=500, detail=str(exc)) finally: if conn is not None: conn.close() @app.get("/api/scan-runs") def list_scan_runs(current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT scan_runs.id::text, scan_runs.scan_range_id::text, scan_ranges.name AS scan_range_name, rooms.name AS room_name, scan_runs.triggered_by, scan_runs.triggered_by_user_id::text, scan_runs.started_at, scan_runs.finished_at, scan_runs.status, scan_runs.total_ips, scan_runs.scanned_ips, scan_runs.miners_found, scan_runs.errors_count, scan_runs.notes FROM scan_runs LEFT JOIN scan_ranges ON scan_ranges.id = scan_runs.scan_range_id LEFT JOIN rooms ON rooms.id = scan_ranges.room_id ORDER BY scan_runs.started_at DESC LIMIT 100; """ ) scan_runs = [dict(row) for row in cur.fetchall()] return api_response(data=scan_runs) finally: if conn is not None: conn.close() @app.get("/api/scan-runs/{scan_run_id}") def get_scan_run(scan_run_id: str, current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT scan_runs.id::text, scan_runs.scan_range_id::text, scan_ranges.name AS scan_range_name, rooms.name AS room_name, scan_runs.triggered_by, scan_runs.triggered_by_user_id::text, scan_runs.started_at, scan_runs.finished_at, scan_runs.status, scan_runs.total_ips, scan_runs.scanned_ips, scan_runs.miners_found, scan_runs.errors_count, scan_runs.notes FROM scan_runs LEFT JOIN scan_ranges ON scan_ranges.id = scan_runs.scan_range_id LEFT JOIN rooms ON rooms.id = scan_ranges.room_id WHERE scan_runs.id = %s; """, (scan_run_id,), ) scan_run = cur.fetchone() if not scan_run: raise HTTPException(status_code=404, detail="Scan run not found") return api_response(data=dict(scan_run)) finally: if conn is not None: conn.close() @app.get("/api/scan-runs/{scan_run_id}/results") def list_scan_run_results(scan_run_id: str, current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id::text, scan_run_id::text, host(ip_address) AS ip_address, port, detected, mac_address, response_ms, status, error_message, raw_summary_json, raw_version_json, raw_pools_json, raw_error_json, created_at FROM scan_results WHERE scan_run_id = %s ORDER BY ip_address LIMIT 2000; """, (scan_run_id,), ) results = [dict(row) for row in cur.fetchall()] return api_response(data=results) finally: if conn is not None: conn.close() @app.get("/api/scan-runs") def list_scan_runs(current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT scan_runs.id::text, scan_runs.scan_range_id::text, scan_ranges.name AS scan_range_name, rooms.name AS room_name, scan_runs.triggered_by, scan_runs.triggered_by_user_id::text, scan_runs.started_at, scan_runs.finished_at, scan_runs.status, scan_runs.total_ips, scan_runs.scanned_ips, scan_runs.miners_found, scan_runs.errors_count, scan_runs.notes FROM scan_runs LEFT JOIN scan_ranges ON scan_ranges.id = scan_runs.scan_range_id LEFT JOIN rooms ON rooms.id = scan_ranges.room_id ORDER BY scan_runs.started_at DESC LIMIT 100; """ ) scan_runs = [dict(row) for row in cur.fetchall()] return api_response(data=scan_runs) finally: if conn is not None: conn.close() @app.get("/api/scan-runs/{scan_run_id}") def get_scan_run(scan_run_id: str, current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT scan_runs.id::text, scan_runs.scan_range_id::text, scan_ranges.name AS scan_range_name, rooms.name AS room_name, scan_runs.triggered_by, scan_runs.triggered_by_user_id::text, scan_runs.started_at, scan_runs.finished_at, scan_runs.status, scan_runs.total_ips, scan_runs.scanned_ips, scan_runs.miners_found, scan_runs.errors_count, scan_runs.notes FROM scan_runs LEFT JOIN scan_ranges ON scan_ranges.id = scan_runs.scan_range_id LEFT JOIN rooms ON rooms.id = scan_ranges.room_id WHERE scan_runs.id = %s; """, (scan_run_id,), ) scan_run = cur.fetchone() if not scan_run: raise HTTPException(status_code=404, detail="Scan run not found") return api_response(data=dict(scan_run)) finally: if conn is not None: conn.close() @app.get("/api/scan-runs/{scan_run_id}/results") def list_scan_run_results(scan_run_id: str, current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id::text, scan_run_id::text, host(ip_address) AS ip_address, port, detected, mac_address, response_ms, status, error_message, raw_summary_json, raw_version_json, raw_pools_json, raw_error_json, created_at FROM scan_results WHERE scan_run_id = %s ORDER BY ip_address LIMIT 2000; """, (scan_run_id,), ) results = [dict(row) for row in cur.fetchall()] return api_response(data=results) finally: if conn is not None: conn.close() import socket import time from concurrent.futures import ThreadPoolExecutor, as_completed class ScanNowRequest(BaseModel): max_ips: int = Field(default=25, ge=1, le=2000) specific_ips: list[str] | None = None def expand_scan_range_for_scan(scan_range: dict, max_ips: int, specific_ips: list[str] | None = None) -> list[str]: if specific_ips: validated_ips = [] for ip in specific_ips: try: validated_ips.append(str(ipaddress.ip_address(ip))) except ValueError as exc: raise HTTPException(status_code=422, detail=f"Invalid IP address {ip}: {exc}") return validated_ips[:max_ips] range_type = scan_range["range_type"] if range_type == "cidr": network = ipaddress.ip_network(scan_range["cidr"], strict=False) return [str(ip) for ip in list(network.hosts())[:max_ips]] if range_type == "start_end": start = ipaddress.ip_address(scan_range["start_ip"]) end = ipaddress.ip_address(scan_range["end_ip"]) ips = [] current = int(start) end_int = int(end) while current <= end_int and len(ips) < max_ips: ips.append(str(ipaddress.ip_address(current))) current += 1 return ips raise HTTPException(status_code=422, detail="Unsupported scan range type") def tcp_check_ip(ip_address: str, port: int, timeout_ms: int) -> dict: started = time.perf_counter() try: with socket.create_connection((ip_address, port), timeout=timeout_ms / 1000): elapsed_ms = int((time.perf_counter() - started) * 1000) return { "ip_address": ip_address, "port": port, "detected": True, "response_ms": elapsed_ms, "status": "port_open", "error_message": None, } except socket.timeout: elapsed_ms = int((time.perf_counter() - started) * 1000) return { "ip_address": ip_address, "port": port, "detected": False, "response_ms": elapsed_ms, "status": "timeout", "error_message": "Connection timed out", } except OSError as exc: elapsed_ms = int((time.perf_counter() - started) * 1000) return { "ip_address": ip_address, "port": port, "detected": False, "response_ms": elapsed_ms, "status": "no_response", "error_message": str(exc), } @app.post("/api/scan-ranges/{scan_range_id}/scan-now") def scan_range_now(scan_range_id: str, request: ScanNowRequest, current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() conn.autocommit = False with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id::text, room_id::text, name, range_type, cidr, host(start_ip) AS start_ip, host(end_ip) AS end_ip, enabled, timeout_ms, retry_limit, parallelism_limit, stagger_ms, api_port, scan_interval_seconds FROM scan_ranges WHERE id = %s; """, (scan_range_id,), ) scan_range = cur.fetchone() if not scan_range: raise HTTPException(status_code=404, detail="Scan range not found") if not scan_range["enabled"]: raise HTTPException(status_code=409, detail="Scan range is disabled") ip_list = expand_scan_range_for_scan(dict(scan_range), request.max_ips, request.specific_ips) cur.execute( """ INSERT INTO scan_runs ( scan_range_id, triggered_by, triggered_by_user_id, status, total_ips, scanned_ips, miners_found, errors_count, notes ) VALUES (%s, 'manual', %s, 'running', %s, 0, 0, 0, %s) RETURNING id::text; """, ( scan_range_id, current_user["id"], len(ip_list), "TCP port 4028 reachability scan only", ), ) scan_run_id = cur.fetchone()["id"] cur.execute( """ INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json) VALUES (%s, 'scan.started', 'scan_run', %s, %s); """, ( current_user["id"], scan_run_id, psycopg.types.json.Jsonb( { "scan_range_id": scan_range_id, "scan_range_name": scan_range["name"], "total_ips": len(ip_list), "mode": "tcp_4028_only", } ), ), ) conn.commit() results = [] detected_count = 0 errors_count = 0 max_workers = min(scan_range["parallelism_limit"], len(ip_list)) if max_workers < 1: max_workers = 1 with ThreadPoolExecutor(max_workers=max_workers) as executor: future_map = { executor.submit( tcp_check_ip, ip, scan_range["api_port"], scan_range["timeout_ms"], ): ip for ip in ip_list } for future in as_completed(future_map): result = future.result() results.append(result) if result["detected"]: detected_count += 1 if result["status"] not in ("port_open", "timeout", "no_response"): errors_count += 1 results = sorted(results, key=lambda item: ipaddress.ip_address(item["ip_address"])) conn = get_db_connection() conn.autocommit = False with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: for result in results: cur.execute( """ INSERT INTO scan_results ( scan_run_id, ip_address, port, detected, response_ms, status, error_message ) VALUES (%s, %s, %s, %s, %s, %s, %s); """, ( scan_run_id, result["ip_address"], result["port"], result["detected"], result["response_ms"], result["status"], result["error_message"], ), ) final_status = "completed" if errors_count > 0: final_status = "completed_with_errors" cur.execute( """ UPDATE scan_runs SET finished_at = now(), status = %s, scanned_ips = %s, miners_found = %s, errors_count = %s WHERE id = %s RETURNING id::text, scan_range_id::text, triggered_by, started_at, finished_at, status, total_ips, scanned_ips, miners_found, errors_count, notes; """, ( final_status, len(results), detected_count, errors_count, scan_run_id, ), ) scan_run = cur.fetchone() cur.execute( """ UPDATE scan_ranges SET last_scan_at = now(), next_scan_at = now() + make_interval(secs => scan_interval_seconds), updated_at = now() WHERE id = %s; """, (scan_range_id,), ) cur.execute( """ INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json) VALUES (%s, 'scan.completed', 'scan_run', %s, %s); """, ( current_user["id"], scan_run_id, psycopg.types.json.Jsonb( { "scan_range_id": scan_range_id, "total_ips": len(ip_list), "scanned_ips": len(results), "port_4028_open": detected_count, "errors_count": errors_count, } ), ), ) conn.commit() return api_response( data={ "scan_run": dict(scan_run), "summary": { "scan_range_id": scan_range_id, "scan_range_name": scan_range["name"], "total_ips": len(ip_list), "scanned_ips": len(results), "port_4028_open": detected_count, "errors_count": errors_count, }, } ) except HTTPException: if conn is not None: conn.rollback() raise except Exception as exc: if conn is not None: conn.rollback() raise HTTPException(status_code=500, detail=str(exc)) finally: if conn is not None: conn.close() class WhatsminerReadTestRequest(BaseModel): ip_address: str port: int = Field(default=4028, ge=1, le=65535) command: str = Field(default="summary", min_length=1, max_length=80) params: dict[str, Any] | None = None timeout_ms: int = Field(default=5000, ge=500, le=30000) def read_from_socket_until_closed(sock: socket.socket, max_bytes: int = 1048576) -> bytes: chunks = [] total = 0 while True: chunk = sock.recv(65536) if not chunk: break chunks.append(chunk) total += len(chunk) if total >= max_bytes: break return b"".join(chunks) def send_whatsminer_read_command(ip_address: str, port: int, command: str, timeout_ms: int, params: dict[str, Any] | None = None) -> dict: try: ipaddress.ip_address(ip_address) except ValueError as exc: raise HTTPException(status_code=422, detail=f"Invalid IP address: {exc}") import json started = time.perf_counter() params = params or {} command_payload = {"command": command} command_payload.update(params) cmd_payload = {"cmd": command} cmd_payload.update(params) command_payloads = [ { "name": "json_command_plain", "payload": json.dumps(command_payload, separators=(",", ":")).encode("utf-8"), }, { "name": "json_command_newline", "payload": (json.dumps(command_payload, separators=(",", ":")) + "\n").encode("utf-8"), }, { "name": "json_cmd_plain", "payload": json.dumps(cmd_payload, separators=(",", ":")).encode("utf-8"), }, { "name": "json_cmd_newline", "payload": (json.dumps(cmd_payload, separators=(",", ":")) + "\n").encode("utf-8"), }, { "name": "plain_command", "payload": command.encode("utf-8"), }, { "name": "plain_command_newline", "payload": (command + "\n").encode("utf-8"), }, ] attempts = [] for command_payload in command_payloads: payload_name = command_payload["name"] payload = command_payload["payload"] try: with socket.create_connection((ip_address, port), timeout=timeout_ms / 1000) as sock: sock.settimeout(timeout_ms / 1000) sock.sendall(payload) raw_response = read_from_socket_until_closed(sock) elapsed_ms = int((time.perf_counter() - started) * 1000) response_text = raw_response.decode("utf-8", errors="replace").strip() attempts.append( { "payload_type": payload_name, "bytes_received": len(raw_response), "response_preview": response_text[:1000], } ) if response_text: parsed_json = None parse_error = None try: parsed_json = json.loads(response_text) except Exception as exc: parse_error = str(exc) return { "success": True, "ip_address": ip_address, "port": port, "command": command, "payload_type": payload_name, "response_ms": elapsed_ms, "bytes_received": len(raw_response), "response_text": response_text, "parsed_json": parsed_json, "parse_error": parse_error, "attempts": attempts, } except Exception as exc: attempts.append( { "payload_type": payload_name, "bytes_received": 0, "error": str(exc), } ) elapsed_ms = int((time.perf_counter() - started) * 1000) return { "success": False, "ip_address": ip_address, "port": port, "command": command, "response_ms": elapsed_ms, "attempts": attempts, } @app.post("/api/tools/whatsminer-read-test") def whatsminer_read_test(request: WhatsminerReadTestRequest, current_user: dict = Depends(get_current_user)): result = send_whatsminer_read_command( ip_address=request.ip_address, port=request.port, command=request.command, timeout_ms=request.timeout_ms, params=request.params, ) return api_response(data=result) @app.get("/api/miners") def list_miners(current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT miners.id::text, miners.site_id::text, sites.name AS site_name, miners.room_id::text, rooms.name AS room_name, miners.asset_tag, miners.current_mac, host(miners.current_ip) AS current_ip, miners.hostname, miners.miner_sn, miners.power_sn, miners.model, miners.miner_type, miners.api_version, miners.firmware_version, miners.platform, miners.chip, miners.status, miners.first_seen_at, miners.last_seen_at, miners.updated_at FROM miners LEFT JOIN sites ON sites.id = miners.site_id LEFT JOIN rooms ON rooms.id = miners.room_id ORDER BY rooms.sort_order, miners.current_ip, miners.current_mac; """ ) miners = [dict(row) for row in cur.fetchall()] return api_response(data=miners) finally: if conn is not None: conn.close() @app.get("/api/miners/{miner_id}") def get_miner(miner_id: str, current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT miners.id::text, miners.site_id::text, sites.name AS site_name, miners.room_id::text, rooms.name AS room_name, miners.asset_tag, miners.current_mac, host(miners.current_ip) AS current_ip, miners.hostname, miners.miner_sn, miners.power_sn, miners.model, miners.miner_type, miners.api_version, miners.firmware_version, miners.platform, miners.chip, miners.status, miners.notes, miners.first_seen_at, miners.last_seen_at, miners.created_at, miners.updated_at FROM miners LEFT JOIN sites ON sites.id = miners.site_id LEFT JOIN rooms ON rooms.id = miners.room_id WHERE miners.id = %s; """, (miner_id,), ) miner = cur.fetchone() if not miner: raise HTTPException(status_code=404, detail="Miner not found") return api_response(data=dict(miner)) finally: if conn is not None: conn.close() @app.get("/api/miners/{miner_id}/network-identities") def list_miner_network_identities(miner_id: str, current_user: dict = Depends(require_permission("miners.view"))): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id::text, miner_id::text, mac_address, host(ip_address) AS ip_address, hostname, source, is_current, first_seen_at, last_seen_at, created_at, updated_at FROM miner_network_identities WHERE miner_id = %s ORDER BY is_current DESC, last_seen_at DESC; """, (miner_id,), ) identities = [dict(row) for row in cur.fetchall()] return api_response(data=identities) finally: if conn is not None: conn.close() @app.get("/api/miners/{miner_id}/hashboards") def list_miner_hashboards(miner_id: str, current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id::text, miner_id::text, slot, pcb_sn, chip_data, temperature_c, chip_frequency, mhs_av, mhs_1m, mhs_15m, hs_rt, factory_ghs, effective_chips, upfreq_complete, first_seen_at, last_seen_at, updated_at FROM miner_hashboards WHERE miner_id = %s ORDER BY slot; """, (miner_id,), ) hashboards = [dict(row) for row in cur.fetchall()] return api_response(data=hashboards) finally: if conn is not None: conn.close() @app.get("/api/miners/{miner_id}/status-snapshots") def list_miner_status_snapshots( miner_id: str, current_user: dict = Depends(require_permission("miners.view")), ): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id::text, miner_id::text, host(observed_ip) AS observed_ip, observed_at, mhs_av, mhs_1m, mhs_15m, hs_rt, power_w, power_rate_w_per_th, env_temp_c, chip_temp_min_c, chip_temp_max_c, chip_temp_avg_c, fan_speed_in_rpm, fan_speed_out_rpm, uptime_seconds, hash_stable, pool_rejected_percent, COALESCE( raw_summary_json #>> '{Msg,Power Mode}', raw_summary_json #>> '{MSG,Power Mode}', raw_summary_json #>> '{msg,Power Mode}', raw_summary_json ->> 'Power Mode', raw_summary_json ->> 'PowerMode', raw_summary_json ->> 'power_mode' ) AS performance_mode, raw_summary_json, created_at FROM miner_status_snapshots WHERE miner_id = %s ORDER BY observed_at DESC LIMIT 100; """, (miner_id,), ) snapshots = [dict(row) for row in cur.fetchall()] return api_response(data=snapshots) finally: if conn is not None: conn.close() @app.get("/api/miners/{miner_id}/configured-performance-mode") def get_miner_configured_performance_mode(miner_id: str, current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ WITH latest AS ( SELECT COALESCE( raw_summary_json #>> '{Msg,Power Mode}', raw_summary_json #>> '{MSG,Power Mode}', raw_summary_json #>> '{msg,Power Mode}', raw_summary_json ->> 'Power Mode', raw_summary_json ->> 'PowerMode', raw_summary_json ->> 'power_mode' ) AS runtime_power_state, created_at AS runtime_power_state_at FROM miner_status_snapshots WHERE miner_id = %s ORDER BY observed_at DESC LIMIT 1 ) SELECT miners.id::text AS miner_id, INITCAP(miners.configured_performance_mode) AS configured_performance_mode, miners.configured_performance_mode_source, miners.configured_performance_mode_updated_at, latest.runtime_power_state, latest.runtime_power_state_at FROM miners LEFT JOIN latest ON true WHERE miners.id = %s; """, (miner_id, miner_id), ) row = cur.fetchone() if row is None: raise HTTPException(status_code=404, detail="Miner not found.") return api_response(data=dict(row)) finally: if conn is not None: conn.close() class DiscoverOneMinerRequest(BaseModel): ip_address: str port: int = Field(default=4028, ge=1, le=65535) scan_range_id: str | None = None timeout_ms: int = Field(default=5000, ge=500, le=30000) def clean_ip_text(value: Any) -> str | None: if value is None: return None return str(value).split("/")[0] def to_bool_or_none(value: Any) -> bool | None: if value is None: return None if isinstance(value, bool): return value text = str(value).strip().lower() if text in ("true", "1", "yes", "y", "on"): return True if text in ("false", "0", "no", "n", "off"): return False return None def get_msg_dict(result: dict, command_name: str) -> dict: if not result.get("success"): raise HTTPException(status_code=502, detail=f"{command_name} failed: {result}") parsed_json = result.get("parsed_json") if not isinstance(parsed_json, dict): raise HTTPException(status_code=502, detail=f"{command_name} did not return JSON") if parsed_json.get("STATUS") == "E": raise HTTPException(status_code=502, detail=f"{command_name} returned error: {parsed_json}") msg = parsed_json.get("Msg", {}) if isinstance(msg, dict): return msg return {} def find_scan_range_context(cur, ip_address: str, scan_range_id: str | None) -> dict: if scan_range_id: cur.execute( """ SELECT scan_ranges.id::text AS scan_range_id, scan_ranges.room_id::text, rooms.site_id::text, rooms.name AS room_name, scan_ranges.name AS scan_range_name FROM scan_ranges LEFT JOIN rooms ON rooms.id = scan_ranges.room_id WHERE scan_ranges.id = %s; """, (scan_range_id,), ) row = cur.fetchone() if row: return dict(row) cur.execute( """ SELECT scan_ranges.id::text AS scan_range_id, scan_ranges.room_id::text, rooms.site_id::text, rooms.name AS room_name, scan_ranges.name AS scan_range_name, scan_ranges.range_type, scan_ranges.cidr, host(scan_ranges.start_ip) AS start_ip, scan_ranges.end_ip::text FROM scan_ranges LEFT JOIN rooms ON rooms.id = scan_ranges.room_id WHERE scan_ranges.enabled = true; """ ) ip_obj = ipaddress.ip_address(ip_address) for row in cur.fetchall(): row_dict = dict(row) if row_dict["range_type"] == "cidr" and row_dict["cidr"]: network = ipaddress.ip_network(row_dict["cidr"], strict=False) if ip_obj in network: return row_dict if row_dict["range_type"] == "start_end" and row_dict["start_ip"] and row_dict["end_ip"]: start_ip = ipaddress.ip_address(clean_ip_text(row_dict["start_ip"])) end_ip = ipaddress.ip_address(clean_ip_text(row_dict["end_ip"])) if int(start_ip) <= int(ip_obj) <= int(end_ip): return row_dict return { "scan_range_id": scan_range_id, "room_id": None, "site_id": None, "room_name": None, "scan_range_name": None, } def find_existing_miner(cur, miner_sn: str | None, mac_address: str | None, ip_address: str) -> dict | None: if miner_sn: cur.execute( """ SELECT id::text FROM miners WHERE miner_sn = %s LIMIT 1; """, (miner_sn,), ) row = cur.fetchone() if row: return dict(row) if mac_address: cur.execute( """ SELECT id::text FROM miners WHERE lower(current_mac) = lower(%s) LIMIT 1; """, (mac_address,), ) row = cur.fetchone() if row: return dict(row) cur.execute( """ SELECT id::text FROM miners WHERE current_ip = %s LIMIT 1; """, (ip_address,), ) row = cur.fetchone() if row: return dict(row) return None @app.post("/api/miners/discover-one") def discover_one_miner(request: DiscoverOneMinerRequest, current_user: dict = Depends(get_current_user)): try: ip_address = str(ipaddress.ip_address(request.ip_address)) except ValueError as exc: raise HTTPException(status_code=422, detail=f"Invalid IP address: {exc}") miner_info_result = send_whatsminer_read_command( ip_address=ip_address, port=request.port, command="get_miner_info", timeout_ms=request.timeout_ms, params={ "info": "ip,proto,netmask,gateway,dns,hostname,mac,ledstat,minersn,powersn" }, ) version_result = send_whatsminer_read_command( ip_address=ip_address, port=request.port, command="get_version", timeout_ms=request.timeout_ms, ) summary_result = send_whatsminer_read_command( ip_address=ip_address, port=request.port, command="summary", timeout_ms=request.timeout_ms, ) edevs_result = send_whatsminer_read_command( ip_address=ip_address, port=request.port, command="edevs", timeout_ms=request.timeout_ms, ) pools_result = send_whatsminer_read_command( ip_address=ip_address, port=request.port, command="pools", timeout_ms=request.timeout_ms, ) miner_info_json = miner_info_result.get("parsed_json") or {} version_json = version_result.get("parsed_json") or {} summary_json = summary_result.get("parsed_json") or {} edevs_json = edevs_result.get("parsed_json") or {} pools_json = pools_result.get("parsed_json") or {} miner_info_msg = get_msg_dict(miner_info_result, "get_miner_info") version_msg = get_msg_dict(version_result, "get_version") try: summary_msg = get_msg_dict(summary_result, "summary") summary_warning = None except HTTPException as exc: summary_msg = {} summary_warning = exc.detail mac_address = miner_info_msg.get("mac") if mac_address: mac_address = str(mac_address).upper() hostname = miner_info_msg.get("hostname") miner_sn = miner_info_msg.get("minersn") power_sn = miner_info_msg.get("powersn") miner_type = version_msg.get("miner_type") model = miner_type.split("_")[0] if miner_type else None conn = None try: conn = get_db_connection() conn.autocommit = False with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: context = find_scan_range_context(cur, ip_address, request.scan_range_id) existing_miner = find_existing_miner( cur=cur, miner_sn=miner_sn, mac_address=mac_address, ip_address=ip_address, ) if existing_miner: miner_id = existing_miner["id"] cur.execute( """ UPDATE miners SET site_id = COALESCE(%s, site_id), room_id = COALESCE(%s, room_id), current_mac = %s, current_ip = %s, hostname = %s, miner_sn = %s, power_sn = %s, model = %s, miner_type = %s, api_version = %s, firmware_version = %s, platform = %s, chip = %s, status = 'online', last_seen_at = now(), updated_at = now() WHERE id = %s RETURNING id::text; """, ( context.get("site_id"), context.get("room_id"), mac_address, ip_address, hostname, miner_sn, power_sn, model, miner_type, version_msg.get("api_ver"), version_msg.get("fw_ver"), version_msg.get("platform"), version_msg.get("chip"), miner_id, ), ) miner_id = cur.fetchone()["id"] action = "miner.updated" else: cur.execute( """ INSERT INTO miners ( site_id, room_id, current_mac, current_ip, hostname, miner_sn, power_sn, model, miner_type, api_version, firmware_version, platform, chip, status ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'online') RETURNING id::text; """, ( context.get("site_id"), context.get("room_id"), mac_address, ip_address, hostname, miner_sn, power_sn, model, miner_type, version_msg.get("api_ver"), version_msg.get("fw_ver"), version_msg.get("platform"), version_msg.get("chip"), ), ) miner_id = cur.fetchone()["id"] action = "miner.created" if mac_address: cur.execute( """ UPDATE miner_network_identities SET is_current = false, updated_at = now() WHERE miner_id = %s; """, (miner_id,), ) cur.execute( """ SELECT id::text FROM miner_network_identities WHERE miner_id = %s AND lower(mac_address) = lower(%s) LIMIT 1; """, (miner_id, mac_address), ) identity = cur.fetchone() if identity: cur.execute( """ UPDATE miner_network_identities SET ip_address = %s, hostname = %s, source = 'scanner', is_current = true, last_seen_at = now(), updated_at = now() WHERE id = %s; """, (ip_address, hostname, identity["id"]), ) else: cur.execute( """ INSERT INTO miner_network_identities ( miner_id, mac_address, ip_address, hostname, source, is_current ) VALUES (%s, %s, %s, %s, 'scanner', true); """, (miner_id, mac_address, ip_address, hostname), ) for board in edevs_json.get("DEVS", []): slot = board.get("Slot") if slot is None: continue cur.execute( """ INSERT INTO miner_hashboards ( miner_id, slot, pcb_sn, chip_data, temperature_c, chip_frequency, mhs_av, mhs_1m, mhs_15m, hs_rt, factory_ghs, effective_chips, upfreq_complete, raw_json ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT ON CONSTRAINT uq_miner_hashboards_miner_slot DO UPDATE SET pcb_sn = EXCLUDED.pcb_sn, chip_data = EXCLUDED.chip_data, temperature_c = EXCLUDED.temperature_c, chip_frequency = EXCLUDED.chip_frequency, mhs_av = EXCLUDED.mhs_av, mhs_1m = EXCLUDED.mhs_1m, mhs_15m = EXCLUDED.mhs_15m, hs_rt = EXCLUDED.hs_rt, factory_ghs = EXCLUDED.factory_ghs, effective_chips = EXCLUDED.effective_chips, upfreq_complete = EXCLUDED.upfreq_complete, raw_json = EXCLUDED.raw_json, last_seen_at = now(), updated_at = now(); """, ( miner_id, int(slot), board.get("PCB SN"), board.get("Chip Data"), board.get("Temperature"), board.get("Chip Frequency"), board.get("MHS av"), board.get("MHS 1m"), board.get("MHS 15m"), board.get("HS RT"), board.get("Factory GHS"), board.get("Effective Chips"), board.get("Upfreq Complete"), psycopg.types.json.Jsonb(json_safe(board)), ), ) cur.execute( """ INSERT INTO miner_status_snapshots ( miner_id, observed_ip, mhs_av, mhs_1m, mhs_15m, hs_rt, power_w, power_rate_w_per_th, env_temp_c, chip_temp_min_c, chip_temp_max_c, chip_temp_avg_c, fan_speed_in_rpm, fan_speed_out_rpm, uptime_seconds, hash_stable, pool_rejected_percent, raw_summary_json, raw_version_json, raw_miner_info_json ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s); """, ( miner_id, ip_address, summary_msg.get("MHS av"), summary_msg.get("MHS 1m"), summary_msg.get("MHS 15m"), summary_msg.get("HS RT"), summary_msg.get("Power"), summary_msg.get("Power Rate"), summary_msg.get("Env Temp"), summary_msg.get("Chip Temp Min"), summary_msg.get("Chip Temp Max"), summary_msg.get("Chip Temp Avg"), summary_msg.get("Fan Speed In"), summary_msg.get("Fan Speed Out"), summary_msg.get("Uptime"), to_bool_or_none(summary_msg.get("Hash Stable")), summary_msg.get("Pool Rejected%"), psycopg.types.json.Jsonb(json_safe(summary_json)), psycopg.types.json.Jsonb(json_safe(version_json)), psycopg.types.json.Jsonb(json_safe(miner_info_json)), ), ) cur.execute( """ INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json) VALUES (%s, %s, 'miner', %s, %s); """, ( current_user["id"], action, miner_id, psycopg.types.json.Jsonb( { "ip_address": ip_address, "mac_address": mac_address, "miner_sn": miner_sn, "power_sn": power_sn, "miner_type": miner_type, "firmware_version": version_msg.get("fw_ver"), "room_id": context.get("room_id"), "scan_range_id": context.get("scan_range_id"), } ), ), ) cur.execute( """ SELECT miners.id::text, miners.site_id::text, sites.name AS site_name, miners.room_id::text, rooms.name AS room_name, miners.current_mac, host(miners.current_ip) AS current_ip, miners.hostname, miners.miner_sn, miners.power_sn, miners.model, miners.miner_type, miners.api_version, miners.firmware_version, miners.platform, miners.chip, miners.status, miners.first_seen_at, miners.last_seen_at, miners.updated_at FROM miners LEFT JOIN sites ON sites.id = miners.site_id LEFT JOIN rooms ON rooms.id = miners.room_id WHERE miners.id = %s; """, (miner_id,), ) miner = dict(cur.fetchone()) conn.commit() return api_response( data={ "action": action, "miner": miner, "summary": { "ip_address": ip_address, "mac_address": mac_address, "miner_sn": miner_sn, "power_sn": power_sn, "miner_type": miner_type, "firmware_version": version_msg.get("fw_ver"), "hashboards_seen": len(edevs_json.get("DEVS", [])), "pools_seen": len(pools_json.get("POOLS", [])), "hashrate_mhs_av": summary_msg.get("MHS av"), "power_w": summary_msg.get("Power"), "chip_temp_max_c": summary_msg.get("Chip Temp Max"), "summary_warning": summary_warning, }, } ) except HTTPException: if conn is not None: conn.rollback() raise except Exception as exc: if conn is not None: conn.rollback() raise HTTPException(status_code=500, detail=str(exc)) finally: if conn is not None: conn.close() @app.post("/api/scan-ranges/{scan_range_id}/discover-now") def discover_scan_range_now(scan_range_id: str, request: ScanNowRequest, current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() conn.autocommit = False with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id::text, room_id::text, name, range_type, cidr, host(start_ip) AS start_ip, host(end_ip) AS end_ip, enabled, timeout_ms, retry_limit, parallelism_limit, stagger_ms, api_port, scan_interval_seconds FROM scan_ranges WHERE id = %s; """, (scan_range_id,), ) scan_range = cur.fetchone() if not scan_range: raise HTTPException(status_code=404, detail="Scan range not found") if not scan_range["enabled"]: raise HTTPException(status_code=409, detail="Scan range is disabled") ip_list = expand_scan_range_for_scan(dict(scan_range), request.max_ips, request.specific_ips) cur.execute( """ INSERT INTO scan_runs ( scan_range_id, triggered_by, triggered_by_user_id, status, total_ips, scanned_ips, miners_found, errors_count, notes ) VALUES (%s, 'manual', %s, 'running', %s, 0, 0, 0, %s) RETURNING id::text; """, ( scan_range_id, current_user["id"], len(ip_list), "TCP port 4028 scan with read-only miner discovery", ), ) scan_run_id = cur.fetchone()["id"] cur.execute( """ INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json) VALUES (%s, 'scan.discovery.started', 'scan_run', %s, %s); """, ( current_user["id"], scan_run_id, psycopg.types.json.Jsonb( { "scan_range_id": scan_range_id, "scan_range_name": scan_range["name"], "total_ips": len(ip_list), "mode": "tcp_4028_then_whatsminer_read_only_discovery", } ), ), ) conn.commit() except HTTPException: if conn is not None: conn.rollback() raise except Exception as exc: if conn is not None: conn.rollback() raise HTTPException(status_code=500, detail=str(exc)) finally: if conn is not None: conn.close() results = [] detected_count = 0 max_workers = min(scan_range["parallelism_limit"], len(ip_list)) if max_workers < 1: max_workers = 1 with ThreadPoolExecutor(max_workers=max_workers) as executor: future_map = { executor.submit( tcp_check_ip, ip, scan_range["api_port"], scan_range["timeout_ms"], ): ip for ip in ip_list } for future in as_completed(future_map): result = future.result() results.append(result) if result["detected"]: detected_count += 1 results = sorted(results, key=lambda item: ipaddress.ip_address(item["ip_address"])) conn = None try: conn = get_db_connection() conn.autocommit = False with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: for result in results: cur.execute( """ INSERT INTO scan_results ( scan_run_id, ip_address, port, detected, response_ms, status, error_message ) VALUES (%s, %s, %s, %s, %s, %s, %s); """, ( scan_run_id, result["ip_address"], result["port"], result["detected"], result["response_ms"], result["status"], result["error_message"], ), ) cur.execute( """ UPDATE scan_ranges SET last_scan_at = now(), next_scan_at = now() + make_interval(secs => scan_interval_seconds), updated_at = now() WHERE id = %s; """, (scan_range_id,), ) cur.execute( """ UPDATE scan_runs SET scanned_ips = %s, miners_found = %s, errors_count = 0 WHERE id = %s; """, ( len(results), detected_count, scan_run_id, ), ) conn.commit() except Exception as exc: if conn is not None: conn.rollback() raise HTTPException(status_code=500, detail=str(exc)) finally: if conn is not None: conn.close() discoveries = [] discovery_errors = [] for result in results: if not result["detected"]: continue try: discovery_response = discover_one_miner( DiscoverOneMinerRequest( ip_address=result["ip_address"], port=result["port"], scan_range_id=scan_range_id, timeout_ms=scan_range["timeout_ms"], ), current_user=current_user, ) discoveries.append(discovery_response["data"]) except HTTPException as exc: discovery_errors.append( { "ip_address": result["ip_address"], "status_code": exc.status_code, "detail": exc.detail, } ) except Exception as exc: discovery_errors.append( { "ip_address": result["ip_address"], "status_code": 500, "detail": str(exc), } ) final_status = "completed" if discovery_errors: final_status = "completed_with_errors" conn = None try: conn = get_db_connection() conn.autocommit = False with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ UPDATE scan_runs SET finished_at = now(), status = %s, miners_found = %s, errors_count = %s, notes = %s WHERE id = %s RETURNING id::text, scan_range_id::text, triggered_by, started_at, finished_at, status, total_ips, scanned_ips, miners_found, errors_count, notes; """, ( final_status, len(discoveries), len(discovery_errors), "TCP port 4028 scan with read-only miner discovery", scan_run_id, ), ) scan_run = cur.fetchone() cur.execute( """ INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json) VALUES (%s, 'scan.discovery.completed', 'scan_run', %s, %s); """, ( current_user["id"], scan_run_id, psycopg.types.json.Jsonb( { "scan_range_id": scan_range_id, "total_ips": len(ip_list), "scanned_ips": len(results), "port_4028_open": detected_count, "miners_discovered": len(discoveries), "discovery_errors": len(discovery_errors), } ), ), ) conn.commit() except Exception as exc: if conn is not None: conn.rollback() raise HTTPException(status_code=500, detail=str(exc)) finally: if conn is not None: conn.close() return api_response( data={ "scan_run": dict(scan_run), "summary": { "scan_range_id": scan_range_id, "scan_range_name": scan_range["name"], "total_ips": len(ip_list), "scanned_ips": len(results), "port_4028_open": detected_count, "miners_discovered": len(discoveries), "discovery_errors": len(discovery_errors), }, "discoveries": discoveries, "discovery_errors": discovery_errors, } ) @app.get("/api/dashboard/summary") def dashboard_summary(current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT COUNT(*) AS total_miners, COUNT(*) FILTER (WHERE status = 'online') AS online_miners, COUNT(*) FILTER (WHERE current_mac IS NULL OR current_mac = '') AS missing_mac_count, COUNT(*) FILTER (WHERE miner_type IS NULL OR miner_type = '') AS missing_miner_type_count, COUNT(*) FILTER (WHERE firmware_version IS NULL OR firmware_version = '') AS missing_firmware_count, COUNT(*) FILTER (WHERE last_seen_at >= now() - interval '30 minutes') AS seen_last_30_minutes, COUNT(*) FILTER (WHERE last_seen_at < now() - interval '30 minutes') AS not_seen_last_30_minutes FROM miners; """ ) totals = dict(cur.fetchone()) cur.execute( """ SELECT COALESCE(rooms.name, 'Unassigned') AS room_name, COUNT(miners.id) AS miner_count, COUNT(miners.id) FILTER (WHERE miners.status = 'online') AS online_count, COUNT(miners.id) FILTER (WHERE miners.last_seen_at >= now() - interval '30 minutes') AS seen_last_30_minutes FROM miners LEFT JOIN rooms ON rooms.id = miners.room_id GROUP BY rooms.name, rooms.sort_order ORDER BY rooms.sort_order, rooms.name; """ ) by_room = [dict(row) for row in cur.fetchall()] cur.execute( """ SELECT COALESCE(NULLIF(miner_type, ''), NULLIF(model, ''), 'Unknown') AS model, COUNT(*) AS miner_count FROM miners GROUP BY COALESCE(NULLIF(miner_type, ''), NULLIF(model, ''), 'Unknown') ORDER BY miner_count DESC, model; """ ) by_model = [dict(row) for row in cur.fetchall()] cur.execute( """ SELECT COALESCE(NULLIF(firmware_version, ''), 'Unknown') AS firmware_version, COUNT(*) AS miner_count FROM miners GROUP BY COALESCE(NULLIF(firmware_version, ''), 'Unknown') ORDER BY miner_count DESC, firmware_version; """ ) by_firmware = [dict(row) for row in cur.fetchall()] cur.execute( """ SELECT COALESCE(NULLIF(status, ''), 'Unknown') AS status, COUNT(*) AS miner_count FROM miners GROUP BY COALESCE(NULLIF(status, ''), 'Unknown') ORDER BY miner_count DESC, status; """ ) by_status = [dict(row) for row in cur.fetchall()] cur.execute( """ WITH latest AS ( SELECT DISTINCT ON (miner_id) miner_id, observed_at, power_w, power_rate_w_per_th, chip_temp_max_c, chip_temp_avg_c, mhs_av, hash_stable FROM miner_status_snapshots ORDER BY miner_id, observed_at DESC ) SELECT COUNT(*) AS miners_with_snapshots, ROUND(AVG(power_w)::numeric, 2)::float AS avg_power_w, ROUND(SUM(power_w)::numeric, 2)::float AS total_power_w, ROUND(AVG(power_rate_w_per_th)::numeric, 2)::float AS avg_power_rate_w_per_th, ROUND(MAX(chip_temp_max_c)::numeric, 2)::float AS hottest_chip_temp_c, ROUND(AVG(chip_temp_avg_c)::numeric, 2)::float AS avg_chip_temp_c, ROUND(SUM(mhs_av)::numeric / 1000000, 2)::float AS total_hashrate_ths, COUNT(*) FILTER (WHERE chip_temp_max_c >= 90) AS miners_chip_temp_90c_or_higher, COUNT(*) FILTER (WHERE hash_stable = false) AS miners_hash_unstable FROM latest; """ ) telemetry = dict(cur.fetchone()) cur.execute( """ SELECT COUNT(DISTINCT miner_id) AS miners_with_hashboards, COUNT(*) AS total_hashboards, COUNT(*) FILTER (WHERE temperature_c >= 90) AS hashboards_90c_or_higher, COUNT(*) FILTER (WHERE pcb_sn IS NULL OR pcb_sn = '') AS hashboards_missing_pcb_sn FROM miner_hashboards; """ ) hashboards = dict(cur.fetchone()) return api_response( data={ "totals": totals, "by_room": by_room, "by_model": by_model, "by_firmware": by_firmware, "by_status": by_status, "telemetry": telemetry, "hashboards": hashboards, } ) finally: if conn is not None: conn.close() from fastapi import File, Query, UploadFile import csv import io def csv_value(row: dict, possible_names: list[str]) -> str | None: for name in possible_names: if name in row and row[name] is not None: value = str(row[name]).strip() if value: return value return None def normalize_mac_for_match(mac: str | None) -> str | None: if not mac: return None cleaned = "".join(ch for ch in mac.lower() if ch in "0123456789abcdef") if len(cleaned) != 12: return None return cleaned def model_from_miner_type(miner_type: str | None) -> str | None: if not miner_type: return None if "_" in miner_type: return miner_type.split("_", 1)[0] return miner_type @app.post("/api/imports/whatsminer-tool-scan") async def import_whatsminer_tool_scan( dry_run: bool = Query(default=True), update_existing: bool = Query(default=False), file: UploadFile = File(...), current_user: dict = Depends(get_current_user), ): file_bytes = await file.read() try: csv_text = file_bytes.decode("utf-8-sig") except UnicodeDecodeError: csv_text = file_bytes.decode("latin-1") reader = csv.DictReader(io.StringIO(csv_text)) rows = list(reader) if not rows: raise HTTPException(status_code=422, detail="CSV file has no rows") matched_count = 0 unmatched_count = 0 rows_with_updates = 0 updates_applied = 0 skipped_no_useful_data = 0 update_samples = [] unmatched_samples = [] conn = None try: conn = get_db_connection() conn.autocommit = False with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: for row_number, row in enumerate(rows, start=2): ip_address = csv_value(row, ["IP", "IP Address", "ip", "ip_address"]) mac_address = csv_value(row, ["MAC Addr", "MAC", "Mac", "mac", "mac_address"]) miner_type = csv_value(row, ["Miner Type", "MinerType", "Model", "model", "miner_type"]) firmware_version = csv_value(row, ["Firmware", "Firmware Version", "FW Version", "fw_ver", "firmware_version"]) if ip_address: ip_address = clean_ip_text(ip_address) normalized_mac = normalize_mac_for_match(mac_address) model = model_from_miner_type(miner_type) if not ip_address and not normalized_mac: skipped_no_useful_data += 1 continue miner = None if normalized_mac: cur.execute( """ SELECT id::text, host(current_ip) AS current_ip, current_mac, model, miner_type, firmware_version FROM miners WHERE regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g') = %s LIMIT 1; """, (normalized_mac,), ) miner = cur.fetchone() if not miner and ip_address: cur.execute( """ SELECT id::text, host(current_ip) AS current_ip, current_mac, model, miner_type, firmware_version FROM miners WHERE host(current_ip) = %s LIMIT 1; """, (ip_address,), ) miner = cur.fetchone() if not miner: unmatched_count += 1 if len(unmatched_samples) < 20: unmatched_samples.append( { "row_number": row_number, "ip_address": ip_address, "mac_address": mac_address, "miner_type": miner_type, "firmware_version": firmware_version, } ) continue matched_count += 1 set_clauses = [] params = [] proposed_changes = {} if miner_type and (update_existing or not miner["miner_type"]): set_clauses.append("miner_type = %s") params.append(miner_type) proposed_changes["miner_type"] = { "old": miner["miner_type"], "new": miner_type, } if model and (update_existing or not miner["model"]): set_clauses.append("model = %s") params.append(model) proposed_changes["model"] = { "old": miner["model"], "new": model, } if firmware_version and (update_existing or not miner["firmware_version"]): set_clauses.append("firmware_version = %s") params.append(firmware_version) proposed_changes["firmware_version"] = { "old": miner["firmware_version"], "new": firmware_version, } if not set_clauses: continue rows_with_updates += 1 if len(update_samples) < 50: update_samples.append( { "row_number": row_number, "miner_id": miner["id"], "ip_address": miner["current_ip"], "current_mac": miner["current_mac"], "changes": proposed_changes, } ) if not dry_run: set_sql = ", ".join(set_clauses) params.append(miner["id"]) cur.execute( f""" UPDATE miners SET {set_sql}, updated_at = now() WHERE id = %s; """, tuple(params), ) updates_applied += 1 if not dry_run: cur.execute( """ INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json) VALUES (%s, 'import.whatsminertool_csv.applied', 'import', NULL, %s); """, ( current_user["id"], psycopg.types.json.Jsonb( { "filename": file.filename, "rows_read": len(rows), "matched_count": matched_count, "unmatched_count": unmatched_count, "rows_with_updates": rows_with_updates, "updates_applied": updates_applied, "update_existing": update_existing, } ), ), ) if dry_run: conn.rollback() else: conn.commit() return api_response( data={ "filename": file.filename, "dry_run": dry_run, "update_existing": update_existing, "rows_read": len(rows), "matched_count": matched_count, "unmatched_count": unmatched_count, "skipped_no_useful_data": skipped_no_useful_data, "rows_with_updates": rows_with_updates, "updates_applied": updates_applied, "update_samples": update_samples, "unmatched_samples": unmatched_samples, } ) except HTTPException: if conn is not None: conn.rollback() raise except Exception as exc: if conn is not None: conn.rollback() raise HTTPException(status_code=500, detail=str(exc)) finally: if conn is not None: conn.close() from fastapi.responses import HTMLResponse, Response @app.get("/tools/whatsminer-import", response_class=HTMLResponse) def whatsminer_import_tool_page(): return """ MinerOps - WhatsMinerTool CSV Import
New miner WebAPI reminder: Before importing or adding new WhatsMiners, enable WebAPI/API3 in WhatsMinerTool. Reboot, Suspend, Resume, and password changes need WebAPI enabled.

MinerOps - WhatsMinerTool CSV Import

1. Login

Not logged in.

2. Upload CSV

Choose a CSV and preview first.

3. Preview Summary

Rows Read
-
Matched
-
Unmatched
-
Rows With Updates
-
Updates Applied
-

Raw Result

No result yet.
""" class ScheduledBulkActionCreateRequest(BaseModel): name: str = "" target_type: str = "unit" target_value: str = "" action: str = "performance_mode" performance_mode: str = "" repeat_rule: str = "once" scheduled_for: str def minerops_ensure_scheduled_bulk_actions_table(): conn = None try: conn = get_db_connection() with conn.cursor() as cur: cur.execute( """ CREATE TABLE IF NOT EXISTS minerops_scheduled_bulk_actions ( id TEXT PRIMARY KEY, name TEXT, target_type TEXT NOT NULL, target_value TEXT, action TEXT NOT NULL DEFAULT 'performance_mode', performance_mode TEXT, repeat_rule TEXT NOT NULL DEFAULT 'once', scheduled_for TIMESTAMPTZ NOT NULL, status TEXT NOT NULL DEFAULT 'pending', agent_id TEXT NOT NULL DEFAULT 'plexus-windows-agent', requested_by_user_id TEXT, requested_by_username TEXT, queued_command_count INTEGER NOT NULL DEFAULT 0, last_queued_command_count INTEGER NOT NULL DEFAULT 0, run_count INTEGER NOT NULL DEFAULT 0, last_error TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), processed_at TIMESTAMPTZ ); ALTER TABLE minerops_scheduled_bulk_actions ALTER COLUMN performance_mode DROP NOT NULL; ALTER TABLE minerops_scheduled_bulk_actions ADD COLUMN IF NOT EXISTS repeat_rule TEXT NOT NULL DEFAULT 'once', ADD COLUMN IF NOT EXISTS last_queued_command_count INTEGER NOT NULL DEFAULT 0, ADD COLUMN IF NOT EXISTS run_count INTEGER NOT NULL DEFAULT 0; UPDATE minerops_scheduled_bulk_actions SET repeat_rule = 'once' WHERE repeat_rule IS NULL OR repeat_rule = ''; CREATE INDEX IF NOT EXISTS idx_minerops_scheduled_bulk_actions_due ON minerops_scheduled_bulk_actions(status, scheduled_for); CREATE INDEX IF NOT EXISTS idx_minerops_scheduled_bulk_actions_created ON minerops_scheduled_bulk_actions(created_at DESC); """ ) conn.commit() finally: if conn is not None: conn.close() def minerops_next_scheduled_run(current_dt, repeat_rule): import datetime as _datetime repeat_rule = (repeat_rule or "once").lower() if repeat_rule == "once": return None candidate = current_dt + _datetime.timedelta(days=1) if repeat_rule == "daily": return candidate if repeat_rule == "weekdays": while candidate.weekday() >= 5: candidate = candidate + _datetime.timedelta(days=1) return candidate if repeat_rule == "weekends": while candidate.weekday() < 5: candidate = candidate + _datetime.timedelta(days=1) return candidate return None def minerops_scheduled_action_label(action, performance_mode): action = (action or "").lower() performance_mode = (performance_mode or "").lower() if action == "performance_mode": return f"Set {performance_mode} mode" if action == "suspend": return "Suspend" if action == "resume": return "Resume" return action or "unknown" def minerops_process_due_scheduled_bulk_actions(): import json as _json import uuid as _uuid minerops_ensure_scheduled_bulk_actions_table() conn = None processed = [] try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT * FROM minerops_scheduled_bulk_actions WHERE status = 'pending' AND scheduled_for <= now() ORDER BY scheduled_for ASC LIMIT 10 FOR UPDATE SKIP LOCKED; """ ) due_actions = [dict(row) for row in cur.fetchall()] for action in due_actions: action_id = action["id"] target_type = (action.get("target_type") or "").lower() target_value = action.get("target_value") or "" command_action = (action.get("action") or "performance_mode").lower() performance_mode = (action.get("performance_mode") or "").lower() repeat_rule = (action.get("repeat_rule") or "once").lower() try: if command_action not in ("performance_mode", "suspend", "resume"): raise ValueError("Invalid scheduled action.") if command_action == "performance_mode" and performance_mode not in ("low", "normal", "high"): raise ValueError("Invalid performance mode.") cur.execute( """ UPDATE minerops_scheduled_bulk_actions SET status = 'running', updated_at = now(), last_error = NULL WHERE id = %s; """, (action_id,), ) params = [] where_extra = "" if target_type == "all": where_extra = "" elif target_type == "unit": where_extra = "AND rooms.name = %s" params.append(target_value) elif target_type == "model": where_extra = "AND (miners.miner_type = %s OR miners.model = %s)" params.extend([target_value, target_value]) elif target_type == "configured_mode": where_extra = "AND lower(COALESCE(miners.configured_performance_mode, '')) = %s" params.append(target_value.lower()) elif target_type == "selected_miners": selected_ids = [ item.strip() for item in str(target_value or "").replace("\n", ",").split(",") if item.strip() ] if not selected_ids: raise ValueError("No selected miners were saved with this schedule.") placeholders = ",".join(["%s"] * len(selected_ids)) where_extra = f"AND miners.id::text IN ({placeholders})" params.extend(selected_ids) else: raise ValueError("Invalid target type.") cur.execute( f""" SELECT miners.id::text AS miner_id, host(miners.current_ip) AS ip_address, miners.current_mac AS mac_address, rooms.name AS room_name FROM miners LEFT JOIN rooms ON rooms.id = miners.room_id WHERE miners.current_ip IS NOT NULL {where_extra} ORDER BY rooms.sort_order, miners.current_ip; """, tuple(params), ) miners = [dict(row) for row in cur.fetchall()] queued = 0 for miner in miners: payload = { "miner_id": miner["miner_id"], "action": command_action, "performance_mode": performance_mode if command_action == "performance_mode" else "", "ip_address": miner["ip_address"], "current_ip": miner["ip_address"], "mac_address": miner["mac_address"], "dry_run": False, "scheduled_action_id": action_id, "scheduled_action_name": action.get("name") or "", } cur.execute( """ INSERT INTO minerops_agent_command_queue ( id, agent_id, command_type, command_status, requested_by_user_id, requested_by_username, request_payload ) VALUES (%s, %s, 'miner_control', 'pending', %s, %s, %s::jsonb); """, ( str(_uuid.uuid4()), action.get("agent_id") or "plexus-windows-agent", action.get("requested_by_user_id"), action.get("requested_by_username") or "scheduler", _json.dumps(payload), ), ) queued += 1 next_run_at = minerops_next_scheduled_run(action["scheduled_for"], repeat_rule) if next_run_at is None: cur.execute( """ UPDATE minerops_scheduled_bulk_actions SET status = 'queued', queued_command_count = queued_command_count + %s, last_queued_command_count = %s, run_count = run_count + 1, processed_at = now(), updated_at = now(), last_error = NULL WHERE id = %s; """, (queued, queued, action_id), ) final_status = "queued" else: cur.execute( """ UPDATE minerops_scheduled_bulk_actions SET status = 'pending', scheduled_for = %s, queued_command_count = queued_command_count + %s, last_queued_command_count = %s, run_count = run_count + 1, processed_at = now(), updated_at = now(), last_error = NULL WHERE id = %s; """, (next_run_at, queued, queued, action_id), ) final_status = "pending-next-run" processed.append({"id": action_id, "queued": queued, "status": final_status}) except Exception as exc: cur.execute( """ UPDATE minerops_scheduled_bulk_actions SET status = 'failed', processed_at = now(), updated_at = now(), last_error = %s WHERE id = %s; """, (str(exc), action_id), ) processed.append({"id": action_id, "queued": 0, "status": "failed", "error": str(exc)}) conn.commit() return processed finally: if conn is not None: conn.close() app.state.agent_command_before_claim = minerops_process_due_scheduled_bulk_actions def minerops_reconcile_scheduled_bulk_action_statuses(): minerops_ensure_scheduled_bulk_actions_table() conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT s.id, COUNT(q.id) AS total_commands, COUNT(q.id) FILTER ( WHERE q.command_status NOT IN ('completed', 'failed', 'cancelled') ) AS active_commands, COUNT(q.id) FILTER (WHERE q.command_status = 'completed') AS completed_commands, COUNT(q.id) FILTER (WHERE q.command_status = 'failed') AS failed_commands, COUNT(q.id) FILTER (WHERE q.command_status = 'cancelled') AS cancelled_commands FROM minerops_scheduled_bulk_actions s LEFT JOIN minerops_agent_command_queue q ON q.request_payload ->> 'scheduled_action_id' = s.id WHERE s.status IN ('queued', 'running') GROUP BY s.id; """ ) rows = [dict(row) for row in cur.fetchall()] for row in rows: total = int(row.get("total_commands") or 0) active = int(row.get("active_commands") or 0) completed = int(row.get("completed_commands") or 0) failed = int(row.get("failed_commands") or 0) cancelled = int(row.get("cancelled_commands") or 0) if total <= 0: continue if active > 0: continue if completed == total: new_status = "completed" last_error = None else: new_status = "completed_with_errors" last_error = f"Completed with errors: {completed} completed, {failed} failed, {cancelled} cancelled." cur.execute( """ UPDATE minerops_scheduled_bulk_actions SET status = %s, processed_at = COALESCE(processed_at, now()), updated_at = now(), last_error = %s WHERE id = %s AND status IN ('queued', 'running'); """, (new_status, last_error, row["id"]), ) conn.commit() finally: if conn is not None: conn.close() class M30CommandTestRequest(BaseModel): miner_id: str test_action: str def minerops_ensure_pool_info_table(): conn = None try: conn = get_db_connection() with conn.cursor() as cur: cur.execute( """ CREATE TABLE IF NOT EXISTS miner_pool_snapshots ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), miner_id UUID, ip_address INET, mac_address TEXT, model TEXT, pool_index INTEGER, url TEXT, worker_user TEXT, pool_status TEXT, stratum_active BOOLEAN, accepted DOUBLE PRECISION, rejected DOUBLE PRECISION, stale DOUBLE PRECISION, get_failures DOUBLE PRECISION, remote_failures DOUBLE PRECISION, pool_rejected_percent DOUBLE PRECISION, pool_stale_percent DOUBLE PRECISION, last_share_time_epoch DOUBLE PRECISION, raw_pool_json JSONB NOT NULL DEFAULT '{}'::jsonb, agent_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); DO $$ DECLARE id_type TEXT; BEGIN SELECT data_type INTO id_type FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'miner_pool_snapshots' AND column_name = 'id'; IF id_type = 'text' THEN ALTER TABLE miner_pool_snapshots ALTER COLUMN id SET DEFAULT (gen_random_uuid())::text; ELSIF id_type = 'uuid' THEN ALTER TABLE miner_pool_snapshots ALTER COLUMN id SET DEFAULT gen_random_uuid(); END IF; END $$; CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_miner_created ON miner_pool_snapshots (miner_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_ip_created ON miner_pool_snapshots (ip_address, created_at DESC); """ ) conn.commit() finally: if conn is not None: conn.close() @app.post("/api/agent/pool-snapshots") async def agent_pool_snapshots( request: Request, authenticated_agent_id: str = Depends(require_agent_identity), zone_service: AgentZoneService = Depends(get_agent_zone_service), ): import json as _json import uuid as _uuid routing = zone_service.routing_context(authenticated_agent_id) if routing.routing_reason or routing.agent_zone_id is None: raise HTTPException( status_code=409, detail=( "Agent routing unavailable: " + (routing.routing_reason or "agent_zone_unassigned") ), ) body = await request.json() agent_id = authenticated_agent_id snapshots = body.get("pool_snapshots") or body.get("snapshots") or [] minerops_ensure_pool_info_table() inserted = 0 matched = 0 conn = None def fnum(value): try: if value is None or value == "": return None return float(value) except Exception: return None def fint(value): try: if value is None or value == "": return None return int(float(value)) except Exception: return None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: for snap in snapshots: ip = (snap.get("ip_address") or snap.get("ip") or "").strip() mac = (snap.get("mac_address") or snap.get("mac") or "").strip() model = snap.get("model") or "" pools = snap.get("pools") or [] if isinstance(pools, dict): pools = [pools] if not isinstance(pools, list): pools = [] if not ip or not pools: continue miner_id = None if mac: cur.execute( """ SELECT id FROM miners WHERE agent_zone_id = %s AND ( upper(current_mac) = upper(%s) OR host(current_ip) = %s ) LIMIT 1; """, (routing.agent_zone_id, mac, ip), ) else: cur.execute( """ SELECT id FROM miners WHERE agent_zone_id = %s AND host(current_ip) = %s LIMIT 1; """, (routing.agent_zone_id, ip), ) row = cur.fetchone() if not row: continue miner_id = row["id"] matched += 1 for pool in pools: if not isinstance(pool, dict): continue pool_index = fint(pool.get("POOL") or pool.get("pool") or pool.get("Pool")) cur.execute( """ INSERT INTO miner_pool_snapshots ( miner_id, ip_address, mac_address, model, pool_index, url, worker_user, pool_status, stratum_active, accepted, rejected, stale, get_failures, remote_failures, pool_rejected_percent, pool_stale_percent, last_share_time_epoch, raw_pool_json, agent_id ) VALUES ( %s, %s::inet, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s ); """, ( miner_id, ip, mac, model, pool_index, pool.get("URL") or pool.get("url"), pool.get("User") or pool.get("user"), pool.get("Status") or pool.get("status"), pool.get("Stratum Active") if "Stratum Active" in pool else pool.get("stratum_active"), fnum(pool.get("Accepted")), fnum(pool.get("Rejected")), fnum(pool.get("Stale")), fnum(pool.get("Get Failures")), fnum(pool.get("Remote Failures")), fnum(pool.get("Pool Rejected%")), fnum(pool.get("Pool Stale%")), fnum(pool.get("Last Share Time")), _json.dumps(pool), agent_id, ), ) inserted += 1 conn.commit() return api_response( data={ "inserted": inserted, "matched": matched, "received_miners": len(snapshots), }, meta={"message": "Pool snapshots saved."}, ) finally: if conn is not None: conn.close() @app.get("/api/dashboard/pool-info-latest") def dashboard_pool_info_latest(current_user: dict = Depends(get_current_user)): minerops_ensure_pool_info_table() conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ WITH latest_pool AS ( SELECT DISTINCT ON (miner_id, ip_address, pool_index) * FROM miner_pool_snapshots ORDER BY miner_id, ip_address, pool_index, created_at DESC ) SELECT lp.id, lp.miner_id::text AS miner_id, rooms.name AS room_name, host(COALESCE(m.current_ip, lp.ip_address)) AS current_ip, COALESCE(m.current_mac, lp.mac_address) AS current_mac, COALESCE(m.miner_type, m.model, lp.model) AS model, lp.pool_index, lp.url, lp.worker_user, lp.pool_status, lp.stratum_active, lp.accepted, lp.rejected, lp.stale, lp.get_failures, lp.remote_failures, lp.pool_rejected_percent, lp.pool_stale_percent, to_timestamp(lp.last_share_time_epoch) AS last_share_time, lp.created_at FROM latest_pool lp LEFT JOIN miners m ON m.id = lp.miner_id LEFT JOIN rooms ON rooms.id = m.room_id ORDER BY rooms.name NULLS LAST, COALESCE(m.current_mac, lp.mac_address), lp.pool_index; """ ) rows = [dict(row) for row in cur.fetchall()] return api_response(data={"pool_info": rows}) finally: if conn is not None: conn.close() @app.get("/api/dashboard/runtime-fields-latest") def dashboard_runtime_fields_latest(current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT m.id::text AS miner_id, rooms.name AS room_name, host(m.current_ip) AS current_ip, m.current_mac, m.status, COALESCE(m.miner_type, m.model) AS model, m.miner_sn, s.created_at AS latest_snapshot, s.fan_speed_in, s.fan_speed_out, s.env_temp_c, s.uptime_seconds, s.elapsed_seconds FROM miners m LEFT JOIN rooms ON rooms.id = m.room_id LEFT JOIN LATERAL ( SELECT * FROM miner_status_snapshots snap WHERE snap.miner_id = m.id ORDER BY snap.created_at DESC LIMIT 1 ) s ON true ORDER BY rooms.name NULLS LAST, COALESCE(m.miner_type, m.model), m.current_mac; """ ) rows = [dict(row) for row in cur.fetchall()] return api_response(data={"runtime_fields": rows}) finally: if conn is not None: conn.close() @app.get("/api/dashboard/m30-command-test-lab") def dashboard_m30_command_test_lab(current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT q.id, q.command_status, q.requested_by_username, q.request_payload ->> 'test_action' AS test_action, q.request_payload ->> 'action' AS action, q.request_payload ->> 'performance_mode' AS performance_mode, q.created_at, q.claimed_at, q.completed_at, q.error_message, left(COALESCE(q.result_payload::text, ''), 700) AS result_preview, rooms.name AS room_name, m.current_mac, host(m.current_ip) AS current_ip, COALESCE(m.miner_type, m.model) AS model FROM minerops_agent_command_queue q LEFT JOIN miners m ON m.id = CASE WHEN q.request_payload ->> 'miner_id' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN (q.request_payload ->> 'miner_id')::uuid ELSE NULL END LEFT JOIN rooms ON rooms.id = m.room_id WHERE q.command_type = 'miner_control' AND q.request_payload ->> 'test_lab' = 'm30' ORDER BY q.created_at DESC LIMIT 40; """ ) recent_tests = [dict(row) for row in cur.fetchall()] return api_response(data={"recent_tests": recent_tests}) finally: if conn is not None: conn.close() @app.post("/api/dashboard/m30-command-test-lab/run") def run_dashboard_m30_command_test_lab(request: M30CommandTestRequest, current_user: dict = Depends(get_current_user)): import json as _json import uuid as _uuid miner_id = (request.miner_id or "").strip() test_action = (request.test_action or "").strip().lower() action = test_action performance_mode = "" control_method = "api3" legacy_4028_command = "" legacy_4028_json = "" legacy_4028_actions = { "legacy_4028_summary": "summary", "legacy_4028_version": "version", "legacy_4028_pools": "pools", "legacy_4028_devs": "devs", "legacy_4028_stats": "stats", "legacy_4028_reboot": "reboot", "legacy_4028_restart": "restart", } if test_action == "low": action = "performance_mode" performance_mode = "low" elif test_action == "normal": action = "performance_mode" performance_mode = "normal" elif test_action == "high": action = "performance_mode" performance_mode = "high" elif test_action in legacy_4028_actions: action = "legacy_4028" control_method = "legacy_4028" legacy_4028_command = legacy_4028_actions[test_action] legacy_4028_json = _json.dumps({"command": legacy_4028_command}) if action not in ("reboot", "suspend", "resume", "performance_mode", "legacy_4028"): raise HTTPException(status_code=400, detail="Invalid M30 test action.") conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT miners.id::text AS miner_id, host(miners.current_ip) AS ip_address, miners.current_mac AS mac_address, rooms.name AS room_name, COALESCE(miners.miner_type, miners.model) AS model FROM miners LEFT JOIN rooms ON rooms.id = miners.room_id WHERE miners.id = %s::uuid; """, (miner_id,), ) miner = cur.fetchone() if miner is None: raise HTTPException(status_code=404, detail="Miner not found.") if not miner.get("ip_address"): raise HTTPException(status_code=400, detail="Miner has no current IP address.") payload = { "miner_id": miner["miner_id"], "action": action, "performance_mode": performance_mode, "control_method": control_method, "legacy_4028_command": legacy_4028_command, "legacy_4028_json": legacy_4028_json, "ip_address": miner["ip_address"], "current_ip": miner["ip_address"], "mac_address": miner["mac_address"], "dry_run": False, "test_lab": "m30", "test_action": test_action, "model": miner.get("model") or "", } command_id = str(_uuid.uuid4()) username = current_user.get("username") or current_user.get("email") or "unknown" user_id = str(current_user.get("id") or "") cur.execute( """ INSERT INTO minerops_agent_command_queue ( id, agent_id, command_type, command_status, requested_by_user_id, requested_by_username, request_payload ) VALUES (%s, 'plexus-windows-agent', 'miner_control', 'pending', %s, %s, %s::jsonb); """, ( command_id, user_id, username, _json.dumps(payload), ), ) conn.commit() return api_response( data={ "command_id": command_id, "miner": dict(miner), "test_action": test_action, }, meta={"message": "M30 command test queued."}, ) finally: if conn is not None: conn.close() @app.get("/api/dashboard/scheduled-bulk-actions") def dashboard_scheduled_bulk_actions(current_user: dict = Depends(get_current_user)): minerops_ensure_scheduled_bulk_actions_table() minerops_reconcile_scheduled_bulk_action_statuses() conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id, name, target_type, target_value, action, performance_mode, repeat_rule, scheduled_for, status, queued_command_count, last_queued_command_count, run_count, last_error, requested_by_username, created_at, updated_at, processed_at FROM minerops_scheduled_bulk_actions ORDER BY CASE WHEN status = 'pending' THEN 0 ELSE 1 END, scheduled_for ASC, created_at DESC LIMIT 80; """ ) schedules = [dict(row) for row in cur.fetchall()] schedule_ids = [row.get("id") for row in schedules if row.get("id")] command_counts_by_schedule = {} if schedule_ids: cur.execute( """ SELECT request_payload ->> 'scheduled_action_id' AS scheduled_action_id, COUNT(*) AS command_total_count, COUNT(*) FILTER (WHERE command_status = 'pending') AS command_pending_count, COUNT(*) FILTER (WHERE command_status = 'completed') AS command_completed_count, COUNT(*) FILTER (WHERE command_status = 'failed') AS command_failed_count, COUNT(*) FILTER (WHERE command_status = 'cancelled') AS command_cancelled_count, COUNT(*) FILTER (WHERE command_status NOT IN ('completed', 'failed', 'cancelled')) AS command_active_count FROM minerops_agent_command_queue WHERE request_payload ->> 'scheduled_action_id' = ANY(%s) GROUP BY request_payload ->> 'scheduled_action_id'; """, (schedule_ids,), ) command_counts_by_schedule = { row["scheduled_action_id"]: dict(row) for row in cur.fetchall() } for schedule in schedules: counts = command_counts_by_schedule.get(schedule.get("id"), {}) schedule["command_total_count"] = int(counts.get("command_total_count") or 0) schedule["command_pending_count"] = int(counts.get("command_pending_count") or 0) schedule["command_completed_count"] = int(counts.get("command_completed_count") or 0) schedule["command_failed_count"] = int(counts.get("command_failed_count") or 0) schedule["command_cancelled_count"] = int(counts.get("command_cancelled_count") or 0) schedule["command_active_count"] = int(counts.get("command_active_count") or 0) cur.execute( """ SELECT name FROM rooms ORDER BY sort_order, name; """ ) units = [row["name"] for row in cur.fetchall()] cur.execute( """ SELECT DISTINCT COALESCE(miner_type, model) AS model_name FROM miners WHERE COALESCE(miner_type, model) IS NOT NULL AND COALESCE(miner_type, model) <> '' ORDER BY COALESCE(miner_type, model); """ ) models = [row["model_name"] for row in cur.fetchall()] return api_response( data={ "schedules": schedules, "units": units, "models": models, } ) finally: if conn is not None: conn.close() @app.post("/api/dashboard/scheduled-bulk-actions") def create_dashboard_scheduled_bulk_action(request: ScheduledBulkActionCreateRequest, current_user: dict = Depends(get_current_user)): import datetime as _datetime import uuid as _uuid minerops_ensure_scheduled_bulk_actions_table() target_type = (request.target_type or "").strip().lower() target_value = (request.target_value or "").strip() command_action = (getattr(request, "action", None) or "performance_mode").strip().lower() performance_mode = (request.performance_mode or "").strip().lower() repeat_rule = (getattr(request, "repeat_rule", None) or "once").strip().lower() if target_type not in ("all", "unit", "model", "configured_mode", "selected_miners"): raise HTTPException(status_code=400, detail="Invalid target type.") if target_type != "all" and not target_value: raise HTTPException(status_code=400, detail="Target value is required.") if command_action not in ("performance_mode", "suspend", "resume"): raise HTTPException(status_code=400, detail="Invalid action.") if command_action == "performance_mode" and performance_mode not in ("low", "normal", "high"): raise HTTPException(status_code=400, detail="Invalid performance mode.") if command_action in ("suspend", "resume"): performance_mode = "" if repeat_rule not in ("once", "daily", "weekdays", "weekends"): raise HTTPException(status_code=400, detail="Invalid repeat rule.") try: raw_dt = (request.scheduled_for or "").strip().replace("Z", "+00:00") scheduled_for = _datetime.datetime.fromisoformat(raw_dt) if scheduled_for.tzinfo is None: scheduled_for = scheduled_for.replace(tzinfo=_datetime.timezone.utc) except Exception: raise HTTPException(status_code=400, detail="Invalid scheduled date/time.") action_id = str(_uuid.uuid4()) username = current_user.get("username") or current_user.get("email") or "unknown" user_id = str(current_user.get("id") or "") conn = None try: conn = get_db_connection() with conn.cursor() as cur: cur.execute( """ INSERT INTO minerops_scheduled_bulk_actions ( id, name, target_type, target_value, action, performance_mode, repeat_rule, scheduled_for, status, requested_by_user_id, requested_by_username ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', %s, %s); """, ( action_id, request.name or "", target_type, target_value, command_action, performance_mode, repeat_rule, scheduled_for, user_id, username, ), ) conn.commit() return api_response( data={"id": action_id}, meta={"message": "Scheduled bulk action created."}, ) finally: if conn is not None: conn.close() @app.post("/api/dashboard/scheduled-bulk-actions/{action_id}/cancel") def cancel_dashboard_scheduled_bulk_action(action_id: str, current_user: dict = Depends(get_current_user)): minerops_ensure_scheduled_bulk_actions_table() conn = None try: conn = get_db_connection() with conn.cursor() as cur: cur.execute( """ UPDATE minerops_agent_command_queue SET command_status = 'cancelled', completed_at = now(), error_message = COALESCE(error_message, '') || ' Cancelled by admin from scheduled action.' WHERE request_payload ->> 'scheduled_action_id' = %s AND command_status = 'pending'; """, (action_id,), ) cur.execute( """ UPDATE minerops_scheduled_bulk_actions SET status = 'cancelled', updated_at = now(), last_error = CASE WHEN COALESCE(last_error, '') = '' THEN 'Cancelled by admin.' ELSE last_error END WHERE id = %s AND status IN ('pending', 'queued', 'running', 'failed') RETURNING id; """, (action_id,), ) row = cur.fetchone() conn.commit() if row is None: raise HTTPException(status_code=404, detail="Scheduled action not found or cannot be cancelled.") return api_response(meta={"message": "Scheduled action cancelled."}) finally: if conn is not None: conn.close() @app.post("/api/dashboard/scheduled-bulk-actions/{action_id}/delete") def delete_dashboard_scheduled_bulk_action(action_id: str, current_user: dict = Depends(get_current_user)): minerops_ensure_scheduled_bulk_actions_table() conn = None try: conn = get_db_connection() with conn.cursor() as cur: cur.execute( """ UPDATE minerops_agent_command_queue SET command_status = 'cancelled', completed_at = now(), error_message = COALESCE(error_message, '') || ' Cancelled because scheduled action was deleted.' WHERE request_payload ->> 'scheduled_action_id' = %s AND command_status = 'pending'; """, (action_id,), ) cur.execute( """ DELETE FROM minerops_scheduled_bulk_actions WHERE id = %s RETURNING id; """, (action_id,), ) row = cur.fetchone() conn.commit() if row is None: raise HTTPException(status_code=404, detail="Scheduled action not found.") return api_response(meta={"message": "Scheduled action deleted."}) finally: if conn is not None: conn.close() @app.post("/api/dashboard/scheduled-bulk-actions/process-due") def process_due_dashboard_scheduled_bulk_actions(current_user: dict = Depends(get_current_user)): processed = minerops_process_due_scheduled_bulk_actions() return api_response(data={"processed": processed}) @app.get("/api/dashboard/bulk-action-status") def dashboard_bulk_action_status(current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT COALESCE(INITCAP(configured_performance_mode), 'Unknown') AS configured_mode, COALESCE(configured_performance_mode_source, 'none') AS source, COUNT(*) AS miner_count FROM miners GROUP BY configured_performance_mode, configured_performance_mode_source ORDER BY configured_mode, source; """ ) mode_counts = [dict(row) for row in cur.fetchall()] cur.execute( """ SELECT COALESCE(q.request_payload ->> 'action', 'unknown') AS action, COALESCE(q.request_payload ->> 'performance_mode', '') AS performance_mode, q.command_status, COUNT(*) AS command_count FROM minerops_agent_command_queue q WHERE q.command_type = 'miner_control' AND q.created_at >= now() - interval '6 hours' GROUP BY q.request_payload ->> 'action', q.request_payload ->> 'performance_mode', q.command_status ORDER BY command_count DESC, action, performance_mode, command_status; """ ) recent_summary = [dict(row) for row in cur.fetchall()] cur.execute( """ SELECT q.id, q.command_status, q.requested_by_username, q.request_payload ->> 'action' AS action, q.request_payload ->> 'performance_mode' AS performance_mode, q.created_at, q.claimed_at, q.completed_at, q.error_message, rooms.name AS room_name, m.current_mac, host(m.current_ip) AS current_ip FROM minerops_agent_command_queue q LEFT JOIN miners m ON m.id = CASE WHEN q.request_payload ->> 'miner_id' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN (q.request_payload ->> 'miner_id')::uuid ELSE NULL END LEFT JOIN rooms ON rooms.id = m.room_id WHERE q.command_type = 'miner_control' ORDER BY q.created_at DESC LIMIT 40; """ ) recent_commands = [dict(row) for row in cur.fetchall()] cur.execute( """ SELECT q.id, q.command_status, q.requested_by_username, q.request_payload ->> 'action' AS action, q.request_payload ->> 'performance_mode' AS performance_mode, q.created_at, q.completed_at, q.error_message, rooms.name AS room_name, m.current_mac, host(m.current_ip) AS current_ip FROM minerops_agent_command_queue q LEFT JOIN miners m ON m.id = CASE WHEN q.request_payload ->> 'miner_id' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN (q.request_payload ->> 'miner_id')::uuid ELSE NULL END LEFT JOIN rooms ON rooms.id = m.room_id WHERE q.command_type = 'miner_control' AND ( q.command_status <> 'completed' OR COALESCE(q.error_message, '') <> '' ) ORDER BY q.created_at DESC LIMIT 12; """ ) recent_failures = [dict(row) for row in cur.fetchall()] return api_response( data={ "configured_mode_counts": mode_counts, "recent_summary": recent_summary, "recent_commands": recent_commands, "recent_failures": recent_failures, } ) finally: if conn is not None: conn.close() @app.get("/api/dashboard/miner-table") def dashboard_miner_table(current_user: dict = Depends(get_current_user)): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ WITH latest AS ( SELECT DISTINCT ON (miner_id) miner_id, observed_at, observed_ip, mhs_av, power_w, power_rate_w_per_th, chip_temp_max_c, chip_temp_avg_c, fan_speed_in_rpm, fan_speed_out_rpm, hash_stable, pool_rejected_percent, COALESCE( raw_summary_json #>> '{Msg,Power Mode}', raw_summary_json #>> '{MSG,Power Mode}', raw_summary_json #>> '{msg,Power Mode}', raw_summary_json ->> 'Power Mode', raw_summary_json ->> 'PowerMode', raw_summary_json ->> 'power_mode' ) AS runtime_power_state FROM miner_status_snapshots ORDER BY miner_id, observed_at DESC ) SELECT miners.id::text, COALESCE(rooms.name, 'Unassigned') AS room_name, host(miners.current_ip) AS current_ip, miners.current_mac, miners.hostname, miners.miner_sn, miners.power_sn, miners.model, miners.miner_type, miners.firmware_version, miners.status, miners.last_seen_at, latest.observed_at, ROUND((latest.mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths, ROUND(latest.power_w::numeric, 2)::float AS power_w, ROUND(latest.power_rate_w_per_th::numeric, 2)::float AS power_rate_w_per_th, ROUND(latest.chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c, ROUND(latest.chip_temp_avg_c::numeric, 2)::float AS chip_temp_avg_c, latest.fan_speed_in_rpm, latest.fan_speed_out_rpm, latest.hash_stable, INITCAP(miners.configured_performance_mode) AS performance_mode, miners.configured_performance_mode_source, miners.configured_performance_mode_updated_at, latest.runtime_power_state, ROUND(latest.pool_rejected_percent::numeric, 4)::float AS pool_rejected_percent FROM miners LEFT JOIN rooms ON rooms.id = miners.room_id LEFT JOIN latest ON latest.miner_id = miners.id ORDER BY rooms.sort_order, miners.current_ip; """ ) rows = [dict(row) for row in cur.fetchall()] return api_response(data=rows) finally: if conn is not None: conn.close() @app.get("/api/dashboard/hot-miners") def dashboard_hot_miners( min_temp_c: float = 90, limit: int = 100, current_user: dict = Depends(get_current_user), ): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ WITH latest AS ( SELECT DISTINCT ON (miner_id) miner_id, observed_at, chip_temp_max_c, chip_temp_avg_c, power_w, mhs_av, hash_stable FROM miner_status_snapshots ORDER BY miner_id, observed_at DESC ) SELECT miners.id::text, COALESCE(rooms.name, 'Unassigned') AS room_name, host(miners.current_ip) AS current_ip, miners.current_mac, miners.model, miners.miner_type, miners.firmware_version, miners.status, latest.observed_at, ROUND(latest.chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c, ROUND(latest.chip_temp_avg_c::numeric, 2)::float AS chip_temp_avg_c, ROUND(latest.power_w::numeric, 2)::float AS power_w, ROUND((latest.mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths, latest.hash_stable FROM miners LEFT JOIN rooms ON rooms.id = miners.room_id JOIN latest ON latest.miner_id = miners.id WHERE latest.chip_temp_max_c >= %s ORDER BY latest.chip_temp_max_c DESC LIMIT %s; """, (min_temp_c, limit), ) rows = [dict(row) for row in cur.fetchall()] return api_response(data=rows) finally: if conn is not None: conn.close() @app.get("/api/dashboard/unstable-miners") def dashboard_unstable_miners( limit: int = 100, current_user: dict = Depends(get_current_user), ): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ WITH latest AS ( SELECT DISTINCT ON (miner_id) miner_id, observed_at, chip_temp_max_c, power_w, mhs_av, hash_stable, pool_rejected_percent FROM miner_status_snapshots ORDER BY miner_id, observed_at DESC ) SELECT miners.id::text, COALESCE(rooms.name, 'Unassigned') AS room_name, host(miners.current_ip) AS current_ip, miners.current_mac, miners.model, miners.miner_type, miners.firmware_version, miners.status, latest.observed_at, ROUND((latest.mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths, ROUND(latest.power_w::numeric, 2)::float AS power_w, ROUND(latest.chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c, latest.hash_stable, ROUND(latest.pool_rejected_percent::numeric, 4)::float AS pool_rejected_percent FROM miners LEFT JOIN rooms ON rooms.id = miners.room_id JOIN latest ON latest.miner_id = miners.id WHERE latest.hash_stable = false OR latest.pool_rejected_percent >= 1 ORDER BY latest.hash_stable ASC, latest.pool_rejected_percent DESC NULLS LAST, latest.chip_temp_max_c DESC NULLS LAST LIMIT %s; """, (limit,), ) rows = [dict(row) for row in cur.fetchall()] return api_response(data=rows) finally: if conn is not None: conn.close() @app.get("/api/dashboard/stale-miners") def dashboard_stale_miners( minutes: int = 30, limit: int = 200, current_user: dict = Depends(get_current_user), ): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT miners.id::text, COALESCE(rooms.name, 'Unassigned') AS room_name, host(miners.current_ip) AS current_ip, miners.current_mac, miners.model, miners.miner_type, miners.firmware_version, miners.status, miners.last_seen_at, ROUND(EXTRACT(EPOCH FROM (now() - miners.last_seen_at)) / 60.0, 1)::float AS minutes_since_seen FROM miners LEFT JOIN rooms ON rooms.id = miners.room_id WHERE miners.last_seen_at < now() - make_interval(mins => %s) ORDER BY miners.last_seen_at ASC LIMIT %s; """, (minutes, limit), ) rows = [dict(row) for row in cur.fetchall()] return api_response(data=rows) finally: if conn is not None: conn.close() MINEROPS_UNIFIED_CSS = r""" :root { --minerops-bg: #f4f7fb; --minerops-card: #ffffff; --minerops-border: #d9e2ec; --minerops-text: #102a43; --minerops-muted: #52606d; --minerops-blue: #0b63ce; --minerops-blue-dark: #084b9a; --minerops-green: #0f766e; --minerops-red: #dc2626; } body { background: var(--minerops-bg) !important; color: var(--minerops-text) !important; font-family: "Segoe UI", Arial, sans-serif !important; margin: 0 !important; padding: 20px !important; } /* Hide old page headers/navs that made the pages look duplicated */ body > header:not(.minerops-shell-header) { display: none !important; } body > h1 { display: none !important; } body > .topbar { display: none !important; } .app-header { display: none !important; } #dashboardLocationMapLink { display: none !important; } .minerops-shell-header { background: linear-gradient(135deg, #0b63ce, #0f766e); color: white; border-radius: 16px; padding: 18px 20px; margin-bottom: 16px; display: flex; justify-content: space-between; align-items: center; gap: 16px; box-shadow: 0 8px 18px rgba(15, 23, 42, 0.18); } .minerops-kicker { font-size: 13px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.86; margin-bottom: 4px; } .minerops-shell-header h1 { display: block !important; margin: 0; color: white; font-size: 28px; line-height: 1.1; } .minerops-subtitle { margin-top: 6px; font-size: 14px; opacity: 0.9; } .minerops-nav { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; } .minerops-nav-link { display: inline-block; border: 1px solid rgba(255, 255, 255, 0.45); background: rgba(255, 255, 255, 0.14); color: white !important; border-radius: 999px; padding: 8px 12px; font-weight: 800; font-size: 13px; text-decoration: none !important; } .minerops-nav-link:hover { background: rgba(255, 255, 255, 0.24); } .minerops-nav-link.active { background: white; color: var(--minerops-blue) !important; } .card { background: var(--minerops-card) !important; border: 1px solid var(--minerops-border) !important; border-radius: 14px !important; padding: 16px !important; margin-bottom: 16px !important; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06) !important; } .card h2 { color: var(--minerops-text) !important; margin-top: 0 !important; } .small, .muted { color: var(--minerops-muted) !important; } button, input[type="button"], input[type="submit"], .issue-action-button { background: var(--minerops-blue) !important; color: white !important; border: 0 !important; border-radius: 8px !important; padding: 8px 11px !important; font-weight: 800 !important; cursor: pointer !important; text-decoration: none !important; } button:hover, input[type="button"]:hover, input[type="submit"]:hover, .issue-action-button:hover { background: var(--minerops-blue-dark) !important; } button.secondary { background: #64748b !important; } button.danger, .danger { background: var(--minerops-red) !important; color: white !important; } input, select, textarea { border: 1px solid #bcccdc !important; border-radius: 8px !important; padding: 8px !important; background: white !important; color: var(--minerops-text) !important; } table { width: 100%; border-collapse: collapse; background: white; border-radius: 10px; overflow: hidden; } th { background: #eef2ff !important; color: #1e293b !important; text-align: left; font-size: 13px; } th, td { border-bottom: 1px solid #e2e8f0 !important; padding: 8px !important; vertical-align: top; } tr:hover td { background: #f8fafc !important; } .status { border-radius: 10px !important; padding: 10px 12px !important; margin-top: 10px !important; border: 1px solid var(--minerops-border) !important; background: #f8fafc !important; } .status.ok { background: #ecfdf5 !important; border-color: #6ee7b7 !important; } .status.warn { background: #fff7ed !important; border-color: #fdba74 !important; } .status.bad { background: #fef2f2 !important; border-color: #fca5a5 !important; } .sticky-mini-toolbar, .card-toolbar { background: rgba(255, 255, 255, 0.96) !important; border: 1px solid var(--minerops-border) !important; border-radius: 14px !important; box-shadow: 0 4px 12px rgba(15, 23, 42, 0.10) !important; padding: 10px !important; } .sticky-mini-toolbar { top: 8px !important; } /* Step 80: Header auth controls */ .minerops-auth { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-left: 8px; } .minerops-auth input { width: 115px !important; min-width: 90px !important; padding: 7px 8px !important; border-radius: 999px !important; border: 1px solid rgba(255, 255, 255, 0.55) !important; background: rgba(255, 255, 255, 0.92) !important; font-size: 12px !important; } .minerops-auth button { border-radius: 999px !important; padding: 7px 10px !important; font-size: 12px !important; background: rgba(255, 255, 255, 0.18) !important; border: 1px solid rgba(255, 255, 255, 0.45) !important; color: white !important; } .minerops-auth button:hover { background: rgba(255, 255, 255, 0.28) !important; } .minerops-auth-user { color: white; font-size: 13px; font-weight: 800; padding: 7px 10px; border-radius: 999px; background: rgba(255, 255, 255, 0.14); border: 1px solid rgba(255, 255, 255, 0.30); } .minerops-auth-message { color: white; font-size: 12px; font-weight: 700; opacity: 0.92; } .minerops-hidden-login-card { display: none !important; } @media (max-width: 950px) { .minerops-auth { width: 100%; margin-left: 0; } .minerops-auth input { width: 140px !important; } } /* Step 83: Global miner search */ .minerops-global-search { position: relative; display: flex; align-items: center; gap: 6px; margin-left: 8px; } .minerops-global-search input { width: 230px !important; min-width: 180px !important; border-radius: 999px !important; padding: 8px 12px !important; border: 1px solid rgba(255, 255, 255, 0.55) !important; background: rgba(255, 255, 255, 0.95) !important; font-size: 13px !important; } .minerops-search-results { position: absolute; top: 40px; right: 0; z-index: 5000; width: min(680px, 92vw); max-height: 440px; overflow: auto; background: white; color: var(--minerops-text); border: 1px solid var(--minerops-border); border-radius: 14px; box-shadow: 0 14px 32px rgba(15, 23, 42, 0.28); padding: 8px; display: none; } .minerops-search-results.open { display: block; } .minerops-search-result { display: grid; grid-template-columns: 150px 110px 1fr; gap: 8px; padding: 9px; border-radius: 10px; cursor: pointer; border-bottom: 1px solid #e2e8f0; } .minerops-search-result:hover, .minerops-search-result.active { background: #eff6ff; } .minerops-search-result-main { font-weight: 900; color: #0f172a; } .minerops-search-result-sub { font-size: 12px; color: #475569; } .minerops-search-empty { padding: 10px; color: #475569; font-size: 13px; } @media (max-width: 950px) { .minerops-global-search { width: 100%; margin-left: 0; } .minerops-global-search input { width: 100% !important; } .minerops-search-results { left: 0; right: auto; width: 92vw; } } /* Step 84: Global scan action */ .minerops-header-scan { display: flex; align-items: center; gap: 6px; margin-left: 4px; } .minerops-scan-button { background: rgba(255, 255, 255, 0.18) !important; border: 1px solid rgba(255, 255, 255, 0.45) !important; color: white !important; border-radius: 999px !important; padding: 8px 12px !important; font-size: 13px !important; font-weight: 800 !important; } .minerops-scan-button:hover { background: rgba(255, 255, 255, 0.28) !important; } .minerops-scan-button.running { background: #f59e0b !important; color: #111827 !important; } .minerops-scan-status { color: white; font-size: 12px; font-weight: 700; opacity: 0.92; max-width: 260px; } @media (max-width: 950px) { .minerops-header-scan { width: 100%; margin-left: 0; } .minerops-scan-status { max-width: 100%; } } @media (max-width: 800px) { body { padding: 12px !important; } .minerops-shell-header { align-items: flex-start; flex-direction: column; } .minerops-nav { justify-content: flex-start; } } """ @app.get("/assets/minerops-unified.css") async def minerops_unified_css(): return HTMLResponse(content=MINEROPS_UNIFIED_CSS, media_type="text/css") MINEROPS_SHELL_AUTH_JS = r""" (function () { const TOKEN_KEYS = [ "minerops_token", "access_token", "token", "auth_token", "authToken" ]; const USERNAME_KEY = "minerops_username"; async function apiPatch(url, body) { const response = await fetch(url, { method: "PATCH", credentials: "same-origin", headers: authHeaders({ "Accept": "application/json", "Content-Type": "application/json" }), body: JSON.stringify(body || {}) }); const payload = await response.json().catch(function () { return null; }); if (!response.ok || (payload && payload.error)) { throw new Error((payload && payload.error) || "Request failed"); } return payload ? payload.data : null; } async function apiDelete(url) { const response = await fetch(url, { method: "DELETE", credentials: "same-origin", headers: authHeaders({ "Accept": "application/json" }) }); const payload = await response.json().catch(function () { return null; }); if (!response.ok || (payload && payload.error)) { throw new Error((payload && payload.error) || "Request failed"); } return payload ? payload.data : null; } function escapeHtml(value) { return String(value || "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function looksLikeToken(value) { if (!value || typeof value !== "string") { return false; } if (value.split(".").length === 3 && value.length > 30) { return true; } return value.length > 40 && !value.includes(" "); } function findTokenRecursive(value) { if (!value) { return ""; } if (typeof value === "string") { return looksLikeToken(value) ? value : ""; } if (Array.isArray(value)) { for (const item of value) { const found = findTokenRecursive(item); if (found) { return found; } } return ""; } if (typeof value === "object") { const preferredKeys = [ "access_token", "accessToken", "token", "jwt", "auth_token", "authToken" ]; for (const key of preferredKeys) { if (looksLikeToken(value[key])) { return value[key]; } } for (const key of Object.keys(value)) { if (key.toLowerCase().includes("token")) { const found = findTokenRecursive(value[key]); if (found) { return found; } } } for (const key of Object.keys(value)) { const found = findTokenRecursive(value[key]); if (found) { return found; } } } return ""; } function getToken() { for (const key of TOKEN_KEYS) { const value = localStorage.getItem(key); if (value) { return value; } } return ""; } function setToken(token) { for (const key of TOKEN_KEYS) { localStorage.setItem(key, token); } } function clearToken() { for (const key of TOKEN_KEYS) { localStorage.removeItem(key); } localStorage.removeItem(USERNAME_KEY); } function authHeaders() { const token = getToken(); if (!token) { return {}; } return { "Authorization": "Bearer " + token }; } function findLegacyLoginContainer(heading) { let node = heading.parentElement; let best = null; for (let i = 0; i < 7 && node && node !== document.body; i++) { const hasPassword = node.querySelector && node.querySelector('input[type="password"]'); const hasClearToken = node.textContent && node.textContent.toLowerCase().includes("clear token"); const isCard = node.classList && (node.classList.contains("card") || node.classList.contains("panel")); if (hasPassword || hasClearToken) { best = node; } if (isCard) { best = node; break; } node = node.parentElement; } return best || heading.parentElement; } function hideLegacyLoginCards() { document.querySelectorAll("h1, h2, h3").forEach(heading => { if (heading.closest(".minerops-shell-header")) { return; } const title = heading.textContent.trim().toLowerCase(); if ( title === "login" || title === "login / refresh" || title === "login/refresh" ) { const container = findLegacyLoginContainer(heading); if (container && !container.closest(".minerops-shell-header")) { container.classList.add("minerops-hidden-login-card"); container.style.display = "none"; } } }); } function setLegacyLoginInputs(username, password) { const usernameInputs = Array.from(document.querySelectorAll('input[type="text"], input:not([type])')) .filter(input => !input.closest(".minerops-shell-header")); const passwordInputs = Array.from(document.querySelectorAll('input[type="password"]')) .filter(input => !input.closest(".minerops-shell-header")); for (const input of usernameInputs) { const idName = ((input.id || "") + " " + (input.name || "") + " " + (input.placeholder || "")).toLowerCase(); if (idName.includes("user") || idName.includes("admin") || idName.includes("login")) { input.value = username; input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true })); } } for (const input of passwordInputs) { input.value = password; input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true })); } } async function tryLegacyPageLogin(username, password) { setLegacyLoginInputs(username, password); const functionNames = [ "login", "doLogin", "loginUser", "dashboardLogin", "locationLogin" ]; for (const functionName of functionNames) { if (typeof window[functionName] === "function") { try { const result = window[functionName](); await Promise.resolve(result); await delay(800); if (getToken()) { return true; } } catch (error) { // Keep trying other methods. } } } const buttons = Array.from(document.querySelectorAll("button")) .filter(button => !button.closest(".minerops-shell-header")) .filter(button => button.textContent.trim().toLowerCase() === "login"); for (const button of buttons) { try { button.click(); await delay(1000); if (getToken()) { return true; } } catch (error) { // Keep trying. } } return false; } async function parseLoginResponse(response) { const text = await response.text(); let data = null; try { data = text ? JSON.parse(text) : {}; } catch (error) { data = text; } if (!response.ok) { const message = data && typeof data === "object" ? (data.detail || data.message || data.error || "Login failed.") : (text || "Login failed."); throw new Error(message); } return findTokenRecursive(data) || findTokenRecursive(text); } async function tryJsonLogin(username, password) { const response = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username, password: password }) }); return await parseLoginResponse(response); } async function tryFormLogin(username, password) { const form = new URLSearchParams(); form.set("username", username); form.set("password", password); const response = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: form.toString() }); return await parseLoginResponse(response); } async function getCurrentUser() { try { const response = await fetch("/api/auth/me", { credentials: "same-origin", headers: authHeaders() }); if (!response.ok) { window.location.replace("/login?next=" + encodeURIComponent(window.location.pathname)); return null; } const payload = await response.json(); const user = payload && payload.data ? payload.data : payload; if (user && !getToken()) { setToken("cookie-session"); } return user; } catch (error) { return null; } } function userDisplayName(user) { const stored = localStorage.getItem(USERNAME_KEY); const candidates = [ stored, user && user.username, user && user.user_name, user && user.name, user && user.email ]; for (const candidate of candidates) { if (!candidate) { continue; } const value = String(candidate).trim(); if (!value) { continue; } if (value.toLowerCase() === "signed in") { continue; } return value; } return ""; } async function loginFromHeader() { const username = document.getElementById("mineropsHeaderUsername")?.value || ""; const password = document.getElementById("mineropsHeaderPassword")?.value || ""; const message = document.getElementById("mineropsHeaderAuthMessage"); if (!username || !password) { if (message) { message.textContent = "Enter username and password."; } return; } if (message) { message.textContent = "Signing in..."; } let token = ""; let lastError = null; try { token = await tryJsonLogin(username, password); } catch (error) { lastError = error; } if (!token) { try { token = await tryFormLogin(username, password); } catch (error) { lastError = error; } } if (token) { setToken(token); localStorage.setItem(USERNAME_KEY, username); window.location.reload(); return; } if (message) { message.textContent = "Trying page login..."; } const legacyWorked = await tryLegacyPageLogin(username, password); if (legacyWorked || getToken()) { localStorage.setItem(USERNAME_KEY, username); window.location.reload(); return; } if (message) { message.textContent = lastError ? lastError.message : "Login failed. No token was returned."; } } async function logoutFromHeader() { try { await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" }); } finally { clearToken(); window.location.replace("/login"); } } async function renderHeaderAuth() { const nav = document.querySelector(".minerops-nav"); if (!nav) { return; } let authArea = document.getElementById("mineropsHeaderAuth"); if (!authArea) { authArea = document.createElement("div"); authArea.id = "mineropsHeaderAuth"; authArea.className = "minerops-auth"; nav.appendChild(authArea); } const user = await getCurrentUser(); if (user) { const displayName = userDisplayName(user); authArea.innerHTML = displayName ? ` Signed in: ${escapeHtml(displayName)} ` : ` Signed in `; if (user.role === "Admin") { const accountsLink = document.createElement("a"); accountsLink.href = "/accounts"; accountsLink.textContent = "Accounts"; authArea.prepend(accountsLink); } document.getElementById("mineropsHeaderLogout").onclick = logoutFromHeader; return; } authArea.innerHTML = ` `; document.getElementById("mineropsHeaderLogin").onclick = loginFromHeader; document.getElementById("mineropsHeaderPassword").addEventListener("keydown", event => { if (event.key === "Enter") { loginFromHeader(); } }); } function initHeaderAuth() { hideLegacyLoginCards(); renderHeaderAuth(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initHeaderAuth); } else { initHeaderAuth(); } setTimeout(initHeaderAuth, 300); setTimeout(initHeaderAuth, 1000); setTimeout(initHeaderAuth, 2000); window.mineropsHeaderLogout = logoutFromHeader; window.mineropsHeaderLogin = loginFromHeader; })(); """ @app.get("/assets/minerops-shell-auth.js") async def minerops_shell_auth_js(): return HTMLResponse(content=MINEROPS_SHELL_AUTH_JS, media_type="text/javascript") MINEROPS_GLOBAL_SEARCH_JS = r""" (function () { const TOKEN_KEYS = [ "minerops_token", "minerops_access_token", "access_token", "accessToken", "token", "auth_token", "authToken", "jwt" ]; let searchCache = null; let activeIndex = -1; let currentResults = []; let lastLoadSummary = ""; function storageGet(key) { return localStorage.getItem(key) || sessionStorage.getItem(key) || ""; } function getToken() { for (const key of TOKEN_KEYS) { const value = storageGet(key); if (value) { return value; } } for (const store of [localStorage, sessionStorage]) { for (let i = 0; i < store.length; i++) { const key = store.key(i) || ""; const lower = key.toLowerCase(); if (lower.includes("token") || lower.includes("jwt") || lower.includes("auth")) { const value = store.getItem(key); if (value && value.length > 20) { return value; } } } } return ""; } function authHeaders() { const token = getToken(); if (!token) { return {}; } return { "Authorization": "Bearer " + token }; } function escapeHtml(value) { return String(value || "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function normalize(value) { return String(value || "") .toLowerCase() .replaceAll(":", "") .replaceAll("-", "") .replaceAll(" ", "") .trim(); } function firstValue(obj, keys) { for (const key of keys) { if (obj && obj[key] !== null && obj[key] !== undefined && obj[key] !== "") { return obj[key]; } } return ""; } function valueFromNested(obj, paths) { for (const path of paths) { let current = obj; let found = true; for (const part of path.split(".")) { if (!current || current[part] === null || current[part] === undefined || current[part] === "") { found = false; break; } current = current[part]; } if (found) { return current; } } return ""; } function displayValue(value, fallback) { if (value === null || value === undefined || value === "") { return fallback || ""; } return String(value); } async function fetchJson(url) { const response = await fetch(url, { headers: authHeaders() }); if (!response.ok) { throw new Error(url + " returned HTTP " + response.status); } return await response.json(); } function addSearchRow(rows, item, sourceName) { if (!item || typeof item !== "object") { return; } const minerId = firstValue(item, [ "miner_id", "id", "minerId", "miner_uuid", "uuid" ]); const mac = firstValue(item, [ "current_mac", "current_mac_address", "mac_address", "mac", "expected_mac", "network_mac", "macAddress", "primary_mac" ]); const ip = firstValue(item, [ "current_ip", "current_ip_address", "ip_address", "ip", "network_ip", "ipAddress", "host", "hostname" ]); const model = firstValue(item, [ "model", "miner_model", "miner_type", "hardware_model", "machine_model", "platform" ]); const room = firstValue(item, [ "room_name", "room", "unit", "unit_name" ]); const rack = firstValue(item, [ "rack_name", "rack" ]); const shelf = firstValue(item, [ "shelf_name", "shelf" ]); const position = firstValue(item, [ "position_index", "position", "slot", "slot_index" ]); const serial = firstValue(item, [ "serial_number", "miner_serial", "serial", "sn", "control_board_serial", "control_board_sn", "psu_serial", "hashboard_serial" ]) || valueFromNested(item, [ "latest_status.serial_number", "latest_snapshot.serial_number", "identity.serial_number" ]); if (!minerId && !mac && !ip && !serial && !room && !rack) { return; } const locationParts = []; if (room) { locationParts.push(room); } if (rack) { locationParts.push(rack); } if (shelf) { locationParts.push(shelf); } if (position) { locationParts.push("Pos " + position); } const location = locationParts.join(" / "); const hashrate = firstValue(item, [ "hashrate_ths", "hashrate", "avg_hashrate_ths", "current_hashrate_ths" ]); const temp = firstValue(item, [ "chip_temp_max_c", "max_temp_c", "temperature_c", "temp_c" ]); const haystack = [ minerId, mac, ip, model, room, rack, shelf, position, serial, location, sourceName ].map(value => String(value || "")).join(" "); rows.push({ miner_id: minerId, mac: mac, ip: ip, model: model, room: room, rack: rack, shelf: shelf, position: position, serial: serial, location: location, hashrate: hashrate, temp: temp, source: sourceName, haystack: haystack, normalized: normalize(haystack) }); } function addRowsFromArray(rows, arrayValue, sourceName) { if (!Array.isArray(arrayValue)) { return; } for (const item of arrayValue) { addSearchRow(rows, item, sourceName); } } function collectRowsFromPayload(rows, payload, sourceName) { if (!payload) { return; } if (Array.isArray(payload)) { addRowsFromArray(rows, payload, sourceName); return; } if (typeof payload !== "object") { return; } addRowsFromArray(rows, payload.positions, sourceName + ".positions"); addRowsFromArray(rows, payload.virtual_miners, sourceName + ".virtual_miners"); addRowsFromArray(rows, payload.miners, sourceName + ".miners"); addRowsFromArray(rows, payload.items, sourceName + ".items"); addRowsFromArray(rows, payload.results, sourceName + ".results"); addRowsFromArray(rows, payload.rows, sourceName + ".rows"); addRowsFromArray(rows, payload.data, sourceName + ".data"); addSearchRow(rows, payload, sourceName); } function dedupeRows(rows) { const seen = new Set(); const output = []; for (const row of rows) { const key = row.miner_id || normalize(row.mac) || row.ip || row.serial; if (!key) { continue; } if (seen.has(key)) { continue; } seen.add(key); output.push(row); } return output; } async function loadSearchCache() { if (searchCache) { return searchCache; } const rows = []; const attempts = []; const urls = [ "/api/location/map", "/api/miners", "/api/dashboard/miner-table", "/api/dashboard/hot-miners", "/api/dashboard/unstable-miners", "/api/dashboard/stale-miners" ]; for (const url of urls) { try { const payload = await fetchJson(url); const beforeCount = rows.length; collectRowsFromPayload(rows, payload, url); attempts.push(url + ": +" + (rows.length - beforeCount)); } catch (error) { attempts.push(url + ": " + error.message); } } searchCache = dedupeRows(rows); lastLoadSummary = "Loaded " + searchCache.length + " searchable miners. " + attempts.join(" | "); return searchCache; } function resultScore(row, rawQuery, normalizedQuery) { const q = rawQuery.toLowerCase(); if (String(row.mac || "").toLowerCase().includes(q)) { return 100; } if (normalize(row.mac).includes(normalizedQuery)) { return 98; } if (String(row.ip || "").toLowerCase().includes(q)) { return 92; } if (String(row.serial || "").toLowerCase().includes(q)) { return 88; } if (String(row.location || "").toLowerCase().includes(q)) { return 75; } if (String(row.room || "").toLowerCase().includes(q)) { return 72; } if (String(row.rack || "").toLowerCase().includes(q)) { return 70; } if (String(row.model || "").toLowerCase().includes(q)) { return 62; } if (row.normalized.includes(normalizedQuery)) { return 50; } return 0; } function setResultsMessage(message) { const resultsBox = document.getElementById("mineropsGlobalSearchResults"); if (!resultsBox) { return; } resultsBox.classList.add("open"); resultsBox.innerHTML = `
${escapeHtml(message)}
`; } async function runGlobalSearch() { const input = document.getElementById("mineropsGlobalSearchInput"); const resultsBox = document.getElementById("mineropsGlobalSearchResults"); if (!input || !resultsBox) { return; } const query = input.value.trim(); const normalizedQuery = normalize(query); activeIndex = -1; if (query.length < 2) { currentResults = []; resultsBox.classList.remove("open"); resultsBox.innerHTML = ""; return; } resultsBox.classList.add("open"); resultsBox.innerHTML = `
Searching...
`; const token = getToken(); if (!token) { currentResults = []; setResultsMessage("No login token found. Log in from the header first."); return; } const rows = await loadSearchCache(); if (rows.length === 0) { currentResults = []; setResultsMessage(lastLoadSummary || "Loaded 0 searchable miners."); return; } currentResults = rows .map(row => ({ row: row, score: resultScore(row, query, normalizedQuery) })) .filter(item => item.score > 0) .sort((a, b) => b.score - a.score) .slice(0, 12) .map(item => item.row); if (currentResults.length === 0) { resultsBox.innerHTML = `
No matching miners found.
${escapeHtml(lastLoadSummary)}
`; return; } resultsBox.innerHTML = currentResults.map((row, index) => { const mac = displayValue(row.mac, "No MAC"); const ip = displayValue(row.ip, "No IP"); const model = displayValue(row.model, "Unknown model"); const location = displayValue(row.location, "No location"); const serial = row.serial ? "Serial: " + row.serial : ""; const extra = [serial, row.temp ? "Temp: " + row.temp : "", row.source ? "Source: " + row.source : ""] .filter(Boolean) .join(" | "); return `
${escapeHtml(mac)}
${escapeHtml(ip)}
${escapeHtml(model)}
${escapeHtml(extra)}
${escapeHtml(location)}
${escapeHtml(row.miner_id || "No miner ID")}
`; }).join(""); resultsBox.querySelectorAll(".minerops-search-result").forEach(result => { result.onclick = () => { const index = Number(result.dataset.index || 0); openSearchResult(index); }; }); } function updateActiveResult() { const resultsBox = document.getElementById("mineropsGlobalSearchResults"); if (!resultsBox) { return; } resultsBox.querySelectorAll(".minerops-search-result").forEach((result, index) => { result.classList.toggle("active", index === activeIndex); }); } function openSearchResult(index) { const row = currentResults[index]; if (!row) { return; } if (row.miner_id) { window.location.href = "/miners/" + encodeURIComponent(row.miner_id) + "/detail"; return; } window.location.href = "/locations"; } function handleSearchKeydown(event) { if (event.key === "Escape") { document.getElementById("mineropsGlobalSearchResults")?.classList.remove("open"); return; } if (event.key === "ArrowDown") { event.preventDefault(); if (currentResults.length === 0) { return; } activeIndex = Math.min(activeIndex + 1, currentResults.length - 1); updateActiveResult(); return; } if (event.key === "ArrowUp") { event.preventDefault(); if (currentResults.length === 0) { return; } activeIndex = Math.max(activeIndex - 1, 0); updateActiveResult(); return; } if (event.key === "Enter") { event.preventDefault(); if (activeIndex >= 0) { openSearchResult(activeIndex); return; } if (currentResults.length > 0) { openSearchResult(0); } } } function debounce(fn, delay) { let timer = null; return function () { clearTimeout(timer); timer = setTimeout(fn, delay); }; } function setupGlobalMinerSearch() { const nav = document.querySelector(".minerops-nav"); if (!nav || document.getElementById("mineropsGlobalSearch")) { return; } const search = document.createElement("div"); search.id = "mineropsGlobalSearch"; search.className = "minerops-global-search"; search.innerHTML = `
`; const authArea = document.getElementById("mineropsHeaderAuth"); if (authArea) { nav.insertBefore(search, authArea); } else { nav.appendChild(search); } const input = document.getElementById("mineropsGlobalSearchInput"); const delayedSearch = debounce(runGlobalSearch, 180); input.addEventListener("input", delayedSearch); input.addEventListener("focus", runGlobalSearch); input.addEventListener("keydown", handleSearchKeydown); document.addEventListener("click", event => { if (!search.contains(event.target)) { document.getElementById("mineropsGlobalSearchResults")?.classList.remove("open"); } }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", setupGlobalMinerSearch); } else { setupGlobalMinerSearch(); } setTimeout(setupGlobalMinerSearch, 500); setTimeout(setupGlobalMinerSearch, 1200); window.mineropsRefreshGlobalSearch = function () { searchCache = null; runGlobalSearch(); }; })(); """ @app.get("/assets/minerops-global-search.js") async def minerops_global_search_js(): return HTMLResponse(content=MINEROPS_GLOBAL_SEARCH_JS, media_type="text/javascript") MINEROPS_HEADER_SCAN_JS = r""" // Phase 5F: Force Hub Mode into existing header scan JS (function () { async function getHubMode() { try { const response = await fetch("/api/hub/mode", { cache: "no-store" }); if (!response.ok) return null; return await response.json(); } catch (error) { return null; } } function showHubModeBanner() { let banner = document.getElementById("mineropsHubModeBanner"); if (!banner) { banner = document.createElement("div"); banner.id = "mineropsHubModeBanner"; banner.style.cssText = [ "max-width:1500px", "margin:12px auto", "padding:12px 16px", "border-radius:12px", "border:1px solid #facc15", "background:#fef9c3", "color:#713f12", "font-weight:700", "box-shadow:0 2px 8px rgba(15,23,42,.12)" ].join(";"); const header = document.querySelector(".minerops-shell-header"); if (header && header.parentNode) { header.parentNode.insertBefore(banner, header.nextSibling); } else { document.body.insertBefore(banner, document.body.firstChild); } } banner.textContent = "Hub Mode: local miner scanning is disabled on this home server. The Windows site agent will poll miners and push updates here."; } function disableHubScanButtons() { document.querySelectorAll("button, a").forEach(function (el) { const text = (el.textContent || "").trim().toLowerCase(); if ( text.includes("run known-miner scan") || text.includes("run scan") || text.includes("scan now") || text.includes("scanning") ) { el.textContent = "Hub Mode: Windows Agent"; el.title = "Local scanning is disabled on the hub. The Windows site agent will poll miners."; el.disabled = true; el.style.opacity = "0.65"; el.style.cursor = "not-allowed"; el.onclick = function (event) { event.preventDefault(); alert("Hub Mode is enabled. The Windows site agent will poll miners and push updates to this hub."); return false; }; } }); } function updateScannerPanel() { const state = document.getElementById("scannerState"); if (state) state.textContent = "Hub Mode"; const next = document.getElementById("scannerNextRun"); if (next) next.textContent = "Windows Agent"; const interval = document.getElementById("scannerInterval"); if (interval) interval.textContent = "Disabled"; const started = document.getElementById("scannerLastStarted"); if (started) started.textContent = "Local scanner disabled. Waiting for Windows site agent updates."; const refreshed = document.getElementById("scannerPanelRefresh"); if (refreshed) refreshed.textContent = "Hub Mode active."; } async function applyHubMode() { const data = await getHubMode(); if (!data || !data.hub_mode) return; showHubModeBanner(); disableHubScanButtons(); updateScannerPanel(); } document.addEventListener("DOMContentLoaded", function () { applyHubMode(); setTimeout(applyHubMode, 500); setTimeout(applyHubMode, 1500); setInterval(applyHubMode, 4000); }); })(); (function () { const TOKEN_KEYS = [ "minerops_token", "minerops_access_token", "access_token", "accessToken", "token", "auth_token", "authToken", "jwt" ]; let scanRunning = false; function storageGet(key) { return localStorage.getItem(key) || sessionStorage.getItem(key) || ""; } function getToken() { for (const key of TOKEN_KEYS) { const value = storageGet(key); if (value) { return value; } } for (const store of [localStorage, sessionStorage]) { for (let i = 0; i < store.length; i++) { const key = store.key(i) || ""; const lower = key.toLowerCase(); if (lower.includes("token") || lower.includes("jwt") || lower.includes("auth")) { const value = store.getItem(key); if (value && value.length > 20) { return value; } } } } return ""; } function authHeaders() { const token = getToken(); if (!token) { return {}; } return { "Authorization": "Bearer " + token }; } function setScanStatus(message) { const element = document.getElementById("mineropsHeaderScanStatus"); if (element) { element.textContent = message || ""; } } function setScanButtonRunning(isRunning) { scanRunning = isRunning; const button = document.getElementById("mineropsHeaderScanButton"); if (!button) { return; } button.disabled = isRunning; button.classList.toggle("running", isRunning); button.textContent = isRunning ? "Scanning..." : "Run Scan Now"; } function summarizeScanResult(data) { if (!data || typeof data !== "object") { return "Scan requested."; } const total = data.total || data.total_miners || data.miners_total || data.scanned || data.scanned_count; const success = data.success || data.success_count || data.ok || data.ok_count; const failed = data.failed || data.failed_count || data.error_count; const duration = data.duration_seconds || data.elapsed_seconds || data.seconds; const parts = []; if (total !== undefined && total !== null) { parts.push("total " + total); } if (success !== undefined && success !== null) { parts.push("success " + success); } if (failed !== undefined && failed !== null) { parts.push("failed " + failed); } if (duration !== undefined && duration !== null) { parts.push(String(duration).substring(0, 5) + " sec"); } if (parts.length === 0 && data.message) { return data.message; } if (parts.length === 0 && data.status) { return "Scan " + data.status + "."; } return parts.length ? "Scan done: " + parts.join(", ") + "." : "Scan requested."; } async function fetchJson(url, options) { const response = await fetch(url, options || {}); const text = await response.text(); let data = null; try { data = text ? JSON.parse(text) : {}; } catch (error) { data = text; } if (!response.ok) { const message = data && typeof data === "object" ? (data.detail || data.message || data.error || "Request failed.") : (text || "Request failed."); throw new Error(message); } return data; } async function runHeaderKnownMinerScan() { if (scanRunning) { return; } const token = getToken(); if (!token) { setScanStatus("Log in first."); return; } setScanButtonRunning(true); setScanStatus("Starting known-miner scan..."); try { const data = await fetchJson("/api/scanner/run-known-now", { method: "POST", headers: { ...authHeaders(), "Content-Type": "application/json" }, body: "{}" }); setScanStatus(summarizeScanResult(data)); if (typeof window.mineropsRefreshGlobalSearch === "function") { window.mineropsRefreshGlobalSearch(); } if (typeof window.refreshAll === "function") { try { await window.refreshAll(); } catch (error) { // Page refresh helper is optional. } } } catch (error) { setScanStatus("Scan error: " + error.message); } finally { setScanButtonRunning(false); } } async function updateHeaderScanStatus() { const token = getToken(); if (!token) { setScanStatus(""); return; } try { const data = await fetchJson("/api/scanner/status", { headers: authHeaders() }); if (!data || typeof data !== "object") { return; } const running = data.running || data.is_running || data.scan_running; if (running) { setScanButtonRunning(true); setScanStatus("Scanner running..."); return; } setScanButtonRunning(false); const lastSuccess = data.last_success_at || data.last_completed_at || data.last_run_at; const lastSummary = data.last_summary || data.summary; if (lastSummary && typeof lastSummary === "string") { setScanStatus(lastSummary); return; } if (lastSuccess) { setScanStatus("Last scan: " + new Date(lastSuccess).toLocaleString()); } } catch (error) { // Keep header quiet if status is unavailable. } } function setupHeaderScanButton() { const nav = document.querySelector(".minerops-nav"); if (!nav || document.getElementById("mineropsHeaderScan")) { return; } const container = document.createElement("div"); container.id = "mineropsHeaderScan"; container.className = "minerops-header-scan"; container.innerHTML = ` `; const authArea = document.getElementById("mineropsHeaderAuth"); const searchArea = document.getElementById("mineropsGlobalSearch"); if (authArea) { nav.insertBefore(container, authArea); } else if (searchArea && searchArea.nextSibling) { nav.insertBefore(container, searchArea.nextSibling); } else { nav.appendChild(container); } document.getElementById("mineropsHeaderScanButton").onclick = runHeaderKnownMinerScan; updateHeaderScanStatus(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", setupHeaderScanButton); } else { setupHeaderScanButton(); } setTimeout(setupHeaderScanButton, 500); setTimeout(setupHeaderScanButton, 1200); setInterval(updateHeaderScanStatus, 30000); window.mineropsRunHeaderKnownMinerScan = runHeaderKnownMinerScan; })(); """ @app.get("/assets/minerops-header-scan.js") async def minerops_header_scan_js(): return HTMLResponse(content=MINEROPS_HEADER_SCAN_JS, media_type="text/javascript") MINEROPS_MINER_CONTROL_JS = r""" (function () { const TOKEN_KEYS = [ "minerops_token", "minerops_access_token", "access_token", "accessToken", "token", "auth_token", "authToken", "jwt" ]; function storageGet(key) { return localStorage.getItem(key) || sessionStorage.getItem(key) || ""; } function getToken() { for (const key of TOKEN_KEYS) { const value = storageGet(key); if (value) { return value; } } for (const store of [localStorage, sessionStorage]) { for (let i = 0; i < store.length; i++) { const key = store.key(i) || ""; const lower = key.toLowerCase(); if (lower.includes("token") || lower.includes("jwt") || lower.includes("auth")) { const value = store.getItem(key); if (value && value.length > 20) { return value; } } } } return ""; } function authHeaders() { const token = getToken(); if (!token) { return {}; } const headers = { "Authorization": "Bearer " + token, "Content-Type": "application/json" }; const username = localStorage.getItem("minerops_username"); if (username) { headers["X-MinerOps-Username"] = username; } return headers; } function escapeHtml(value) { return String(value || "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function minerIdFromPage() { const card = document.getElementById("minerControlCard"); if (card && card.dataset.minerId) { return card.dataset.minerId; } const match = window.location.pathname.match(/\/miners\/([^\/]+)\/detail/); return match ? decodeURIComponent(match[1]) : ""; } function getMinerOpsTokenForAgentControl() { const keys = [ "minerops_token", "mineropsToken", "access_token", "token", "auth_token", "jwt" ]; for (const storage of [window.localStorage, window.sessionStorage]) { if (!storage) { continue; } for (const key of keys) { const value = storage.getItem(key); if (value && value.trim()) { return value.trim(); } } } return ""; } function getMinerIdForAgentControl() { const pathMatch = window.location.pathname.match(/\/miners\/([^\/]+)\/detail/); if (pathMatch && pathMatch[1]) { return decodeURIComponent(pathMatch[1]); } const dataId = document.body?.dataset?.minerId || document.documentElement?.dataset?.minerId || ""; return dataId; } async function postAgentMinerControl(action, dryRun, label) { const minerId = getMinerIdForAgentControl(); if (!minerId) { throw new Error("Could not determine miner ID from this page."); } const headers = { "Content-Type": "application/json" }; const token = getMinerOpsTokenForAgentControl(); if (token) { headers.Authorization = "Bearer " + token; } const response = await fetch(`/api/miners/${encodeURIComponent(minerId)}/agent-control`, { method: "POST", headers, body: JSON.stringify({ action, dry_run: !!dryRun, performance_mode: window.__mineropsPerformanceMode || null }) }); let data = {}; try { data = await response.json(); } catch { data = {}; } if (!response.ok) { const detail = data.detail || data.error || data.message || response.statusText; throw new Error(detail); } setMinerControlStatus( `${label} command queued for Windows Agent. Command ID: ${data.command_id || "unknown"}`, "ok" ); return data; } function setMinerControlStatus(message, className) { const status = document.getElementById("minerControlStatus"); if (!status) { return; } status.textContent = message; status.className = "status " + (className || "warn"); } async function fetchJson(url, options) { const response = await fetch(url, options || {}); const text = await response.text(); let data = null; try { data = text ? JSON.parse(text) : {}; } catch (error) { data = text; } if (!response.ok) { let message = text || "Request failed."; if (data && typeof data === "object") { if (typeof data.detail === "string") { message = data.detail; } else if (data.detail && data.detail.message) { message = data.detail.message; } else if (data.message) { message = data.message; } else { message = JSON.stringify(data); } } throw new Error(message); } return data; } async function postRebootCommand(dryRun, confirmText) { const label = dryRun ? "Dry-run reboot" : "Reboot"; try { setMinerControlStatus(`${label} command is being queued for Windows Agent...`, "warn"); await postAgentMinerControl("reboot", dryRun, label); } catch (err) { setMinerControlStatus(`${label} failed: ${err.message}`, "bad"); } } function extractPerformanceModeFromSnapshot(snapshot) { if (!snapshot) { return ""; } const raw = snapshot.raw_summary_json || snapshot.raw_summary || snapshot.summary || {}; const msg = raw.Msg || raw.MSG || raw.msg || {}; return ( msg["Power Mode"] || msg.PowerMode || msg.power_mode || raw["Power Mode"] || raw.PowerMode || raw.power_mode || "" ); } function setCurrentPerformanceModeBadge(mode, suffix) { const badge = document.getElementById("currentPerformanceModeBadge"); if (!badge) { return; } const cleanMode = mode ? String(mode).trim() : "Unknown"; const extra = suffix ? " " + suffix : ""; badge.textContent = "Configured Mode: " + cleanMode + extra; const lowered = cleanMode.toLowerCase(); if (lowered === "low") { badge.style.background = "#dcfce7"; badge.style.color = "#166534"; } else if (lowered === "normal") { badge.style.background = "#dbeafe"; badge.style.color = "#1e40af"; } else if (lowered === "high") { badge.style.background = "#fee2e2"; badge.style.color = "#991b1b"; } else { badge.style.background = "#e5e7eb"; badge.style.color = "#111827"; } } async function loadCurrentPerformanceModeBadge() { try { const minerId = getMinerIdForAgentControl(); if (!minerId) { setCurrentPerformanceModeBadge("Unknown"); return; } const headers = {}; const token = getMinerOpsTokenForAgentControl(); if (token) { headers.Authorization = "Bearer " + token; } const response = await fetch(`/api/miners/${encodeURIComponent(minerId)}/configured-performance-mode`, { method: "GET", headers }); if (!response.ok) { throw new Error(response.statusText || "Snapshot request failed"); } const body = await response.json(); const data = body.data || body || {}; const mode = data.configured_performance_mode || "Unknown"; const runtimeState = data.runtime_power_state ? "runtime: " + data.runtime_power_state : ""; setCurrentPerformanceModeBadge(mode || "Unknown", runtimeState ? "(" + runtimeState + ")" : ""); } catch (err) { setCurrentPerformanceModeBadge("Unknown"); } } async function setPerformanceMode() { const select = document.getElementById("performanceModeSelect"); const mode = (select && select.value ? select.value : "normal").toLowerCase(); let warning = "Set this miner to " + mode.toUpperCase() + " performance mode?"; if (mode === "high") { warning += "\n\nHigh mode can increase heat and power use. Avoid this on miners with temperature errors."; } if (!confirm(warning)) { setMinerControlStatus("Performance mode change cancelled.", "warn"); return; } try { window.__mineropsPerformanceMode = mode; setMinerControlStatus("Performance mode command is being queued for Windows Agent...", "warn"); await postAgentMinerControl("performance_mode", false, "Performance mode " + mode.toUpperCase()); setCurrentPerformanceModeBadge(mode, "(queued)"); } catch (err) { setMinerControlStatus("Performance mode failed: " + err.message, "bad"); } finally { window.__mineropsPerformanceMode = null; } } async function dryRunMinerReboot() { await postRebootCommand(true, "DRYRUN"); } async function realRebootMiner() { const confirmed = confirm( "Reboot this miner now?\n\nThis will send the reboot command immediately." ); if (!confirmed) { setMinerControlStatus("Real reboot cancelled.", "warn"); return; } await postRebootCommand(false, "REBOOT"); } async function postPowerCommand(action, label) { try { setMinerControlStatus(`${label} command is being queued for Windows Agent...`, "warn"); await postAgentMinerControl(action, false, label); } catch (err) { setMinerControlStatus(`${label} failed: ${err.message}`, "bad"); } } async function powerOffMiner() { const confirmed = confirm( "Suspend this miner now?\n\nThis will use the authenticated WhatsMiner API3 service stop command." ); if (!confirmed) { setMinerControlStatus("Suspend cancelled.", "warn"); return; } await postPowerCommand("suspend", "Suspend"); } async function powerOnMiner() { const confirmed = confirm( "Resume this miner now?\n\nThis will use the authenticated WhatsMiner API3 service start command." ); if (!confirmed) { setMinerControlStatus("Resume cancelled.", "warn"); return; } await postPowerCommand("resume", "Resume"); } function formatDate(value) { if (!value) { return "-"; } try { return new Date(value).toLocaleString(); } catch (error) { return value; } } async function loadMinerCommandHistory() { const minerId = minerIdFromPage(); const body = document.getElementById("minerCommandHistoryBody"); if (!minerId || !body) { return; } const token = getToken(); if (!token) { body.innerHTML = '
Log in to view command history.
'; return; } try { const data = await fetchJson("/api/miners/" + encodeURIComponent(minerId) + "/commands", { headers: authHeaders() }); const commands = data.commands || []; if (commands.length === 0) { body.innerHTML = '
No command history for this miner yet.
'; return; } body.innerHTML = ` ${commands.map(command => ` `).join("")}
Time Command App Status Target User Error
${escapeHtml(formatDate(command.created_at))} ${escapeHtml(command.command_type)} ${escapeHtml(command.command_status)}
TCP/log status, not miner acceptance proof
${escapeHtml((command.target_ip || "-") + " / " + (command.target_mac || "-"))} ${escapeHtml(command.requested_by_username || "-")} ${escapeHtml(command.error_message || "")}
`; } catch (error) { body.innerHTML = '
Command history error: ' + escapeHtml(error.message) + '
'; } } function setupMinerControlCard() { const realButton = document.getElementById("realRebootButton"); if (realButton && realButton.dataset.handlerAttached !== "true") { realButton.dataset.handlerAttached = "true"; realButton.onclick = realRebootMiner; } const powerOffButton = document.getElementById("powerOffButton"); if (powerOffButton && powerOffButton.dataset.handlerAttached !== "true") { powerOffButton.dataset.handlerAttached = "true"; powerOffButton.onclick = powerOffMiner; } const powerOnButton = document.getElementById("powerOnButton"); if (powerOnButton && powerOnButton.dataset.handlerAttached !== "true") { powerOnButton.dataset.handlerAttached = "true"; powerOnButton.onclick = powerOnMiner; } loadMinerCommandHistory(); loadCurrentPerformanceModeBadge(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", setupMinerControlCard); } else { setupMinerControlCard(); } setTimeout(setupMinerControlCard, 600); window.dryRunMinerReboot = dryRunMinerReboot; window.realRebootMiner = realRebootMiner; window.setPerformanceMode = setPerformanceMode; window.loadCurrentPerformanceModeBadge = loadCurrentPerformanceModeBadge; window.suspendMiner = powerOffMiner; window.suspendMiner = powerOffMiner; window.powerOffMiner = powerOffMiner; window.resumeMiner = powerOnMiner; window.resumeMiner = powerOnMiner; window.powerOnMiner = powerOnMiner; window.loadMinerCommandHistory = loadMinerCommandHistory; })(); """ @app.get("/assets/minerops-miner-control.js") async def minerops_miner_control_js(): return HTMLResponse(content=MINEROPS_MINER_CONTROL_JS, media_type="text/javascript") @app.get("/dashboard", response_class=HTMLResponse) def minerops_dashboard_page(): return """ MinerOps Dashboard
MinerOps Console

MinerOps Dashboard

Plexus Mining Site

MinerOps Dashboard

Plexus Mining Site

Login

Not logged in.
Token is stored in this browser until it expires or you click Clear Token.

Fleet Summary

Not refreshed yet.
Total Miners
-
Online
-
Unit 4
-
Unit 6
-
Hot >= 90C
-
Unstable
-
Stale 30m
-
Total TH/s
-
Total Power W
-

Scanner Status

State
-
Interval
-
Last Duration
-
Total Miners
-
Success
-
Failed
-
Last Finished
-
Next Run
-
Last started: -
Scanner panel refreshed: -
Scanner poll count: 0

Issues / Alerts

Sorted by severity, MAC-first.
Total Issues
-
Critical
-
Warning
-
Info
-
Repair status actions ready.
Severity Issue MAC IP Unit Model Firmware Detail Last Seen

Open Repairs / Follow-ups

Only shows repairs that are not completed.
Open Repairs
-
Needs Follow-up
-
In Progress
-
Failed
-
Open repair actions ready.
Date App Status Actions Type MAC IP Unit Model Title Component Slot Serial Notes By

Recent Repair / Maintenance Activity

Fleet-wide repair history, MAC-first.
Shown
-
Completed
-
Needs Follow-up
-
In Progress
-
Repair CSV export ready.
Repair status actions ready.
Date App Status Actions Type MAC IP Unit Model Title Component Slot Serial Notes By

Models

ModelCount

Firmware

FirmwareCount

Miner Tables

Search by IP, MAC, serial, model, firmware, or unit.
Selected: 0
Repair status actions ready.

Status

Ready.
""" @app.get("/dashboard", response_class=HTMLResponse) def minerops_dashboard_page(): return """ MinerOps Dashboard

MinerOps Dashboard

Plexus Mining Site

Login

Not logged in.
Token is stored in this browser until it expires or you click Clear Token.

Fleet Summary

Not refreshed yet.
Total Miners
-
Online
-
Unit 4
-
Unit 6
-
Hot >= 90C
-
Unstable
-
Stale 30m
-
Total TH/s
-
Total Power W
-

Scanner Status

State
-
Interval
-
Last Duration
-
Total Miners
-
Success
-
Failed
-
Last Finished
-
Next Run
-
Last started: -
Scanner panel refreshed: -
Scanner poll count: 0

Issues / Alerts

Sorted by severity, MAC-first.
Total Issues
-
Critical
-
Warning
-
Info
-
Repair status actions ready.
Severity Issue MAC IP Unit Model Firmware Detail Last Seen

Models

ModelCount

Firmware

FirmwareCount

Miner Tables

Search by IP, MAC, serial, model, firmware, or unit.
Selected: 0
Repair status actions ready.

Status

Ready.
""" @app.get("/miners/{miner_id}/detail", response_class=HTMLResponse) def miner_detail_page(miner_id: str): return f""" MinerOps - Miner Detail
MinerOps Console

Miner Detail

Miner ID: {miner_id}
Back to Previous Page Back to Dashboard Back to Location Map Refresh Miner

Miner Controls

Current Mode: Loading...

Miner Detail

Miner ID: {miner_id}
← Back to Dashboard

Login

Not logged in.

Identity

Unit
-
MAC
-
IP
-
Hostname
-
Model
-
Miner Type
-
Firmware
-
API Version
-
Miner SN
-
Power SN
-
Status
-
Last Seen
-

Latest Telemetry

TH/s
-
Power W
-
W/TH
-
Max Chip C
-
Avg Chip C
-
Fan In
-
Fan Out
-
Hash Stable
-

Hashboards

Slot PCB SN Chip Data Temp C Freq Factory GHS Chips

Network Identity History

MAC IP Hostname Current First Seen Last Seen

Add Repair / Maintenance Note

Event Type
Status
Title
Component Type
Component Slot
Component Serial
Old MAC
New MAC
Downtime Minutes
Cost
Description / Notes
Ready to add a repair note.

Maintenance / Repair History

Date Type App Status Title Description Old MAC New MAC Component Slot Serial Downtime Created By

Recent Telemetry Snapshots

Observed IP TH/s Power W W/TH Env C Max C Avg C Fan In Fan Out Stable Rejected %

Status

Ready.
""" import asyncio import inspect import os import threading import time import traceback from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone def minerops_env_true(name: str) -> bool: import os value = str(os.getenv(name, "") or "").strip().lower() return value in {"1", "true", "yes", "on", "enabled"} def minerops_hub_mode_enabled() -> bool: return minerops_env_true("MINEROPS_HUB_MODE") or minerops_env_true("MINEROPS_DISABLE_LOCAL_SCANNER") SCHEDULED_SCANNER_STATE = { "enabled": os.getenv("SCHEDULED_SCAN_ENABLED", "true").lower() == "true", "interval_seconds": int(os.getenv("SCHEDULED_SCAN_INTERVAL_SECONDS", "120")), "max_workers": int(os.getenv("SCHEDULED_SCAN_MAX_WORKERS", "40")), "running": False, "thread_started": False, "last_started_at": None, "last_finished_at": None, "next_run_at": None, "last_duration_seconds": None, "last_reason": None, "last_total_miners": 0, "last_success_count": 0, "last_failure_count": 0, "last_errors": [], "total_runs": 0, } SCHEDULED_SCANNER_LOCK = threading.Lock() SCHEDULED_SCANNER_STOP = threading.Event() def scheduled_now_iso(): return datetime.now(timezone.utc).isoformat() def get_known_miners_for_scheduled_scan(): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT miners.id::text AS miner_id, host(miners.current_ip) AS ip_address, miners.room_id::text AS room_id, COALESCE(rooms.name, 'Unassigned') AS room_name, miners.current_mac FROM miners LEFT JOIN rooms ON rooms.id = miners.room_id WHERE miners.current_ip IS NOT NULL AND miners.room_id IN ( SELECT DISTINCT room_id FROM scan_ranges WHERE enabled = true AND room_id IS NOT NULL ) ORDER BY rooms.sort_order, miners.current_ip; """ ) return [dict(row) for row in cur.fetchall()] finally: if conn is not None: conn.close() def call_discover_one_miner_flexible(ip_address: str, room_id: str | None = None): func = globals().get("discover_one_miner") if func is None: raise RuntimeError("discover_one_miner function was not found") signature = inspect.signature(func) params = signature.parameters candidate_kwargs = { "ip_address": ip_address, "ip": ip_address, "host": ip_address, "room_id": room_id, "source": "scheduled_known_miner_scan", } kwargs = {} for name, param in params.items(): if name in candidate_kwargs: kwargs[name] = candidate_kwargs[name] missing_required = [] for name, param in params.items(): if param.default is inspect._empty and name not in kwargs: if param.kind in ( inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY, ): missing_required.append(name) if missing_required: raise RuntimeError( "discover_one_miner requires unsupported argument(s): " + ", ".join(missing_required) ) result = func(**kwargs) if inspect.iscoroutine(result): result = asyncio.run(result) return result def refresh_one_known_miner_for_schedule(miner_row: dict): ip_address = miner_row.get("ip_address") room_id = miner_row.get("room_id") if not ip_address: raise RuntimeError("Miner has no IP address") call_discover_one_miner_flexible(ip_address=ip_address, room_id=room_id) return { "miner_id": miner_row.get("miner_id"), "ip_address": ip_address, "mac": miner_row.get("current_mac"), "room_name": miner_row.get("room_name"), } def run_scheduled_known_miner_scan(reason: str = "scheduled"): if not SCHEDULED_SCANNER_LOCK.acquire(blocking=False): SCHEDULED_SCANNER_STATE["last_reason"] = "skipped_overlap" return started = time.time() SCHEDULED_SCANNER_STATE["running"] = True SCHEDULED_SCANNER_STATE["last_started_at"] = scheduled_now_iso() SCHEDULED_SCANNER_STATE["last_reason"] = reason SCHEDULED_SCANNER_STATE["last_errors"] = [] success_count = 0 failure_count = 0 errors = [] try: miners = get_known_miners_for_scheduled_scan() SCHEDULED_SCANNER_STATE["last_total_miners"] = len(miners) max_workers = max(1, int(SCHEDULED_SCANNER_STATE["max_workers"])) with ThreadPoolExecutor(max_workers=max_workers) as executor: future_map = { executor.submit(refresh_one_known_miner_for_schedule, miner): miner for miner in miners } for future in as_completed(future_map): miner = future_map[future] try: future.result() success_count += 1 except Exception as exc: failure_count += 1 if len(errors) < 25: errors.append( { "ip_address": miner.get("ip_address"), "mac": miner.get("current_mac"), "room_name": miner.get("room_name"), "error": str(exc), } ) SCHEDULED_SCANNER_STATE["last_success_count"] = success_count SCHEDULED_SCANNER_STATE["last_failure_count"] = failure_count SCHEDULED_SCANNER_STATE["last_errors"] = errors SCHEDULED_SCANNER_STATE["total_runs"] += 1 except Exception as exc: SCHEDULED_SCANNER_STATE["last_errors"] = [ { "error": str(exc), "traceback": traceback.format_exc(limit=3), } ] SCHEDULED_SCANNER_STATE["last_failure_count"] = failure_count finally: finished = time.time() SCHEDULED_SCANNER_STATE["running"] = False SCHEDULED_SCANNER_STATE["last_finished_at"] = scheduled_now_iso() SCHEDULED_SCANNER_STATE["last_duration_seconds"] = round(finished - started, 2) SCHEDULED_SCANNER_STATE["next_run_at"] = datetime.fromtimestamp( finished + int(SCHEDULED_SCANNER_STATE["interval_seconds"]), tz=timezone.utc, ).isoformat() SCHEDULED_SCANNER_LOCK.release() def scheduled_scanner_loop(): interval = max(60, int(SCHEDULED_SCANNER_STATE["interval_seconds"])) SCHEDULED_SCANNER_STATE["next_run_at"] = datetime.fromtimestamp( time.time() + 10, tz=timezone.utc, ).isoformat() SCHEDULED_SCANNER_STOP.wait(10) while not SCHEDULED_SCANNER_STOP.is_set(): if SCHEDULED_SCANNER_STATE["enabled"]: run_scheduled_known_miner_scan(reason="scheduled") SCHEDULED_SCANNER_STOP.wait(interval) @app.on_event("startup") def start_scheduled_scanner(): if SCHEDULED_SCANNER_STATE["thread_started"]: return if minerops_hub_mode_enabled(): SCHEDULED_SCANNER_STATE["enabled"] = False SCHEDULED_SCANNER_STATE["running"] = False SCHEDULED_SCANNER_STATE["thread_started"] = False SCHEDULED_SCANNER_STATE["last_reason"] = "hub_mode" SCHEDULED_SCANNER_STATE["last_errors"] = [ { "message": "Hub Mode: local scanner disabled. Waiting for Windows site agent updates." } ] SCHEDULED_SCANNER_STATE["next_run_at"] = None return SCHEDULED_SCANNER_STATE["thread_started"] = True thread = threading.Thread( target=scheduled_scanner_loop, name="minerops-scheduled-scanner", daemon=True, ) thread.start() @app.get("/api/scanner/status") def scanner_status(current_user: dict = Depends(get_current_user)): status = dict(SCHEDULED_SCANNER_STATE) if minerops_hub_mode_enabled(): status["enabled"] = False status["running"] = False status["hub_mode"] = True status["scanner_disabled"] = True status["last_reason"] = "hub_mode" status["last_errors"] = [ { "message": "Hub Mode: local scanner disabled. Waiting for Windows site agent updates." } ] status["next_run_at"] = None else: status["hub_mode"] = False status["scanner_disabled"] = False return api_response(data=status) @app.post("/api/scanner/run-known-now") def scanner_run_known_now(current_user: dict = Depends(get_current_user)): if minerops_hub_mode_enabled(): status = dict(SCHEDULED_SCANNER_STATE) status["enabled"] = False status["running"] = False status["hub_mode"] = True status["scanner_disabled"] = True status["last_reason"] = "hub_mode_manual_scan_blocked" status["last_errors"] = [ { "message": "Hub Mode: manual local scan disabled. Use the Windows site agent to poll miners." } ] status["next_run_at"] = None return api_response( data=status, meta={"message": "Hub Mode: local scanner disabled. Use the Windows site agent."}, ) if SCHEDULED_SCANNER_STATE["running"]: return api_response( data=SCHEDULED_SCANNER_STATE, meta={"message": "Scanner is already running."}, ) thread = threading.Thread( target=run_scheduled_known_miner_scan, kwargs={"reason": "manual"}, name="minerops-manual-known-scan", daemon=True, ) thread.start() return api_response( data=SCHEDULED_SCANNER_STATE, meta={"message": "Known-miner scan started."}, ) # Patch: allow the scheduled scanner to call discover_one_miner when it expects FastAPI request=None. def call_discover_one_miner_flexible(ip_address: str, room_id: str | None = None): func = globals().get("discover_one_miner") if func is None: raise RuntimeError("discover_one_miner function was not found") signature = inspect.signature(func) params = signature.parameters candidate_kwargs = { "request": None, "ip_address": ip_address, "ip": ip_address, "host": ip_address, "room_id": room_id, "source": "scheduled_known_miner_scan", } kwargs = {} for name, param in params.items(): if name in candidate_kwargs: kwargs[name] = candidate_kwargs[name] missing_required = [] for name, param in params.items(): if param.default is inspect._empty and name not in kwargs: if param.kind in ( inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY, ): missing_required.append(name) if missing_required: raise RuntimeError( "discover_one_miner requires unsupported argument(s): " + ", ".join(missing_required) ) result = func(**kwargs) if inspect.iscoroutine(result): result = asyncio.run(result) return result # Patch 2: scheduled scanner passes a body-like request object to discover_one_miner. from types import SimpleNamespace def call_discover_one_miner_flexible(ip_address: str, room_id: str | None = None): func = globals().get("discover_one_miner") if func is None: raise RuntimeError("discover_one_miner function was not found") signature = inspect.signature(func) params = signature.parameters request_body = SimpleNamespace( ip_address=ip_address, room_id=room_id, source="scheduled_known_miner_scan", update_existing=True, api_port=4028, ) candidate_kwargs = { "request": request_body, "body": request_body, "payload": request_body, "ip_address": ip_address, "ip": ip_address, "host": ip_address, "room_id": room_id, "source": "scheduled_known_miner_scan", } kwargs = {} for name, param in params.items(): if name in candidate_kwargs: kwargs[name] = candidate_kwargs[name] missing_required = [] for name, param in params.items(): if param.default is inspect._empty and name not in kwargs: if param.kind in ( inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY, ): missing_required.append(name) if missing_required: raise RuntimeError( "discover_one_miner requires unsupported argument(s): " + ", ".join(missing_required) ) result = func(**kwargs) if inspect.iscoroutine(result): result = asyncio.run(result) return result # Patch 3: stronger fake request object for scheduled known-miner scans. class ScheduledDiscoverRequest: def __init__(self, ip_address: str, room_id: str | None = None): self.ip_address = ip_address self.port = 4028 self.api_port = 4028 self.room_id = room_id self.source = "scheduled_known_miner_scan" self.update_existing = True self.timeout_seconds = 5 self.connect_timeout_seconds = 3 self.read_timeout_seconds = 5 self.scan_range_id = None self.max_ips = None self.specific_ips = None def model_dump(self): return self.__dict__ def dict(self): return self.__dict__ def __getattr__(self, name): return None def call_discover_one_miner_flexible(ip_address: str, room_id: str | None = None): func = globals().get("discover_one_miner") if func is None: raise RuntimeError("discover_one_miner function was not found") signature = inspect.signature(func) params = signature.parameters request_body = ScheduledDiscoverRequest( ip_address=ip_address, room_id=room_id, ) candidate_kwargs = { "request": request_body, "body": request_body, "payload": request_body, "ip_address": ip_address, "ip": ip_address, "host": ip_address, "port": 4028, "api_port": 4028, "room_id": room_id, "source": "scheduled_known_miner_scan", } kwargs = {} for name, param in params.items(): if name in candidate_kwargs: kwargs[name] = candidate_kwargs[name] missing_required = [] for name, param in params.items(): if param.default is inspect._empty and name not in kwargs: if param.kind in ( inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY, ): missing_required.append(name) if missing_required: raise RuntimeError( "discover_one_miner requires unsupported argument(s): " + ", ".join(missing_required) ) result = func(**kwargs) if inspect.iscoroutine(result): result = asyncio.run(result) return result # Patch 4: scheduled scanner request object with timeout_ms fields. class ScheduledDiscoverRequestV2: def __init__(self, ip_address: str, room_id: str | None = None): self.ip_address = ip_address self.host = ip_address self.ip = ip_address self.port = 4028 self.api_port = 4028 self.room_id = room_id self.source = "scheduled_known_miner_scan" self.update_existing = True # The existing Whatsminer read code expects millisecond timeout fields. self.timeout_ms = 5000 self.connect_timeout_ms = 3000 self.read_timeout_ms = 5000 self.command_timeout_ms = 5000 # Also include second-based versions in case other code uses them. self.timeout_seconds = 5 self.connect_timeout_seconds = 3 self.read_timeout_seconds = 5 self.retries = 1 self.retry_count = 1 self.scan_range_id = None self.max_ips = None self.specific_ips = None def model_dump(self): return self.__dict__ def dict(self): return self.__dict__ def __getattr__(self, name): if "timeout_ms" in name: return 5000 if "timeout_seconds" in name: return 5 if name in ("port", "api_port"): return 4028 if name in ("retries", "retry_count"): return 1 return None def call_discover_one_miner_flexible(ip_address: str, room_id: str | None = None): func = globals().get("discover_one_miner") if func is None: raise RuntimeError("discover_one_miner function was not found") signature = inspect.signature(func) params = signature.parameters request_body = ScheduledDiscoverRequestV2( ip_address=ip_address, room_id=room_id, ) candidate_kwargs = { "request": request_body, "body": request_body, "payload": request_body, "ip_address": ip_address, "ip": ip_address, "host": ip_address, "port": 4028, "api_port": 4028, "room_id": room_id, "source": "scheduled_known_miner_scan", "timeout_ms": 5000, "connect_timeout_ms": 3000, "read_timeout_ms": 5000, } kwargs = {} for name, param in params.items(): if name in candidate_kwargs: kwargs[name] = candidate_kwargs[name] missing_required = [] for name, param in params.items(): if param.default is inspect._empty and name not in kwargs: if param.kind in ( inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY, ): missing_required.append(name) if missing_required: raise RuntimeError( "discover_one_miner requires unsupported argument(s): " + ", ".join(missing_required) ) result = func(**kwargs) if inspect.iscoroutine(result): result = asyncio.run(result) return result # Patch 5: scheduled scanner passes an internal system user so FastAPI Depends is not used directly. def call_discover_one_miner_flexible(ip_address: str, room_id: str | None = None): func = globals().get("discover_one_miner") if func is None: raise RuntimeError("discover_one_miner function was not found") signature = inspect.signature(func) params = signature.parameters request_body = ScheduledDiscoverRequestV2( ip_address=ip_address, room_id=room_id, ) system_user = { "id": "00000000-0000-0000-0000-000000000000", "user_id": "00000000-0000-0000-0000-000000000000", "username": "system-scanner", "role": "system", "is_admin": True, } candidate_kwargs = { "request": request_body, "body": request_body, "payload": request_body, "ip_address": ip_address, "ip": ip_address, "host": ip_address, "port": 4028, "api_port": 4028, "room_id": room_id, "source": "scheduled_known_miner_scan", "timeout_ms": 5000, "connect_timeout_ms": 3000, "read_timeout_ms": 5000, "current_user": system_user, "user": system_user, "auth_user": system_user, } kwargs = {} for name, param in params.items(): if name in candidate_kwargs: kwargs[name] = candidate_kwargs[name] missing_required = [] for name, param in params.items(): if param.default is inspect._empty and name not in kwargs: if param.kind in ( inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY, ): missing_required.append(name) if missing_required: raise RuntimeError( "discover_one_miner requires unsupported argument(s): " + ", ".join(missing_required) ) result = func(**kwargs) if inspect.iscoroutine(result): result = asyncio.run(result) return result # Patch 6: scheduled scanner uses a real user from the users table for audit logs. def get_scheduled_scanner_user(): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id::text AS id, username FROM users ORDER BY CASE WHEN username = 'admin' THEN 0 ELSE 1 END, username LIMIT 1; """ ) user = cur.fetchone() if not user: raise RuntimeError("No users exist. Cannot run scheduled scanner audit logs.") return { "id": user["id"], "user_id": user["id"], "username": user["username"], "role": "system-scanner", "is_admin": True, } finally: if conn is not None: conn.close() def call_discover_one_miner_flexible(ip_address: str, room_id: str | None = None): func = globals().get("discover_one_miner") if func is None: raise RuntimeError("discover_one_miner function was not found") request_body = ScheduledDiscoverRequestV2( ip_address=ip_address, room_id=room_id, ) system_user = get_scheduled_scanner_user() result = func( request=request_body, current_user=system_user, ) if inspect.iscoroutine(result): result = asyncio.run(result) return result @app.get("/api/dashboard/issues") def dashboard_issues( min_temp_c: float = 90, stale_minutes: int = 30, rejected_percent: float = 1, current_user: dict = Depends(get_current_user), ): from collections import Counter, defaultdict def to_float(value): if value is None: return None try: return float(value) except Exception: return None def is_blank(value): return value is None or str(value).strip() == "" conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ WITH latest AS ( SELECT DISTINCT ON (miner_id) miner_id, observed_at, observed_ip, mhs_av, power_w, power_rate_w_per_th, chip_temp_max_c, chip_temp_avg_c, hash_stable, pool_rejected_percent FROM miner_status_snapshots ORDER BY miner_id, observed_at DESC ), hb AS ( SELECT miner_id, COUNT(*) AS hashboard_count, COUNT(*) FILTER ( WHERE pcb_sn IS NULL OR trim(pcb_sn) = '' OR lower(trim(pcb_sn)) = 'none' OR lower(trim(pcb_sn)) = 'unknown' ) AS missing_hashboard_sn_count FROM miner_hashboards GROUP BY miner_id ) SELECT miners.id::text AS miner_id, COALESCE(rooms.name, 'Unassigned') AS room_name, host(miners.current_ip) AS current_ip, miners.current_mac, miners.hostname, miners.miner_sn, miners.power_sn, miners.model, miners.miner_type, miners.firmware_version, miners.status, miners.last_seen_at, latest.observed_at, ROUND((latest.mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths, ROUND(latest.power_w::numeric, 2)::float AS power_w, ROUND(latest.power_rate_w_per_th::numeric, 2)::float AS power_rate_w_per_th, ROUND(latest.chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c, ROUND(latest.chip_temp_avg_c::numeric, 2)::float AS chip_temp_avg_c, latest.hash_stable, ROUND(latest.pool_rejected_percent::numeric, 4)::float AS pool_rejected_percent, COALESCE(hb.hashboard_count, 0) AS hashboard_count, COALESCE(hb.missing_hashboard_sn_count, 0) AS missing_hashboard_sn_count, CASE WHEN miners.last_seen_at IS NULL THEN true WHEN miners.last_seen_at < now() - make_interval(mins => %s) THEN true ELSE false END AS is_stale, ROUND((EXTRACT(EPOCH FROM (now() - miners.last_seen_at)) / 60.0)::numeric, 1)::float AS minutes_since_seen FROM miners LEFT JOIN rooms ON rooms.id = miners.room_id LEFT JOIN latest ON latest.miner_id = miners.id LEFT JOIN hb ON hb.miner_id = miners.id ORDER BY rooms.sort_order, miners.current_mac; """, (stale_minutes,), ) rows = [dict(row) for row in cur.fetchall()] firmware_counts_by_model = defaultdict(Counter) for row in rows: model_key = row.get("miner_type") or row.get("model") firmware = row.get("firmware_version") if not is_blank(model_key) and not is_blank(firmware): firmware_counts_by_model[model_key][firmware] += 1 common_firmware_by_model = {} for model_key, counter in firmware_counts_by_model.items(): total = sum(counter.values()) if total < 5: continue firmware, count = counter.most_common(1)[0] if count / total >= 0.7: common_firmware_by_model[model_key] = firmware issues = [] def add_issue(row, issue_type, severity, title, detail, value=None): issues.append( { "issue_type": issue_type, "severity": severity, "title": title, "detail": detail, "value": value, "miner_id": row.get("miner_id"), "room_name": row.get("room_name"), "current_mac": row.get("current_mac"), "current_ip": row.get("current_ip"), "model": row.get("miner_type") or row.get("model"), "firmware_version": row.get("firmware_version"), "last_seen_at": row.get("last_seen_at"), "observed_at": row.get("observed_at"), } ) for row in rows: chip_temp_max_c = to_float(row.get("chip_temp_max_c")) pool_rejected_percent = to_float(row.get("pool_rejected_percent")) minutes_since_seen = to_float(row.get("minutes_since_seen")) if chip_temp_max_c is not None and chip_temp_max_c >= min_temp_c: severity = "critical" if chip_temp_max_c >= 95 else "warning" add_issue( row, "HOT_MINER", severity, "Hot miner", f"Max chip temperature is {chip_temp_max_c:.1f} C.", chip_temp_max_c, ) if row.get("hash_stable") is False: add_issue( row, "HASH_UNSTABLE", "warning", "Hash unstable", "Miner reports hash_stable=false.", None, ) if pool_rejected_percent is not None and pool_rejected_percent >= rejected_percent: add_issue( row, "HIGH_REJECTED", "warning", "High rejected pool percentage", f"Pool rejected percentage is {pool_rejected_percent:.4f}%.", pool_rejected_percent, ) if row.get("is_stale") is True: value_text = "unknown" if minutes_since_seen is None else f"{minutes_since_seen:.1f} minutes" add_issue( row, "STALE_MINER", "critical", "Stale miner", f"Miner has not been seen in {value_text}.", minutes_since_seen, ) if is_blank(row.get("current_mac")): add_issue( row, "MISSING_MAC", "critical", "Missing MAC address", "Miner does not have a current MAC address recorded.", None, ) if is_blank(row.get("miner_sn")): add_issue( row, "MISSING_MINER_SN", "warning", "Missing miner serial number", "Miner serial number is not recorded.", None, ) if is_blank(row.get("power_sn")): add_issue( row, "MISSING_POWER_SN", "warning", "Missing power supply serial number", "Power supply serial number is not recorded.", None, ) missing_hashboard_sn_count = int(row.get("missing_hashboard_sn_count") or 0) hashboard_count = int(row.get("hashboard_count") or 0) if hashboard_count > 0 and missing_hashboard_sn_count > 0: add_issue( row, "MISSING_HASHBOARD_SN", "warning", "Missing hashboard serials", f"{missing_hashboard_sn_count} of {hashboard_count} hashboards are missing PCB serial numbers.", missing_hashboard_sn_count, ) model_key = row.get("miner_type") or row.get("model") firmware = row.get("firmware_version") expected_firmware = common_firmware_by_model.get(model_key) if ( not is_blank(model_key) and not is_blank(firmware) and not is_blank(expected_firmware) and firmware != expected_firmware ): add_issue( row, "FIRMWARE_MINOR_VERSION", "info", "Firmware differs from model majority", f"{model_key} is running {firmware}; most miners of this model are running {expected_firmware}.", firmware, ) severity_rank = { "critical": 0, "warning": 1, "info": 2, } issues.sort( key=lambda issue: ( severity_rank.get(issue["severity"], 99), issue["issue_type"], issue.get("room_name") or "", issue.get("current_mac") or "", ) ) severity_counts = Counter(issue["severity"] for issue in issues) type_counts = Counter(issue["issue_type"] for issue in issues) return api_response( data=issues, meta={ "total_issues": len(issues), "severity_counts": dict(severity_counts), "type_counts": dict(type_counts), "min_temp_c": min_temp_c, "stale_minutes": stale_minutes, "rejected_percent": rejected_percent, }, ) finally: if conn is not None: conn.close() # ----------------------------- # Phase 2: Maintenance / repair history # ----------------------------- from pydantic import BaseModel from uuid import uuid4 class MaintenanceEventCreate(BaseModel): event_type: str event_status: str = "completed" event_title: str description: str | None = None event_at: str | None = None old_mac: str | None = None new_mac: str | None = None old_ip: str | None = None new_ip: str | None = None component_type: str | None = None component_slot: str | None = None component_serial: str | None = None cost_amount: float | None = None downtime_minutes: int | None = None class MaintenanceEventUpdate(BaseModel): event_type: str | None = None event_status: str | None = None event_title: str | None = None description: str | None = None event_at: str | None = None old_mac: str | None = None new_mac: str | None = None old_ip: str | None = None new_ip: str | None = None component_type: str | None = None component_slot: str | None = None component_serial: str | None = None cost_amount: float | None = None downtime_minutes: int | None = None def get_current_user_id_for_maintenance(current_user: dict): if isinstance(current_user, dict): return current_user.get("id") or current_user.get("user_id") return None def ensure_maintenance_tables(): conn = None try: conn = get_db_connection() with conn.cursor() as cur: cur.execute( """ CREATE TABLE IF NOT EXISTS miner_maintenance_events ( id uuid PRIMARY KEY, miner_id uuid NOT NULL REFERENCES miners(id) ON DELETE CASCADE, event_type text NOT NULL, event_status text NOT NULL DEFAULT 'completed', event_title text NOT NULL, description text, event_at timestamptz NOT NULL DEFAULT now(), old_mac text, new_mac text, old_ip inet, new_ip inet, component_type text, component_slot text, component_serial text, cost_amount numeric(12, 2), downtime_minutes integer, created_by_user_id uuid NULL REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_miner_id ON miner_maintenance_events(miner_id); CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_event_at ON miner_maintenance_events(event_at DESC); CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_event_type ON miner_maintenance_events(event_type); """ ) conn.commit() finally: if conn is not None: conn.close() @app.on_event("startup") def startup_ensure_maintenance_tables(): ensure_maintenance_tables() @app.get("/api/maintenance/event-types") def maintenance_event_types(current_user: dict = Depends(get_current_user)): return api_response( data=[ {"value": "general_note", "label": "General note"}, {"value": "control_board_changed", "label": "Control board changed"}, {"value": "mac_changed", "label": "MAC changed"}, {"value": "hashboard_replaced", "label": "Hashboard replaced"}, {"value": "psu_replaced", "label": "PSU replaced"}, {"value": "fan_replaced", "label": "Fan replaced"}, {"value": "cleaned", "label": "Cleaned"}, {"value": "moved", "label": "Moved"}, {"value": "firmware_updated", "label": "Firmware updated"}, {"value": "troubleshooting", "label": "Troubleshooting"}, ] ) @app.get("/api/miners/{miner_id}/maintenance-events") def list_miner_maintenance_events( miner_id: str, current_user: dict = Depends(get_current_user), ): ensure_maintenance_tables() conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT e.id::text, e.miner_id::text, e.event_type, e.event_status, e.event_title, e.description, e.event_at, e.old_mac, e.new_mac, host(e.old_ip) AS old_ip, host(e.new_ip) AS new_ip, e.component_type, e.component_slot, e.component_serial, e.cost_amount::float, e.downtime_minutes, e.created_by_user_id::text, users.username AS created_by_username, e.created_at, e.updated_at FROM miner_maintenance_events e LEFT JOIN users ON users.id = e.created_by_user_id WHERE e.miner_id = %s::uuid ORDER BY e.event_at DESC, e.created_at DESC; """, (miner_id,), ) rows = [dict(row) for row in cur.fetchall()] return api_response(data=rows) finally: if conn is not None: conn.close() @app.post("/api/miners/{miner_id}/maintenance-events") def create_miner_maintenance_event( miner_id: str, request: MaintenanceEventCreate, current_user: dict = Depends(get_current_user), ): ensure_maintenance_tables() conn = None try: conn = get_db_connection() event_id = str(uuid4()) user_id = get_current_user_id_for_maintenance(current_user) with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id::text FROM miners WHERE id = %s::uuid; """, (miner_id,), ) miner = cur.fetchone() if not miner: raise HTTPException(status_code=404, detail="Miner not found") cur.execute( """ INSERT INTO miner_maintenance_events ( id, miner_id, event_type, event_status, event_title, description, event_at, old_mac, new_mac, old_ip, new_ip, component_type, component_slot, component_serial, cost_amount, downtime_minutes, created_by_user_id ) VALUES ( %s::uuid, %s::uuid, %s, %s, %s, %s, COALESCE(%s::timestamptz, now()), %s, %s, NULLIF(%s, '')::inet, NULLIF(%s, '')::inet, %s, %s, %s, %s, %s, %s::uuid ) RETURNING id::text, miner_id::text, event_type, event_status, event_title, description, event_at, old_mac, new_mac, host(old_ip) AS old_ip, host(new_ip) AS new_ip, component_type, component_slot, component_serial, cost_amount::float, downtime_minutes, created_by_user_id::text, created_at, updated_at; """, ( event_id, miner_id, request.event_type, request.event_status, request.event_title, request.description, request.event_at, request.old_mac, request.new_mac, request.old_ip, request.new_ip, request.component_type, request.component_slot, request.component_serial, request.cost_amount, request.downtime_minutes, user_id, ), ) row = dict(cur.fetchone()) conn.commit() return api_response(data=row) except Exception: if conn is not None: conn.rollback() raise finally: if conn is not None: conn.close() @app.patch("/api/miners/{miner_id}/maintenance-events/{event_id}") def update_miner_maintenance_event( miner_id: str, event_id: str, request: MaintenanceEventUpdate, current_user: dict = Depends(get_current_user), ): ensure_maintenance_tables() update_data = request.model_dump(exclude_unset=True) if not update_data: raise HTTPException(status_code=400, detail="No fields supplied for update") allowed_fields = { "event_type", "event_status", "event_title", "description", "event_at", "old_mac", "new_mac", "old_ip", "new_ip", "component_type", "component_slot", "component_serial", "cost_amount", "downtime_minutes", } set_parts = [] values = [] for key, value in update_data.items(): if key not in allowed_fields: continue if key in ("old_ip", "new_ip"): set_parts.append(f"{key} = NULLIF(%s, '')::inet") elif key == "event_at": set_parts.append(f"{key} = %s::timestamptz") else: set_parts.append(f"{key} = %s") values.append(value) if not set_parts: raise HTTPException(status_code=400, detail="No valid fields supplied for update") set_parts.append("updated_at = now()") values.append(miner_id) values.append(event_id) sql = f""" UPDATE miner_maintenance_events SET {", ".join(set_parts)} WHERE miner_id = %s::uuid AND id = %s::uuid RETURNING id::text, miner_id::text, event_type, event_status, event_title, description, event_at, old_mac, new_mac, host(old_ip) AS old_ip, host(new_ip) AS new_ip, component_type, component_slot, component_serial, cost_amount::float, downtime_minutes, created_by_user_id::text, created_at, updated_at; """ conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute(sql, values) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Maintenance event not found") row = dict(row) conn.commit() return api_response(data=row) except Exception: if conn is not None: conn.rollback() raise finally: if conn is not None: conn.close() @app.delete("/api/miners/{miner_id}/maintenance-events/{event_id}") def delete_miner_maintenance_event( miner_id: str, event_id: str, current_user: dict = Depends(get_current_user), ): ensure_maintenance_tables() conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ DELETE FROM miner_maintenance_events WHERE miner_id = %s::uuid AND id = %s::uuid RETURNING id::text; """, (miner_id, event_id), ) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Maintenance event not found") conn.commit() return api_response(data={"deleted": True, "id": event_id}) except Exception: if conn is not None: conn.rollback() raise finally: if conn is not None: conn.close() class MinerMacChangeRequest(BaseModel): model_config = ConfigDict(extra="forbid") new_mac: str = Field(min_length=1, max_length=64) old_mac: str | None = None new_ip: str | None = None event_title: str | None = None description: str | None = Field(default=None, max_length=4000) component_serial: str | None = Field(default=None, max_length=255) downtime_minutes: int | None = Field(default=None, ge=0) removed_board_disposition: RemovedBoardDisposition | None = None replacement_reason: ControlBoardReplacementReason | None = None @app.post("/api/miners/{miner_id}/change-mac") def change_miner_mac_address( miner_id: str, request: MinerMacChangeRequest, current_user: dict = Depends(get_current_user), ): """Compatibility alias for the repair-scoped replacement workflow.""" if request.replacement_reason is None or request.removed_board_disposition is None: raise HTTPException( status_code=422, detail=( "The compatibility route requires replacement_reason and " "removed_board_disposition; use the repair-scoped replacement fields" ), ) if request.new_ip is not None or request.downtime_minutes is not None: raise HTTPException( status_code=422, detail=( "The repair-scoped compatibility route does not change IP addresses " "or record mutable downtime values" ), ) try: parsed_miner_id = UUID(miner_id) except ValueError as exc: raise HTTPException(status_code=422, detail="Invalid miner UUID") from exc try: replacement = ControlBoardReplacementRequest( new_mac_address=request.new_mac, new_control_board_serial=request.component_serial, removed_board_disposition=request.removed_board_disposition, replacement_reason=request.replacement_reason, notes=request.description, ) except ValidationError as exc: raise HTTPException( status_code=422, detail="Legacy change-mac request is invalid" ) from exc try: result = RepairService(RepairRepository()).replace_control_board( parsed_miner_id, replacement, actor_user_id=UUID(str(current_user["id"])), ) except RepairMinerNotFound as exc: raise HTTPException(status_code=404, detail="Miner not found") from exc except RepairConflict as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except (InvalidMacAddress, ControlBoardReplacementActorRequired) as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc replacement_data = result.model_dump(mode="json") return api_response( data={ "miner": { "id": replacement_data["miner_id"], "current_mac": replacement_data["current_mac"], "control_board_sn": replacement_data["control_board_serial"], }, "control_board_replacement": replacement_data, } ) @app.get("/api/maintenance/events") def list_all_maintenance_events( limit: int = 100, event_status: str | None = None, event_type: str | None = None, q: str | None = None, current_user: dict = Depends(get_current_user), ): from collections import Counter ensure_maintenance_tables() safe_limit = max(1, min(int(limit or 100), 500)) where_parts = ["1 = 1"] values = [] if event_status: where_parts.append("e.event_status = %s") values.append(event_status) if event_type: where_parts.append("e.event_type = %s") values.append(event_type) if q: search_value = f"%{q.strip()}%" where_parts.append( """ ( e.event_title ILIKE %s OR e.description ILIKE %s OR e.event_type ILIKE %s OR e.event_status ILIKE %s OR e.old_mac ILIKE %s OR e.new_mac ILIKE %s OR e.component_type ILIKE %s OR e.component_slot ILIKE %s OR e.component_serial ILIKE %s OR miners.current_mac ILIKE %s OR host(miners.current_ip) ILIKE %s OR miners.miner_sn ILIKE %s OR miners.power_sn ILIKE %s OR rooms.name ILIKE %s ) """ ) values.extend([search_value] * 14) values.append(safe_limit) sql = f""" SELECT e.id::text AS event_id, e.miner_id::text AS miner_id, COALESCE(rooms.name, 'Unassigned') AS room_name, miners.current_mac, host(miners.current_ip) AS current_ip, miners.hostname, miners.miner_sn, miners.power_sn, miners.model, miners.miner_type, miners.firmware_version, e.event_type, e.event_status, e.event_title, e.description, e.event_at, e.old_mac, e.new_mac, host(e.old_ip) AS old_ip, host(e.new_ip) AS new_ip, e.component_type, e.component_slot, e.component_serial, e.cost_amount::float, e.downtime_minutes, e.created_by_user_id::text, users.username AS created_by_username, e.created_at, e.updated_at FROM miner_maintenance_events e INNER JOIN miners ON miners.id = e.miner_id LEFT JOIN rooms ON rooms.id = miners.room_id LEFT JOIN users ON users.id = e.created_by_user_id WHERE {" AND ".join(where_parts)} ORDER BY e.event_at DESC, e.created_at DESC LIMIT %s; """ conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute(sql, values) rows = [dict(row) for row in cur.fetchall()] status_counts = Counter(row.get("event_status") for row in rows) type_counts = Counter(row.get("event_type") for row in rows) room_counts = Counter(row.get("room_name") for row in rows) return api_response( data=rows, meta={ "returned": len(rows), "limit": safe_limit, "status_counts": dict(status_counts), "type_counts": dict(type_counts), "room_counts": dict(room_counts), "filters": { "event_status": event_status, "event_type": event_type, "q": q, }, }, ) finally: if conn is not None: conn.close() @app.get("/api/maintenance/events/export.csv") def export_all_maintenance_events_csv( limit: int = 5000, event_status: str | None = None, event_type: str | None = None, q: str | None = None, current_user: dict = Depends(get_current_user), ): import csv import io from datetime import datetime, timezone from fastapi.responses import StreamingResponse ensure_maintenance_tables() safe_limit = max(1, min(int(limit or 5000), 20000)) where_parts = ["1 = 1"] values = [] if event_status: where_parts.append("e.event_status = %s") values.append(event_status) if event_type: where_parts.append("e.event_type = %s") values.append(event_type) if q: search_value = f"%{q.strip()}%" where_parts.append( """ ( e.event_title ILIKE %s OR e.description ILIKE %s OR e.event_type ILIKE %s OR e.event_status ILIKE %s OR e.old_mac ILIKE %s OR e.new_mac ILIKE %s OR e.component_type ILIKE %s OR e.component_slot ILIKE %s OR e.component_serial ILIKE %s OR miners.current_mac ILIKE %s OR host(miners.current_ip) ILIKE %s OR miners.miner_sn ILIKE %s OR miners.power_sn ILIKE %s OR rooms.name ILIKE %s ) """ ) values.extend([search_value] * 14) values.append(safe_limit) sql = f""" SELECT e.event_at, e.event_status, e.event_type, e.event_title, e.description, COALESCE(rooms.name, 'Unassigned') AS room_name, miners.current_mac, host(miners.current_ip) AS current_ip, miners.hostname, miners.miner_sn, miners.power_sn, miners.model, miners.miner_type, miners.firmware_version, e.old_mac, e.new_mac, host(e.old_ip) AS old_ip, host(e.new_ip) AS new_ip, e.component_type, e.component_slot, e.component_serial, e.cost_amount::float, e.downtime_minutes, users.username AS created_by_username, e.created_at, e.updated_at, e.id::text AS event_id, e.miner_id::text AS miner_id FROM miner_maintenance_events e INNER JOIN miners ON miners.id = e.miner_id LEFT JOIN rooms ON rooms.id = miners.room_id LEFT JOIN users ON users.id = e.created_by_user_id WHERE {" AND ".join(where_parts)} ORDER BY e.event_at DESC, e.created_at DESC LIMIT %s; """ conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute(sql, values) rows = [dict(row) for row in cur.fetchall()] output = io.StringIO() fieldnames = [ "event_at", "event_status", "event_type", "event_title", "description", "room_name", "current_mac", "current_ip", "hostname", "miner_sn", "power_sn", "model", "miner_type", "firmware_version", "old_mac", "new_mac", "old_ip", "new_ip", "component_type", "component_slot", "component_serial", "cost_amount", "downtime_minutes", "created_by_username", "created_at", "updated_at", "event_id", "miner_id", ] writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore") writer.writeheader() for row in rows: cleaned = {} for field in fieldnames: value = row.get(field) if value is None: cleaned[field] = "" else: cleaned[field] = str(value) writer.writerow(cleaned) timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") filename = f"minerops-maintenance-events-{timestamp}.csv" output.seek(0) return StreamingResponse( iter([output.getvalue()]), media_type="text/csv", headers={ "Content-Disposition": f'attachment; filename="{filename}"' }, ) finally: if conn is not None: conn.close() # ----------------------------- # Phase 3: Physical location mapping # ----------------------------- class LocationSeedImportRequest(BaseModel): schema_version: int = 1 generated_from: list[str] | None = None notes: str | None = None virtual_states: list[str] | None = None summary: dict | None = None positions: list[dict] def normalize_mac_for_location(mac: str | None): if mac is None: return None cleaned = "".join(ch for ch in str(mac).strip().lower() if ch in "0123456789abcdef") if len(cleaned) != 12: return None return ":".join(cleaned[i:i + 2] for i in range(0, 12, 2)).upper() def normalize_mac_key_for_location(mac: str | None): normalized = normalize_mac_for_location(mac) if normalized is None: return None return normalized.replace(":", "").lower() def get_current_user_id_for_location(current_user: dict): if isinstance(current_user, dict): return current_user.get("id") or current_user.get("user_id") return None def ensure_location_tables(): conn = None try: conn = get_db_connection() with conn.cursor() as cur: cur.execute( """ CREATE TABLE IF NOT EXISTS location_positions ( id uuid PRIMARY KEY, room_id uuid NULL REFERENCES rooms(id) ON DELETE SET NULL, room_name text NOT NULL, rack_name text NOT NULL, shelf_name text NOT NULL, shelf_index integer, position_index integer NOT NULL, position_label text, position_kind text NOT NULL DEFAULT 'miner_slot', position_status text NOT NULL DEFAULT 'available', expected_mac text, miner_id uuid NULL REFERENCES miners(id) ON DELETE SET NULL, equipment_label text, notes text, source_file text, source_sheet text, source_cell text, raw_value text, conflict_reason text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); DO $$ BEGIN ALTER TABLE location_positions ADD CONSTRAINT uq_location_positions_slot UNIQUE (room_name, rack_name, shelf_name, position_index); EXCEPTION WHEN duplicate_object OR duplicate_table THEN NULL; END $$; CREATE INDEX IF NOT EXISTS idx_location_positions_room ON location_positions(room_name); CREATE INDEX IF NOT EXISTS idx_location_positions_rack ON location_positions(room_name, rack_name); CREATE INDEX IF NOT EXISTS idx_location_positions_expected_mac ON location_positions(expected_mac); CREATE INDEX IF NOT EXISTS idx_location_positions_miner_id ON location_positions(miner_id); CREATE TABLE IF NOT EXISTS miner_location_states ( miner_id uuid PRIMARY KEY REFERENCES miners(id) ON DELETE CASCADE, asset_state text NOT NULL DEFAULT 'waiting_placement', location_position_id uuid NULL REFERENCES location_positions(id) ON DELETE SET NULL, state_notes text, updated_by_user_id uuid NULL REFERENCES users(id) ON DELETE SET NULL, updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_miner_location_states_asset_state ON miner_location_states(asset_state); CREATE TABLE IF NOT EXISTS location_import_issues ( id uuid PRIMARY KEY, import_batch_id uuid NOT NULL, issue_type text NOT NULL, severity text NOT NULL DEFAULT 'warning', message text NOT NULL, mac_address text, room_name text, rack_name text, shelf_name text, position_index integer, source_file text, source_cell text, created_at timestamptz NOT NULL DEFAULT now(), resolved_at timestamptz NULL ); CREATE INDEX IF NOT EXISTS idx_location_import_issues_batch ON location_import_issues(import_batch_id); CREATE INDEX IF NOT EXISTS idx_location_import_issues_mac ON location_import_issues(mac_address); """ ) conn.commit() finally: if conn is not None: conn.close() @app.on_event("startup") def startup_ensure_location_tables(): ensure_location_tables() def get_room_id_by_name(cur, room_name: str): cur.execute( """ SELECT id::text FROM rooms WHERE lower(name) = lower(%s) LIMIT 1; """, (room_name,), ) row = cur.fetchone() if row: return row["id"] if isinstance(row, dict) else row[0] return None def find_or_create_placeholder_miner_for_location(cur, mac_address: str, room_id: str | None): mac_key = normalize_mac_key_for_location(mac_address) if not mac_key: return None, False cur.execute( """ SELECT id::text FROM miners WHERE regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g') = %s LIMIT 1; """, (mac_key,), ) row = cur.fetchone() if row: miner_id = row["id"] if isinstance(row, dict) else row[0] if room_id: cur.execute( """ UPDATE miners SET room_id = COALESCE(%s::uuid, room_id) WHERE id = %s::uuid; """, (room_id, miner_id), ) return miner_id, False miner_id = str(uuid4()) cur.execute( """ SELECT column_name FROM information_schema.columns WHERE table_name = 'miners'; """ ) miner_columns = {row["column_name"] if isinstance(row, dict) else row[0] for row in cur.fetchall()} insert_columns = [] insert_values_sql = [] values = [] def add_value(column_name, value, sql_cast=None): if column_name not in miner_columns: return insert_columns.append(column_name) if sql_cast: insert_values_sql.append(f"%s::{sql_cast}") else: insert_values_sql.append("%s") values.append(value) def add_now(column_name): if column_name not in miner_columns: return insert_columns.append(column_name) insert_values_sql.append("now()") add_value("id", miner_id, "uuid") add_value("room_id", room_id, "uuid") add_value("current_mac", mac_address) add_value("status", "offline") add_value("hostname", None) add_value("model", None) add_value("miner_type", None) add_value("firmware_version", None) add_value("miner_sn", None) add_value("power_sn", None) add_now("created_at") add_now("updated_at") sql = f""" INSERT INTO miners ({", ".join(insert_columns)}) VALUES ({", ".join(insert_values_sql)}) RETURNING id::text; """ cur.execute(sql, values) created = cur.fetchone() if created: miner_id = created["id"] if isinstance(created, dict) else created[0] return miner_id, True @app.post("/api/location/import-layout-seed") def import_location_layout_seed( request: LocationSeedImportRequest, current_user: dict = Depends(get_current_user), ): ensure_location_tables() if not request.positions: raise HTTPException(status_code=400, detail="No positions found in layout seed") import_batch_id = str(uuid4()) user_id = get_current_user_id_for_location(current_user) conn = None counts = { "positions_seen": 0, "positions_upserted": 0, "miner_positions": 0, "switch_positions": 0, "blocked_positions": 0, "empty_positions": 0, "placeholder_miners_created": 0, "existing_miners_matched": 0, "duplicate_conflicts": 0, "miners_marked_waiting_placement": 0, } try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ DELETE FROM location_import_issues WHERE resolved_at IS NULL; """ ) mac_counts = {} for position in request.positions: mac = normalize_mac_for_location(position.get("mac_address")) if mac: mac_counts[mac] = mac_counts.get(mac, 0) + 1 duplicate_macs = {mac for mac, count in mac_counts.items() if count > 1} for position in request.positions: counts["positions_seen"] += 1 room_name = str(position.get("room_name") or "").strip() rack_name = str(position.get("rack_name") or "").strip() shelf_name = str(position.get("shelf_name") or "").strip() position_index = int(position.get("position_index") or 0) if not room_name or not rack_name or not shelf_name or position_index <= 0: continue item_type = str(position.get("item_type") or "empty").strip().lower() mac_address = normalize_mac_for_location(position.get("mac_address")) room_id = get_room_id_by_name(cur, room_name) position_kind = "miner_slot" position_status = str(position.get("position_status") or "available").strip().lower() equipment_label = position.get("label") expected_mac = None miner_id = None conflict_reason = position.get("conflict_reason") if item_type == "switch": position_kind = "switch" position_status = "switch" counts["switch_positions"] += 1 elif item_type == "blocked": position_kind = "blocked" position_status = "blocked" counts["blocked_positions"] += 1 elif item_type == "miner" and mac_address: position_kind = "miner_slot" expected_mac = mac_address counts["miner_positions"] += 1 miner_id, was_created = find_or_create_placeholder_miner_for_location(cur, mac_address, room_id) if was_created: counts["placeholder_miners_created"] += 1 else: counts["existing_miners_matched"] += 1 if mac_address in duplicate_macs: position_status = "conflict" conflict_reason = "duplicate_mac_in_layout" counts["duplicate_conflicts"] += 1 cur.execute( """ INSERT INTO location_import_issues ( id, import_batch_id, issue_type, severity, message, mac_address, room_name, rack_name, shelf_name, position_index, source_file, source_cell ) VALUES ( %s::uuid, %s::uuid, 'duplicate_mac_in_layout', 'warning', %s, %s, %s, %s, %s, %s, %s, %s ); """, ( str(uuid4()), import_batch_id, f"Duplicate MAC {mac_address} appears in more than one physical map position.", mac_address, room_name, rack_name, shelf_name, position_index, position.get("source_file"), position.get("source_cell"), ), ) else: position_status = "occupied" else: position_kind = "miner_slot" position_status = "available" counts["empty_positions"] += 1 cur.execute( """ INSERT INTO location_positions ( id, room_id, room_name, rack_name, shelf_name, shelf_index, position_index, position_label, position_kind, position_status, expected_mac, miner_id, equipment_label, notes, source_file, source_sheet, source_cell, raw_value, conflict_reason, updated_at ) VALUES ( %s::uuid, %s::uuid, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::uuid, %s, %s, %s, %s, %s, %s, %s, now() ) ON CONFLICT (room_name, rack_name, shelf_name, position_index) DO UPDATE SET room_id = EXCLUDED.room_id, position_label = EXCLUDED.position_label, position_kind = EXCLUDED.position_kind, position_status = EXCLUDED.position_status, expected_mac = EXCLUDED.expected_mac, miner_id = EXCLUDED.miner_id, equipment_label = EXCLUDED.equipment_label, notes = EXCLUDED.notes, source_file = EXCLUDED.source_file, source_sheet = EXCLUDED.source_sheet, source_cell = EXCLUDED.source_cell, raw_value = EXCLUDED.raw_value, conflict_reason = EXCLUDED.conflict_reason, updated_at = now() RETURNING id::text; """, ( str(uuid4()), room_id, room_name, rack_name, shelf_name, position.get("shelf_index"), position_index, position.get("position_label"), position_kind, position_status, expected_mac, miner_id, equipment_label, position.get("notes"), position.get("source_file"), position.get("source_sheet"), position.get("source_cell"), position.get("raw_value"), conflict_reason, ), ) position_row = cur.fetchone() location_position_id = position_row["id"] counts["positions_upserted"] += 1 if miner_id: cur.execute( """ INSERT INTO miner_location_states ( miner_id, asset_state, location_position_id, updated_by_user_id, updated_at ) VALUES ( %s::uuid, 'shelved', %s::uuid, %s::uuid, now() ) ON CONFLICT (miner_id) DO UPDATE SET asset_state = 'shelved', location_position_id = EXCLUDED.location_position_id, updated_by_user_id = EXCLUDED.updated_by_user_id, updated_at = now(); """, ( miner_id, location_position_id, user_id, ), ) cur.execute( """ INSERT INTO miner_location_states ( miner_id, asset_state, location_position_id, updated_by_user_id, updated_at ) SELECT miners.id, 'waiting_placement', NULL, %s::uuid, now() FROM miners WHERE NOT EXISTS ( SELECT 1 FROM location_positions WHERE location_positions.miner_id = miners.id ) ON CONFLICT (miner_id) DO UPDATE SET asset_state = CASE WHEN miner_location_states.asset_state IN ('storage', 'repair', 'decommissioned') THEN miner_location_states.asset_state ELSE 'waiting_placement' END, location_position_id = CASE WHEN miner_location_states.asset_state IN ('storage', 'repair', 'decommissioned') THEN miner_location_states.location_position_id ELSE NULL END, updated_by_user_id = EXCLUDED.updated_by_user_id, updated_at = now(); """, (user_id,), ) counts["miners_marked_waiting_placement"] = cur.rowcount conn.commit() return api_response( data={ "import_batch_id": import_batch_id, "counts": counts, "duplicate_macs": sorted(list(duplicate_macs)), } ) except Exception: if conn is not None: conn.rollback() raise finally: if conn is not None: conn.close() @app.get("/api/location/import-issues") def list_location_import_issues( current_user: dict = Depends(get_current_user), ): ensure_location_tables() conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id::text, import_batch_id::text, issue_type, severity, message, mac_address, room_name, rack_name, shelf_name, position_index, source_file, source_cell, created_at, resolved_at FROM location_import_issues WHERE resolved_at IS NULL ORDER BY created_at DESC, mac_address, room_name, rack_name, shelf_name, position_index; """ ) rows = [dict(row) for row in cur.fetchall()] return api_response(data=rows) finally: if conn is not None: conn.close() def get_legacy_location_map( current_user: dict = Depends(get_current_user), ): ensure_location_tables() conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ WITH latest AS ( SELECT DISTINCT ON (miner_id) miner_id, observed_at, ROUND((mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths, ROUND(power_w::numeric, 2)::float AS power_w, ROUND(chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c, ROUND(chip_temp_avg_c::numeric, 2)::float AS chip_temp_avg_c, hash_stable, pool_rejected_percent FROM miner_status_snapshots ORDER BY miner_id, observed_at DESC ) SELECT lp.id::text AS location_position_id, lp.room_name, lp.rack_name, lp.shelf_name, lp.shelf_index, lp.position_index, lp.position_label, lp.position_kind, lp.position_status, lp.expected_mac, lp.equipment_label, lp.notes, lp.source_file, lp.source_cell, lp.conflict_reason, miners.id::text AS miner_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.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 FROM location_positions lp LEFT JOIN rooms room_order ON lower(room_order.name) = lower(lp.room_name) LEFT JOIN miners ON miners.id = lp.miner_id LEFT JOIN miner_location_states mls ON mls.miner_id = miners.id LEFT JOIN latest ON latest.miner_id = miners.id ORDER BY COALESCE(room_order.sort_order, 999999), lp.room_name, lp.rack_name, lp.shelf_index, lp.position_index; """ ) positions = [dict(row) for row in cur.fetchall()] cur.execute( """ SELECT miners.id::text AS miner_id, COALESCE(mls.asset_state, 'waiting_placement') AS asset_state, miners.current_mac, host(miners.current_ip) AS current_ip, miners.status, miners.hostname, miners.miner_sn, miners.power_sn, miners.model, miners.miner_type, miners.firmware_version, miners.last_seen_at FROM miners LEFT JOIN miner_location_states mls ON mls.miner_id = miners.id WHERE COALESCE(mls.asset_state, 'waiting_placement') IN ( 'waiting_placement', 'storage', 'repair', 'decommissioned' ) ORDER BY asset_state, miners.current_mac; """ ) virtual_miners = [dict(row) for row in cur.fetchall()] return api_response( data={ "positions": positions, "virtual_miners": virtual_miners, }, meta={ "position_count": len(positions), "virtual_miner_count": len(virtual_miners), }, ) finally: if conn is not None: conn.close() @app.get("/locations", response_class=HTMLResponse) def location_map_page(): return """ MinerOps Location Map
MinerOps Console

MinerOps Location Map

Physical rack, shelf, position, switch, blocked slot, and miner state map.

MinerOps Location Map

Physical rack, shelf, position, switch, blocked slot, and miner state map.
Dashboard

Login / Refresh

Username
Password
Saved token will be used if available.

Map Controls

Unit / Area
View Mode
Search
Status Filter
Tile Size
Problem Toggle
No map results loaded yet.
Map not loaded yet.

Direct Map Move Mode

Move Mode
Selected Miner
Move Mode is off. Normal clicks open miner detail.

Location Import Issues

Rack / Room Heatmap Summary

Quickly find the hottest racks, offline clusters, not-hashing miners, and lowest efficiency areas.
Group By
Sort By
Scope
Showing all racks/rooms.

Issue Drill-Down

Actionable list of miners matching heat, offline, hashing, and duplicate/conflict issues.
Issue Type
Scope
Issue list not loaded yet.

Physical Map

Waiting / Storage / Repair / Decommissioned

Map Management

Add rooms, racks, shelves, and mark positions as miner slots, switches, or blocked spots.
Map management controls ready.

Import Layout Spreadsheet

Upload an .xlsx file laid out like your Unit 4 / Unit 6 / Unit 12 spreadsheets. MAC addresses can be entered without colons.
Spreadsheet File
Room Override

Add Unit / Room

New Unit / Room Name

Add Rack With Shelves

Unit / Room
Rack Name
Shelf Count
Positions Per Shelf
Switch Shelf #
Switch Position #

Add Shelf to Existing Rack

Unit / Room
Rack
Shelf Name
Shelf Index
Positions Per Shelf

Set Position Type

Unit / Room
Rack
Shelf
Position #
Type
Label
Notes

Move Miner / Change Location

Move a miner to a physical slot or to Waiting, Storage, Repair, or Decommissioned. Occupied, switch, and blocked target positions are protected.
Miner
Destination
Target Unit / Room
Target Rack
Target Shelf
Target Position
Move Notes

Delete Rack / Room

Delete is blocked if miners are still assigned. Move miners first, then delete the rack or room.
Room / Unit
Rack
Room / Unit
Room Record

Unit / Room Order

Use these controls to choose the order rooms appear on the location map.
Room order controls ready.
""" # ----------------------------- # Phase 3 Step 65: Legacy WhatsMiner / M30S summary fallback # ----------------------------- class LegacySummaryRefreshRequest(BaseModel): miner_id: str | None = None ip_address: str | None = None limit: int = 50 timeout_seconds: float = 1.5 only_missing: bool = True def legacy_summary_to_float(value): if value is None: return None try: return float(str(value).replace(",", "").strip()) except Exception: return None def legacy_summary_to_int(value): float_value = legacy_summary_to_float(value) if float_value is None: return None return int(float_value) def legacy_summary_to_bool(value): if value is None: return None if isinstance(value, bool): return value text = str(value).strip().lower() if text in ("true", "1", "yes", "y", "stable"): return True if text in ("false", "0", "no", "n", "unstable"): return False return None def legacy_summary_get(summary: dict, *keys): for key in keys: if key in summary and summary.get(key) is not None: return summary.get(key) return None def infer_legacy_whatsminer_model(summary: dict): factory_ghs = legacy_summary_to_float( legacy_summary_get(summary, "Factory GHS", "FactoryGHS", "factory_ghs") ) target_mhs = legacy_summary_to_float( legacy_summary_get(summary, "Target MHS", "TargetMHS", "target_mhs") ) estimate_ghs = factory_ghs if estimate_ghs is None and target_mhs is not None: estimate_ghs = target_mhs / 1000.0 if estimate_ghs is None: return "WhatsMiner Legacy" if 70000 <= estimate_ghs <= 115000: return "M30S+" if 115000 < estimate_ghs <= 140000: return "M30S++" if 140000 < estimate_ghs <= 180000: return "M50S++" return "WhatsMiner Legacy" def read_legacy_whatsminer_summary(ip_address: str, port: int = 4028, timeout_seconds: float = 1.5): import json import socket payloads = [ b'{"command":"summary"}\n', b'{"command":"summary"}', ] last_error = None for payload in payloads: try: chunks = [] with socket.create_connection((ip_address, port), timeout=timeout_seconds) as sock: sock.settimeout(timeout_seconds) sock.sendall(payload) while True: try: chunk = sock.recv(4096) except socket.timeout: break if not chunk: break chunks.append(chunk) if not chunks: continue response_text = b"".join(chunks).decode("utf-8", errors="replace").strip() parsed = json.loads(response_text) summary_list = parsed.get("SUMMARY") or parsed.get("summary") or [] if not summary_list: return { "success": False, "error": "No SUMMARY section returned", "response_text": response_text, } return { "success": True, "summary": summary_list[0], "response_text": response_text, "parsed_json": parsed, } except Exception as error: last_error = str(error) return { "success": False, "error": last_error or "No response", } def miner_needs_legacy_summary_patch(miner_id: str): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ WITH latest AS ( SELECT DISTINCT ON (miner_id) miner_id, mhs_av, chip_temp_max_c, chip_temp_avg_c, observed_at FROM miner_status_snapshots WHERE miner_id = %s::uuid ORDER BY miner_id, observed_at DESC ) SELECT miners.id::text, miners.model, miners.miner_type, latest.mhs_av, latest.chip_temp_max_c, latest.chip_temp_avg_c FROM miners LEFT JOIN latest ON latest.miner_id = miners.id WHERE miners.id = %s::uuid; """, (miner_id, miner_id), ) row = cur.fetchone() if not row: return False model_missing = row.get("model") is None or str(row.get("model")).strip() == "" miner_type_missing = row.get("miner_type") is None or str(row.get("miner_type")).strip() == "" hashrate_missing = row.get("mhs_av") is None temp_missing = row.get("chip_temp_max_c") is None and row.get("chip_temp_avg_c") is None return model_missing or miner_type_missing or hashrate_missing or temp_missing finally: if conn is not None: conn.close() def apply_legacy_summary_to_miner(miner_id: str, ip_address: str, timeout_seconds: float = 1.5): read_result = read_legacy_whatsminer_summary(ip_address, 4028, timeout_seconds) if not read_result.get("success"): return { "success": False, "miner_id": miner_id, "ip_address": ip_address, "error": read_result.get("error"), } summary = read_result.get("summary") or {} mhs_av = legacy_summary_to_float( legacy_summary_get(summary, "MHS av", "MHS Av", "mhs_av", "MHS 5s", "HS RT") ) power_w = legacy_summary_to_float( legacy_summary_get(summary, "Power", "power", "Power W") ) power_rate = legacy_summary_to_float( legacy_summary_get(summary, "Power Rate", "PowerRate", "power_rate") ) chip_temp_max = legacy_summary_to_float( legacy_summary_get(summary, "Chip Temp Max", "ChipTempMax", "chip_temp_max", "Temperature") ) chip_temp_avg = legacy_summary_to_float( legacy_summary_get(summary, "Chip Temp Avg", "ChipTempAvg", "chip_temp_avg", "Temperature") ) fan_in = legacy_summary_to_int( legacy_summary_get(summary, "Fan Speed In", "FanSpeedIn", "fan_speed_in") ) fan_out = legacy_summary_to_int( legacy_summary_get(summary, "Fan Speed Out", "FanSpeedOut", "fan_speed_out") ) hash_stable = legacy_summary_to_bool( legacy_summary_get(summary, "Hash Stable", "HashStable", "hash_stable") ) pool_rejected_percent = legacy_summary_to_float( legacy_summary_get(summary, "Pool Rejected%", "Pool Rejected", "pool_rejected_percent") ) inferred_model = infer_legacy_whatsminer_model(summary) conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ UPDATE miners SET status = 'online', last_seen_at = now(), current_ip = COALESCE(NULLIF(%s, '')::inet, current_ip), model = COALESCE(NULLIF(model, ''), %s), miner_type = COALESCE(NULLIF(miner_type, ''), %s) WHERE id = %s::uuid RETURNING id::text, current_mac, host(current_ip) AS current_ip, model, miner_type; """, ( ip_address, inferred_model, inferred_model, miner_id, ), ) miner_row = cur.fetchone() if not miner_row: raise HTTPException(status_code=404, detail="Miner not found") cur.execute( """ SELECT column_name FROM information_schema.columns WHERE table_name = 'miner_status_snapshots'; """ ) snapshot_columns = {row["column_name"] for row in cur.fetchall()} insert_columns = [] insert_values_sql = [] values = [] def add_value(column_name, value, sql_cast=None): if column_name not in snapshot_columns: return insert_columns.append(column_name) if sql_cast: insert_values_sql.append(f"%s::{sql_cast}") else: insert_values_sql.append("%s") values.append(value) def add_now(column_name): if column_name not in snapshot_columns: return insert_columns.append(column_name) insert_values_sql.append("now()") add_value("id", str(uuid4()), "uuid") add_value("miner_id", miner_id, "uuid") add_now("observed_at") add_value("observed_ip", ip_address, "inet") add_value("mhs_av", mhs_av) add_value("power_w", power_w) add_value("power_rate_w_per_th", power_rate) add_value("chip_temp_max_c", chip_temp_max) add_value("chip_temp_avg_c", chip_temp_avg) add_value("fan_speed_in_rpm", fan_in) add_value("fan_speed_out_rpm", fan_out) add_value("hash_stable", hash_stable) add_value("pool_rejected_percent", pool_rejected_percent) add_now("created_at") if "raw_summary_json" in snapshot_columns: import json add_value("raw_summary_json", json.dumps(summary)) if len(insert_columns) > 0: sql = f""" INSERT INTO miner_status_snapshots ( {", ".join(insert_columns)} ) VALUES ( {", ".join(insert_values_sql)} ); """ cur.execute(sql, values) conn.commit() return { "success": True, "miner_id": miner_id, "ip_address": ip_address, "inferred_model": inferred_model, "mhs_av": mhs_av, "hashrate_ths": None if mhs_av is None else round(mhs_av / 1000000.0, 2), "power_w": power_w, "chip_temp_max_c": chip_temp_max, "chip_temp_avg_c": chip_temp_avg, "hash_stable": hash_stable, "pool_rejected_percent": pool_rejected_percent, } except Exception: if conn is not None: conn.rollback() raise finally: if conn is not None: conn.close() @app.post("/api/tools/legacy-summary-refresh-one") def legacy_summary_refresh_one( request: LegacySummaryRefreshRequest, current_user: dict = Depends(get_current_user), ): if not request.miner_id and not request.ip_address: raise HTTPException(status_code=400, detail="Provide miner_id or ip_address") conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: if request.miner_id: cur.execute( """ SELECT id::text, host(current_ip) AS current_ip FROM miners WHERE id = %s::uuid; """, (request.miner_id,), ) else: cur.execute( """ SELECT id::text, host(current_ip) AS current_ip FROM miners WHERE current_ip = %s::inet LIMIT 1; """, (request.ip_address,), ) miner = cur.fetchone() if not miner: raise HTTPException(status_code=404, detail="Miner not found") miner_id = miner["id"] ip_address = request.ip_address or miner["current_ip"] if not ip_address: raise HTTPException(status_code=400, detail="Miner has no current IP address") result = apply_legacy_summary_to_miner( miner_id, ip_address, request.timeout_seconds, ) return api_response(data=result) finally: if conn is not None: conn.close() @app.post("/api/tools/legacy-summary-refresh-missing") def legacy_summary_refresh_missing( request: LegacySummaryRefreshRequest, current_user: dict = Depends(get_current_user), ): safe_limit = max(1, min(int(request.limit or 50), 300)) conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ WITH latest AS ( SELECT DISTINCT ON (miner_id) miner_id, mhs_av, chip_temp_max_c, chip_temp_avg_c, observed_at FROM miner_status_snapshots ORDER BY miner_id, observed_at DESC ) SELECT miners.id::text, host(miners.current_ip) AS current_ip, miners.current_mac, miners.model, miners.miner_type, latest.mhs_av, latest.chip_temp_max_c, latest.chip_temp_avg_c FROM miners LEFT JOIN latest ON latest.miner_id = miners.id WHERE miners.current_ip IS NOT NULL AND ( miners.model IS NULL OR trim(miners.model) = '' OR miners.miner_type IS NULL OR trim(miners.miner_type) = '' OR latest.mhs_av IS NULL OR latest.chip_temp_max_c IS NULL ) ORDER BY miners.current_ip LIMIT %s; """, (safe_limit,), ) miners = [dict(row) for row in cur.fetchall()] results = [] for miner in miners: result = apply_legacy_summary_to_miner( miner["id"], miner["current_ip"], request.timeout_seconds, ) results.append(result) return api_response( data=results, meta={ "attempted": len(results), "success": len([row for row in results if row.get("success")]), "failed": len([row for row in results if not row.get("success")]), }, ) finally: if conn is not None: conn.close() def legacy_find_miner_id_by_ip(ip_address: str): conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id::text FROM miners WHERE current_ip = %s::inet LIMIT 1; """, (ip_address,), ) row = cur.fetchone() if not row: return None return row["id"] finally: if conn is not None: conn.close() try: _minerops_original_discover_one_miner_legacy_summary = discover_one_miner def discover_one_miner(request, current_user: dict = Depends(get_current_user)): result = _minerops_original_discover_one_miner_legacy_summary(request, current_user) try: ip_address = getattr(request, "ip_address", None) or getattr(request, "ip", None) if ip_address: miner_id = legacy_find_miner_id_by_ip(ip_address) if miner_id and miner_needs_legacy_summary_patch(miner_id): apply_legacy_summary_to_miner(miner_id, ip_address, 1.5) except Exception as legacy_error: print(f"Legacy summary fallback failed: {legacy_error}") return result except NameError: pass # ----------------------------- # Phase 3 Step 66: Room / Unit ordering # ----------------------------- class LocationRoomReorderRequest(BaseModel): room_ids: list[str] def ensure_room_order_ready(): conn = None try: conn = get_db_connection() with conn.cursor() as cur: cur.execute( """ ALTER TABLE rooms ADD COLUMN IF NOT EXISTS sort_order integer; """ ) cur.execute( """ WITH ordered AS ( SELECT id, ((row_number() OVER (ORDER BY sort_order NULLS LAST, name)) - 1) * 10 AS new_sort_order FROM rooms ) UPDATE rooms SET sort_order = ordered.new_sort_order FROM ordered WHERE rooms.id = ordered.id AND rooms.sort_order IS NULL; """ ) conn.commit() finally: if conn is not None: conn.close() @app.get("/api/location/rooms-order") def get_location_rooms_order(current_user: dict = Depends(get_current_user)): ensure_location_tables() ensure_room_order_ready() conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT rooms.id::text, rooms.name, COALESCE(rooms.sort_order, 999999) AS sort_order, COUNT(location_positions.id)::int AS position_count, COUNT(location_positions.id) FILTER ( WHERE location_positions.position_kind = 'miner_slot' )::int AS miner_slot_count, COUNT(location_positions.id) FILTER ( WHERE location_positions.position_kind = 'switch' )::int AS switch_count, COUNT(location_positions.id) FILTER ( WHERE location_positions.position_status = 'blocked' OR location_positions.position_kind = 'blocked' )::int AS blocked_count, COUNT(location_positions.id) FILTER ( WHERE location_positions.miner_id IS NOT NULL )::int AS assigned_miner_count FROM rooms LEFT JOIN location_positions ON lower(location_positions.room_name) = lower(rooms.name) GROUP BY rooms.id, rooms.name, rooms.sort_order ORDER BY COALESCE(rooms.sort_order, 999999), rooms.name; """ ) rows = [dict(row) for row in cur.fetchall()] return api_response(data=rows) finally: if conn is not None: conn.close() @app.post("/api/location/rooms/reorder") def reorder_location_rooms( request: LocationRoomReorderRequest, current_user: dict = Depends(get_current_user), ): ensure_location_tables() ensure_room_order_ready() if not request.room_ids: raise HTTPException(status_code=400, detail="No room IDs supplied") conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: for index, room_id in enumerate(request.room_ids): cur.execute( """ UPDATE rooms SET sort_order = %s WHERE id = %s::uuid; """, ( index * 10, room_id, ), ) cur.execute( """ SELECT id::text, name, sort_order FROM rooms ORDER BY sort_order, name; """ ) rows = [dict(row) for row in cur.fetchall()] conn.commit() return api_response(data=rows) except Exception: if conn is not None: conn.rollback() raise finally: if conn is not None: conn.close() # ----------------------------- # Phase 3 Step 67: Location management GUI APIs # ----------------------------- class LocationRoomCreateRequest(BaseModel): name: str class LocationRackCreateRequest(BaseModel): room_name: str rack_name: str shelf_count: int = 5 positions_per_shelf: int = 9 switch_shelf_index: int | None = None switch_position_index: int | None = None class LocationShelfCreateRequest(BaseModel): room_name: str rack_name: str shelf_name: str | None = None shelf_index: int | None = None positions_per_shelf: int = 9 class LocationPositionTypeRequest(BaseModel): room_name: str rack_name: str shelf_name: str position_index: int position_kind: str equipment_label: str | None = None notes: str | None = None def location_get_table_columns(cur, table_name: str): cur.execute( """ SELECT column_name FROM information_schema.columns WHERE table_name = %s; """, (table_name,), ) return {row["column_name"] if isinstance(row, dict) else row[0] for row in cur.fetchall()} def find_or_create_location_room(cur, room_name: str): clean_name = str(room_name or "").strip() if not clean_name: raise HTTPException(status_code=400, detail="Room name is required") cur.execute( """ SELECT id::text, name FROM rooms WHERE lower(name) = lower(%s) LIMIT 1; """, (clean_name,), ) existing = cur.fetchone() if existing: return existing["id"], existing["name"], False room_columns = location_get_table_columns(cur, "rooms") insert_columns = [] insert_values_sql = [] values = [] def add_value(column_name, value, sql_cast=None): if column_name not in room_columns: return insert_columns.append(column_name) if sql_cast: insert_values_sql.append(f"%s::{sql_cast}") else: insert_values_sql.append("%s") values.append(value) def add_now(column_name): if column_name not in room_columns: return insert_columns.append(column_name) insert_values_sql.append("now()") room_id = str(uuid4()) add_value("id", room_id, "uuid") add_value("name", clean_name) if "site_id" in room_columns: cur.execute( """ SELECT id::text FROM sites ORDER BY name LIMIT 1; """ ) site = cur.fetchone() if not site: raise HTTPException(status_code=400, detail="No site exists to attach the room to") site_id = site["id"] if isinstance(site, dict) else site[0] add_value("site_id", site_id, "uuid") if "sort_order" in room_columns: cur.execute( """ SELECT COALESCE(MAX(sort_order), -10) + 10 FROM rooms; """ ) next_sort_order = cur.fetchone()[0] add_value("sort_order", next_sort_order) add_value("is_enabled", True) add_value("enabled", True) add_now("created_at") add_now("updated_at") sql = f""" INSERT INTO rooms ({", ".join(insert_columns)}) VALUES ({", ".join(insert_values_sql)}) RETURNING id::text, name; """ cur.execute(sql, values) created = cur.fetchone() return created["id"], created["name"], True def make_default_shelf_name(shelf_index: int): if int(shelf_index) == 1: return "shelf1(top)" return f"shelf{int(shelf_index)}" def create_location_slot( cur, room_id, room_name, rack_name, shelf_name, shelf_index, position_index, position_kind="miner_slot", position_status="available", equipment_label=None, notes=None, ): cur.execute( """ INSERT INTO location_positions ( id, room_id, room_name, rack_name, shelf_name, shelf_index, position_index, position_label, position_kind, position_status, equipment_label, notes, source_file, raw_value, updated_at ) VALUES ( %s::uuid, %s::uuid, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'manual_gui', %s, now() ) ON CONFLICT (room_name, rack_name, shelf_name, position_index) DO NOTHING RETURNING id::text; """, ( str(uuid4()), room_id, room_name, rack_name, shelf_name, shelf_index, position_index, f"Pos {position_index}", position_kind, position_status, equipment_label, notes, equipment_label or notes, ), ) row = cur.fetchone() return row["id"] if row else None @app.post("/api/location/rooms/create") def create_location_room( request: LocationRoomCreateRequest, current_user: dict = Depends(get_current_user), ): ensure_location_tables() ensure_room_order_ready() conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: room_id, room_name, was_created = find_or_create_location_room(cur, request.name) conn.commit() return api_response( data={ "id": room_id, "name": room_name, "created": was_created, } ) except Exception: if conn is not None: conn.rollback() raise finally: if conn is not None: conn.close() @app.post("/api/location/racks/create") def create_location_rack( request: LocationRackCreateRequest, current_user: dict = Depends(get_current_user), ): ensure_location_tables() ensure_room_order_ready() room_name = str(request.room_name or "").strip() rack_name = str(request.rack_name or "").strip() if not room_name: raise HTTPException(status_code=400, detail="Room name is required") if not rack_name: raise HTTPException(status_code=400, detail="Rack name is required") shelf_count = max(1, min(int(request.shelf_count or 1), 20)) positions_per_shelf = max(1, min(int(request.positions_per_shelf or 1), 50)) conn = None try: conn = get_db_connection() created_positions = 0 skipped_positions = 0 with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: room_id, room_name, room_created = find_or_create_location_room(cur, room_name) for shelf_index in range(1, shelf_count + 1): shelf_name = make_default_shelf_name(shelf_index) for position_index in range(1, positions_per_shelf + 1): position_kind = "miner_slot" position_status = "available" equipment_label = None notes = None if ( request.switch_shelf_index is not None and request.switch_position_index is not None and int(request.switch_shelf_index) == shelf_index and int(request.switch_position_index) == position_index ): position_kind = "switch" position_status = "switch" equipment_label = f"Switch {rack_name}" notes = "Created from location management GUI." position_id = create_location_slot( cur, room_id, room_name, rack_name, shelf_name, shelf_index, position_index, position_kind, position_status, equipment_label, notes, ) if position_id: created_positions += 1 else: skipped_positions += 1 conn.commit() return api_response( data={ "room_name": room_name, "rack_name": rack_name, "shelf_count": shelf_count, "positions_per_shelf": positions_per_shelf, "created_positions": created_positions, "skipped_existing_positions": skipped_positions, } ) except Exception: if conn is not None: conn.rollback() raise finally: if conn is not None: conn.close() @app.post("/api/location/shelves/create") def create_location_shelf( request: LocationShelfCreateRequest, current_user: dict = Depends(get_current_user), ): ensure_location_tables() ensure_room_order_ready() room_name = str(request.room_name or "").strip() rack_name = str(request.rack_name or "").strip() if not room_name: raise HTTPException(status_code=400, detail="Room name is required") if not rack_name: raise HTTPException(status_code=400, detail="Rack name is required") positions_per_shelf = max(1, min(int(request.positions_per_shelf or 1), 50)) conn = None try: conn = get_db_connection() created_positions = 0 skipped_positions = 0 with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: room_id, room_name, room_created = find_or_create_location_room(cur, room_name) if request.shelf_index is None: cur.execute( """ SELECT COALESCE(MAX(shelf_index), 0) + 1 AS next_shelf_index FROM location_positions WHERE lower(room_name) = lower(%s) AND lower(rack_name) = lower(%s); """, ( room_name, rack_name, ), ) shelf_index = int(cur.fetchone()["next_shelf_index"]) else: shelf_index = max(1, int(request.shelf_index)) shelf_name = str(request.shelf_name or "").strip() if not shelf_name: shelf_name = make_default_shelf_name(shelf_index) for position_index in range(1, positions_per_shelf + 1): position_id = create_location_slot( cur, room_id, room_name, rack_name, shelf_name, shelf_index, position_index, "miner_slot", "available", None, None, ) if position_id: created_positions += 1 else: skipped_positions += 1 conn.commit() return api_response( data={ "room_name": room_name, "rack_name": rack_name, "shelf_name": shelf_name, "shelf_index": shelf_index, "positions_per_shelf": positions_per_shelf, "created_positions": created_positions, "skipped_existing_positions": skipped_positions, } ) except Exception: if conn is not None: conn.rollback() raise finally: if conn is not None: conn.close() @app.post("/api/location/positions/set-type") def set_location_position_type( request: LocationPositionTypeRequest, current_user: dict = Depends(get_current_user), ): ensure_location_tables() room_name = str(request.room_name or "").strip() rack_name = str(request.rack_name or "").strip() shelf_name = str(request.shelf_name or "").strip() position_index = int(request.position_index or 0) position_kind = str(request.position_kind or "").strip().lower() if position_kind not in ("miner_slot", "switch", "blocked"): raise HTTPException(status_code=400, detail="position_kind must be miner_slot, switch, or blocked") if not room_name or not rack_name or not shelf_name or position_index <= 0: raise HTTPException(status_code=400, detail="Room, rack, shelf, and position are required") if position_kind == "switch": position_status = "switch" equipment_label = request.equipment_label or f"Switch {rack_name}" notes = request.notes or "Switch position." elif position_kind == "blocked": position_status = "blocked" equipment_label = request.equipment_label notes = request.notes or "Blocked/bad spot." else: equipment_label = request.equipment_label notes = request.notes conn_check = None try: conn_check = get_db_connection() with conn_check.cursor(row_factory=psycopg.rows.dict_row) as check_cur: check_cur.execute( """ SELECT miner_id FROM location_positions WHERE lower(room_name) = lower(%s) AND lower(rack_name) = lower(%s) AND lower(shelf_name) = lower(%s) AND position_index = %s LIMIT 1; """, ( room_name, rack_name, shelf_name, position_index, ), ) existing_position = check_cur.fetchone() if existing_position and existing_position.get("miner_id"): position_status = "occupied" else: position_status = "available" finally: if conn_check is not None: conn_check.close() conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ UPDATE location_positions SET position_kind = %s, position_status = %s, equipment_label = %s, notes = %s, updated_at = now() WHERE lower(room_name) = lower(%s) AND lower(rack_name) = lower(%s) AND lower(shelf_name) = lower(%s) AND position_index = %s RETURNING id::text, room_name, rack_name, shelf_name, position_index, position_kind, position_status, equipment_label, notes; """, ( position_kind, position_status, equipment_label, notes, room_name, rack_name, shelf_name, position_index, ), ) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Location position not found") row = dict(row) conn.commit() return api_response(data=row) except Exception: if conn is not None: conn.rollback() raise finally: if conn is not None: conn.close() # ----------------------------- # Phase 3 Step 67.1: Safe location delete APIs # ----------------------------- class LocationRackDeleteRequest(BaseModel): room_name: str rack_name: str allow_delete_occupied: bool = False class LocationRoomDeleteRequest(BaseModel): room_name: str allow_delete_occupied: bool = False delete_room_record: bool = True @app.post("/api/location/racks/delete") def delete_location_rack( request: LocationRackDeleteRequest, current_user: dict = Depends(get_current_user), ): ensure_location_tables() room_name = str(request.room_name or "").strip() rack_name = str(request.rack_name or "").strip() if not room_name or not rack_name: raise HTTPException(status_code=400, detail="Room name and rack name are required") conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT COUNT(*)::int AS position_count, COUNT(*) FILTER (WHERE miner_id IS NOT NULL)::int AS assigned_miner_count FROM location_positions WHERE lower(room_name) = lower(%s) AND lower(rack_name) = lower(%s); """, ( room_name, rack_name, ), ) counts = dict(cur.fetchone()) if counts["position_count"] == 0: raise HTTPException(status_code=404, detail="Rack not found") if counts["assigned_miner_count"] > 0 and not request.allow_delete_occupied: raise HTTPException( status_code=400, detail="Rack has assigned miners. Move miners first before deleting the rack.", ) if request.allow_delete_occupied: cur.execute( """ UPDATE miner_location_states SET asset_state = 'waiting_placement', location_position_id = NULL, updated_at = now() WHERE location_position_id IN ( SELECT id FROM location_positions WHERE lower(room_name) = lower(%s) AND lower(rack_name) = lower(%s) ); """, ( room_name, rack_name, ), ) cur.execute( """ DELETE FROM location_positions WHERE lower(room_name) = lower(%s) AND lower(rack_name) = lower(%s); """, ( room_name, rack_name, ), ) deleted_positions = cur.rowcount conn.commit() return api_response( data={ "room_name": room_name, "rack_name": rack_name, "deleted_positions": deleted_positions, "assigned_miner_count": counts["assigned_miner_count"], } ) except Exception: if conn is not None: conn.rollback() raise finally: if conn is not None: conn.close() @app.post("/api/location/rooms/delete") def delete_location_room( request: LocationRoomDeleteRequest, current_user: dict = Depends(get_current_user), ): ensure_location_tables() room_name = str(request.room_name or "").strip() if not room_name: raise HTTPException(status_code=400, detail="Room name is required") conn = None try: conn = get_db_connection() room_deleted = False room_delete_reason = None room_id = None miner_reference_count = 0 scan_range_reference_count = 0 with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT COUNT(*)::int AS position_count, COUNT(*) FILTER (WHERE miner_id IS NOT NULL)::int AS assigned_miner_count FROM location_positions WHERE lower(room_name) = lower(%s); """, (room_name,), ) counts = dict(cur.fetchone()) if counts["position_count"] == 0: cur.execute( """ SELECT id::text FROM rooms WHERE lower(name) = lower(%s) LIMIT 1; """, (room_name,), ) existing_room = cur.fetchone() if not existing_room: raise HTTPException(status_code=404, detail="Room not found") if counts["assigned_miner_count"] > 0 and not request.allow_delete_occupied: raise HTTPException( status_code=400, detail="Room has assigned miners. Move miners first before deleting the room map.", ) if request.allow_delete_occupied: cur.execute( """ UPDATE miner_location_states SET asset_state = 'waiting_placement', location_position_id = NULL, updated_at = now() WHERE location_position_id IN ( SELECT id FROM location_positions WHERE lower(room_name) = lower(%s) ); """, (room_name,), ) cur.execute( """ DELETE FROM location_positions WHERE lower(room_name) = lower(%s); """, (room_name,), ) deleted_positions = cur.rowcount cur.execute( """ SELECT id::text FROM rooms WHERE lower(name) = lower(%s) LIMIT 1; """, (room_name,), ) room = cur.fetchone() if room: room_id = room["id"] if request.delete_room_record and room_id: cur.execute( """ SELECT COUNT(*)::int AS count FROM miners WHERE room_id = %s::uuid; """, (room_id,), ) miner_reference_count = cur.fetchone()["count"] cur.execute( """ SELECT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'scan_ranges' AND column_name = 'room_id' ) AS has_scan_range_room_id; """ ) has_scan_range_room_id = cur.fetchone()["has_scan_range_room_id"] if has_scan_range_room_id: cur.execute( """ SELECT COUNT(*)::int AS count FROM scan_ranges WHERE room_id = %s::uuid; """, (room_id,), ) scan_range_reference_count = cur.fetchone()["count"] if miner_reference_count == 0 and scan_range_reference_count == 0: cur.execute( """ DELETE FROM rooms WHERE id = %s::uuid; """, (room_id,), ) room_deleted = cur.rowcount > 0 else: room_delete_reason = ( "Room map positions were deleted, but the room record was kept because " f"{miner_reference_count} miners and {scan_range_reference_count} scan ranges still reference it." ) conn.commit() return api_response( data={ "room_name": room_name, "deleted_positions": deleted_positions, "assigned_miner_count": counts["assigned_miner_count"], "room_record_deleted": room_deleted, "room_delete_reason": room_delete_reason, "miner_reference_count": miner_reference_count, "scan_range_reference_count": scan_range_reference_count, } ) except Exception: if conn is not None: conn.rollback() raise finally: if conn is not None: conn.close() # ----------------------------- # Phase 3 Step 68: Spreadsheet layout import # ----------------------------- class LocationSpreadsheetImportRequest(BaseModel): file_name: str file_base64: str room_name_override: str | None = None def xlsx_column_name_to_number(cell_ref: str): letters = "" for ch in str(cell_ref): if ch.isalpha(): letters += ch.upper() else: break value = 0 for ch in letters: value = value * 26 + (ord(ch) - ord("A") + 1) return value def parse_xlsx_shared_strings(zf): import xml.etree.ElementTree as ET try: data = zf.read("xl/sharedStrings.xml") except KeyError: return [] root = ET.fromstring(data) namespace = {"main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"} strings = [] for si in root.findall("main:si", namespace): text_parts = [] for text_node in si.findall(".//main:t", namespace): text_parts.append(text_node.text or "") strings.append("".join(text_parts)) return strings def parse_xlsx_workbook_sheet_names(zf): import xml.etree.ElementTree as ET try: workbook_data = zf.read("xl/workbook.xml") rels_data = zf.read("xl/_rels/workbook.xml.rels") except KeyError: return [("Sheet1", "xl/worksheets/sheet1.xml")] ns_main = {"main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"} ns_rel = {"rel": "http://schemas.openxmlformats.org/package/2006/relationships"} workbook_root = ET.fromstring(workbook_data) rels_root = ET.fromstring(rels_data) rel_map = {} for rel in rels_root.findall("rel:Relationship", ns_rel): rel_id = rel.attrib.get("Id") target = rel.attrib.get("Target") if rel_id and target: if target.startswith("/"): path = target.lstrip("/") elif target.startswith("xl/"): path = target else: path = "xl/" + target rel_map[rel_id] = path sheets = [] for sheet in workbook_root.findall(".//main:sheet", ns_main): name = sheet.attrib.get("name", "Sheet") rel_id = sheet.attrib.get("{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id") path = rel_map.get(rel_id) if path: sheets.append((name, path)) if not sheets: return [("Sheet1", "xl/worksheets/sheet1.xml")] return sheets def parse_xlsx_sheet_rows(zf, sheet_path, shared_strings): import xml.etree.ElementTree as ET try: data = zf.read(sheet_path) except KeyError: return [] root = ET.fromstring(data) namespace = {"main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"} rows = [] for row in root.findall(".//main:sheetData/main:row", namespace): row_number = int(row.attrib.get("r", "0")) cells = {} for cell in row.findall("main:c", namespace): cell_ref = cell.attrib.get("r", "") col_number = xlsx_column_name_to_number(cell_ref) cell_type = cell.attrib.get("t") value_node = cell.find("main:v", namespace) value = None if value_node is not None: raw_value = value_node.text or "" if cell_type == "s": try: value = shared_strings[int(raw_value)] except Exception: value = raw_value else: value = raw_value inline_node = cell.find("main:is/main:t", namespace) if inline_node is not None: value = inline_node.text or "" if value is not None: cells[col_number] = str(value).strip() if cells: rows.append( { "row_number": row_number, "cells": cells, } ) return rows def parse_location_layout_xlsx(file_bytes: bytes, file_name: str, room_name_override: str | None = None): import zipfile import io positions = [] parse_warnings = [] parsed_room_names = set() parsed_rack_names = set() with zipfile.ZipFile(io.BytesIO(file_bytes), "r") as zf: shared_strings = parse_xlsx_shared_strings(zf) sheets = parse_xlsx_workbook_sheet_names(zf) for sheet_name, sheet_path in sheets: rows = parse_xlsx_sheet_rows(zf, sheet_path, shared_strings) current_room_name = room_name_override current_rack_name = None shelf_index_for_rack = 0 for row in rows: cells = row["cells"] first_col = min(cells.keys()) first_value = str(cells.get(first_col, "") or "").strip() if not first_value: continue first_lower = first_value.lower() if first_lower.startswith("unit ") or first_lower.startswith("room "): if not room_name_override: current_room_name = first_value parsed_room_names.add(current_room_name) continue if first_lower.startswith("rack ") or first_lower in ("bathroom", "office"): current_rack_name = first_value parsed_rack_names.add(current_rack_name) shelf_index_for_rack = 0 continue if first_lower.startswith("shelf"): if not current_room_name: parse_warnings.append(f"{file_name} {sheet_name} row {row['row_number']}: shelf found before unit/room name.") continue if not current_rack_name: parse_warnings.append(f"{file_name} {sheet_name} row {row['row_number']}: shelf found before rack name.") continue shelf_index_for_rack += 1 shelf_name = first_value max_col = max(cells.keys()) if cells else first_col for col_number in range(first_col + 1, max_col + 1): raw_value = str(cells.get(col_number, "") or "").strip() position_index = col_number - first_col source_cell = f"R{row['row_number']}C{col_number}" item_type = "empty" position_status = "available" mac_address = None label = None notes = None conflict_reason = None cleaned_mac = "".join(ch for ch in raw_value.lower() if ch in "0123456789abcdef") if raw_value: lower_value = raw_value.lower() if "switch" in lower_value: item_type = "switch" position_status = "switch" label = raw_value elif "bad" in lower_value or "blocked" in lower_value or "unusable" in lower_value: item_type = "blocked" position_status = "blocked" label = raw_value notes = "Imported as permanent blocked/bad spot." elif len(cleaned_mac) == 12: item_type = "miner" position_status = "occupied" mac_address = normalize_mac_for_location(raw_value) else: item_type = "blocked" position_status = "blocked" label = raw_value notes = "Imported as blocked because the cell was not empty, switch, or valid MAC." positions.append( { "room_name": current_room_name, "rack_name": current_rack_name, "shelf_name": shelf_name, "shelf_index": shelf_index_for_rack, "position_index": position_index, "position_label": f"Pos {position_index}", "item_type": item_type, "position_status": position_status, "mac_address": mac_address, "label": label, "notes": notes, "conflict_reason": conflict_reason, "source_file": file_name, "source_sheet": sheet_name, "source_cell": source_cell, "raw_value": raw_value, } ) return { "positions": positions, "parse_warnings": parse_warnings, "parsed_room_names": sorted(list(parsed_room_names)), "parsed_rack_names": sorted(list(parsed_rack_names)), } @app.post("/api/location/import-spreadsheet") def import_location_spreadsheet( request: LocationSpreadsheetImportRequest, current_user: dict = Depends(get_current_user), ): import base64 ensure_location_tables() if not request.file_base64: raise HTTPException(status_code=400, detail="No spreadsheet file data supplied") file_name = request.file_name or "uploaded-layout.xlsx" if not file_name.lower().endswith(".xlsx"): raise HTTPException(status_code=400, detail="Only .xlsx files are supported") try: file_bytes = base64.b64decode(request.file_base64) except Exception: raise HTTPException(status_code=400, detail="Could not decode uploaded spreadsheet") parsed = parse_location_layout_xlsx( file_bytes, file_name, request.room_name_override, ) positions = parsed["positions"] if not positions: raise HTTPException(status_code=400, detail="No layout positions found in spreadsheet") seed_request = LocationSeedImportRequest( schema_version=1, generated_from=[file_name], notes="Imported from Location Map spreadsheet upload GUI.", positions=positions, ) result = import_location_layout_seed(seed_request, current_user) if isinstance(result, dict) and result.get("data") is not None: result["data"]["spreadsheet_parse"] = { "file_name": file_name, "positions_parsed": len(positions), "parse_warnings": parsed["parse_warnings"], "parsed_room_names": parsed["parsed_room_names"], "parsed_rack_names": parsed["parsed_rack_names"], "room_name_override": request.room_name_override, } return result # ----------------------------- # Phase 3 Step 69: Move miners between physical and virtual locations # ----------------------------- class MinerLocationMoveRequest(BaseModel): miner_id: str | None = None mac_address: str | None = None destination_type: str = "physical" location_position_id: str | None = None room_name: str | None = None rack_name: str | None = None shelf_name: str | None = None position_index: int | None = None asset_state: str | None = None notes: str | None = None def find_miner_for_location_move(cur, miner_id: str | None, mac_address: str | None): if miner_id: cur.execute( """ SELECT id::text, current_mac, host(current_ip) AS current_ip, room_id::text FROM miners WHERE id = %s::uuid LIMIT 1; """, (miner_id,), ) row = cur.fetchone() if row: return dict(row) if mac_address: mac_key = normalize_mac_key_for_location(mac_address) if not mac_key: raise HTTPException(status_code=400, detail="MAC address is invalid") cur.execute( """ SELECT id::text, current_mac, host(current_ip) AS current_ip, room_id::text FROM miners WHERE regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g') = %s LIMIT 1; """, (mac_key,), ) row = cur.fetchone() if row: return dict(row) return None def describe_location_position(row): if not row: return "Unplaced" return ( f"{row.get('room_name') or '-'} / " f"{row.get('rack_name') or '-'} / " f"{row.get('shelf_name') or '-'} / " f"Pos {row.get('position_index') or '-'}" ) @app.post("/api/location/miners/move") def move_miner_location( request: MinerLocationMoveRequest, current_user: dict = Depends(get_current_user), ): ensure_location_tables() ensure_maintenance_tables() valid_virtual_states = { "waiting_placement", "storage", "repair", "decommissioned", } destination_type = str(request.destination_type or "physical").strip().lower() if destination_type not in ("physical", "virtual"): raise HTTPException(status_code=400, detail="destination_type must be physical or virtual") conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: miner = find_miner_for_location_move(cur, request.miner_id, request.mac_address) if not miner: raise HTTPException(status_code=404, detail="Miner not found") miner_id = miner["id"] current_mac = miner.get("current_mac") cur.execute( """ SELECT id::text, room_name, rack_name, shelf_name, position_index FROM location_positions WHERE miner_id = %s::uuid ORDER BY room_name, rack_name, shelf_index, position_index; """, (miner_id,), ) old_positions = [dict(row) for row in cur.fetchall()] old_location_text = " / ".join([describe_location_position(row) for row in old_positions]) if old_positions else "Unplaced" new_location_text = None target_position_id = None target_room_name = None # Clear current physical positions first. If anything fails later, rollback restores them. cur.execute( """ UPDATE location_positions SET miner_id = NULL, expected_mac = NULL, position_status = CASE WHEN position_kind = 'miner_slot' THEN 'available' ELSE position_status END, conflict_reason = NULL, updated_at = now() WHERE miner_id = %s::uuid; """, (miner_id,), ) if destination_type == "physical": if request.location_position_id: cur.execute( """ SELECT id::text, room_id::text, room_name, rack_name, shelf_name, shelf_index, position_index, position_kind, position_status, miner_id::text AS assigned_miner_id, expected_mac FROM location_positions WHERE id = %s::uuid FOR UPDATE; """, (request.location_position_id,), ) else: if not request.room_name or not request.rack_name or not request.shelf_name or not request.position_index: raise HTTPException( status_code=400, detail="Physical moves require room, rack, shelf, and position", ) cur.execute( """ SELECT id::text, room_id::text, room_name, rack_name, shelf_name, shelf_index, position_index, position_kind, position_status, miner_id::text AS assigned_miner_id, expected_mac FROM location_positions WHERE lower(room_name) = lower(%s) AND lower(rack_name) = lower(%s) AND lower(shelf_name) = lower(%s) AND position_index = %s FOR UPDATE; """, ( request.room_name, request.rack_name, request.shelf_name, request.position_index, ), ) target = cur.fetchone() if not target: raise HTTPException(status_code=404, detail="Target physical position not found") target = dict(target) if target.get("position_kind") != "miner_slot": raise HTTPException(status_code=400, detail="Target position is not a miner slot") if target.get("position_status") in ("switch", "blocked"): raise HTTPException(status_code=400, detail="Target position is switch/blocked") assigned_miner_id = target.get("assigned_miner_id") if assigned_miner_id and assigned_miner_id != miner_id: raise HTTPException(status_code=400, detail="Target position is already occupied") target_position_id = target["id"] target_room_name = target["room_name"] new_location_text = describe_location_position(target) cur.execute( """ UPDATE location_positions SET miner_id = %s::uuid, expected_mac = %s, position_status = 'occupied', conflict_reason = NULL, updated_at = now() WHERE id = %s::uuid; """, ( miner_id, current_mac, target_position_id, ), ) cur.execute( """ SELECT id::text FROM rooms WHERE lower(name) = lower(%s) LIMIT 1; """, (target_room_name,), ) room = cur.fetchone() room_id = room["id"] if room else None if room_id: cur.execute( """ UPDATE miners SET room_id = %s::uuid WHERE id = %s::uuid; """, ( room_id, miner_id, ), ) cur.execute( """ INSERT INTO miner_location_states ( miner_id, asset_state, location_position_id, state_notes, updated_by_user_id, updated_at ) VALUES ( %s::uuid, 'shelved', %s::uuid, %s, %s::uuid, now() ) ON CONFLICT (miner_id) DO UPDATE SET asset_state = 'shelved', location_position_id = EXCLUDED.location_position_id, state_notes = EXCLUDED.state_notes, updated_by_user_id = EXCLUDED.updated_by_user_id, updated_at = now(); """, ( miner_id, target_position_id, request.notes, get_current_user_id_for_location(current_user), ), ) new_asset_state = "shelved" else: asset_state = str(request.asset_state or "").strip().lower() if asset_state not in valid_virtual_states: raise HTTPException( status_code=400, detail="asset_state must be waiting_placement, storage, repair, or decommissioned", ) cur.execute( """ INSERT INTO miner_location_states ( miner_id, asset_state, location_position_id, state_notes, updated_by_user_id, updated_at ) VALUES ( %s::uuid, %s, NULL, %s, %s::uuid, now() ) ON CONFLICT (miner_id) DO UPDATE SET asset_state = EXCLUDED.asset_state, location_position_id = NULL, state_notes = EXCLUDED.state_notes, updated_by_user_id = EXCLUDED.updated_by_user_id, updated_at = now(); """, ( miner_id, asset_state, request.notes, get_current_user_id_for_location(current_user), ), ) new_location_text = asset_state new_asset_state = asset_state event_description = ( f"Miner moved from {old_location_text} to {new_location_text}." ) if request.notes: event_description += f" Notes: {request.notes}" cur.execute( """ INSERT INTO miner_maintenance_events ( id, miner_id, event_type, event_status, event_title, description, event_at, component_type, component_slot, created_by_user_id ) VALUES ( %s::uuid, %s::uuid, 'moved', 'completed', 'Miner location changed', %s, now(), 'location', %s, %s::uuid ) RETURNING id::text; """, ( str(uuid4()), miner_id, event_description, new_location_text, get_current_user_id_for_location(current_user), ), ) event = cur.fetchone() conn.commit() return api_response( data={ "miner_id": miner_id, "current_mac": current_mac, "old_location": old_location_text, "new_location": new_location_text, "asset_state": new_asset_state, "location_position_id": target_position_id, "maintenance_event_id": event["id"] if event else None, } ) except Exception: if conn is not None: conn.rollback() raise finally: if conn is not None: conn.close() # ----------------------------- # Step 86: Miner command control foundation # ----------------------------- from fastapi import Request, HTTPException async def minerops_command_db_connect(): import os import asyncpg database_url = os.environ.get("DATABASE_URL") or globals().get("DATABASE_URL") if not database_url: raise RuntimeError("DATABASE_URL is not configured.") # asyncpg does not accept SQLAlchemy driver names like postgresql+psycopg:// database_url = database_url.replace("postgresql+psycopg://", "postgresql://", 1) database_url = database_url.replace("postgresql+asyncpg://", "postgresql://", 1) database_url = database_url.replace("postgres://", "postgresql://", 1) return await asyncpg.connect(database_url) async def ensure_miner_command_tables(): conn = await minerops_command_db_connect() try: await conn.execute(""" CREATE TABLE IF NOT EXISTS miner_command_events ( id TEXT PRIMARY KEY, miner_id TEXT NOT NULL, command_type TEXT NOT NULL, command_status TEXT NOT NULL, dry_run BOOLEAN NOT NULL DEFAULT TRUE, requested_by_user_id TEXT, requested_by_username TEXT, target_ip TEXT, target_mac TEXT, request_payload JSONB, response_payload JSONB, error_message TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ ); """) await conn.execute(""" CREATE INDEX IF NOT EXISTS idx_miner_command_events_miner_id_created ON miner_command_events (miner_id, created_at DESC); """) finally: await conn.close() async def get_miner_command_target(miner_id: str): conn = await minerops_command_db_connect() try: columns = await conn.fetch(""" SELECT column_name FROM information_schema.columns WHERE table_name = 'miners' """) column_names = {row["column_name"] for row in columns} def pick_column(candidates): for candidate in candidates: if candidate in column_names: return candidate return None ip_col = pick_column([ "current_ip", "ip_address", "ip", "last_ip", "network_ip" ]) mac_col = pick_column([ "current_mac", "mac_address", "mac", "expected_mac", "network_mac" ]) model_col = pick_column([ "model", "miner_model", "miner_type", "hardware_model" ]) select_parts = ["id::text AS miner_id"] if ip_col: select_parts.append(f"{ip_col}::text AS target_ip") else: select_parts.append("NULL::text AS target_ip") if mac_col: select_parts.append(f"{mac_col}::text AS target_mac") else: select_parts.append("NULL::text AS target_mac") if model_col: select_parts.append(f"{model_col}::text AS model") else: select_parts.append("NULL::text AS model") sql = f""" SELECT {", ".join(select_parts)} FROM miners WHERE id::text = $1 LIMIT 1 """ row = await conn.fetchrow(sql, miner_id) if not row: return None return dict(row) finally: await conn.close() async def insert_miner_command_event( miner_id: str, command_type: str, command_status: str, dry_run: bool, target_ip: str | None = None, target_mac: str | None = None, requested_by_user_id: str | None = None, requested_by_username: str | None = None, request_payload: dict | None = None, response_payload: dict | None = None, error_message: str | None = None, ): import json import uuid await ensure_miner_command_tables() event_id = str(uuid.uuid4()) conn = await minerops_command_db_connect() try: await conn.execute( """ INSERT INTO miner_command_events ( id, miner_id, command_type, command_status, dry_run, requested_by_user_id, requested_by_username, target_ip, target_mac, request_payload, response_payload, error_message, completed_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb, $12, now() ) """, event_id, miner_id, command_type, command_status, dry_run, requested_by_user_id, requested_by_username, target_ip, target_mac, json.dumps(request_payload or {}), json.dumps(response_payload or {}), error_message, ) finally: await conn.close() return event_id def minerops_command_require_bearer(request: Request): auth_header = request.headers.get("authorization") or "" cookie_token = request.cookies.get(SESSION_COOKIE) or "" header_token = auth_header.split(" ", 1)[1].strip() if auth_header.lower().startswith("bearer ") else "" if not cookie_token and (not header_token or header_token == COOKIE_TOKEN_HINT): raise HTTPException(status_code=401, detail="Login required.") async def minerops_command_user_from_request(request: Request): username = None user_id = None # Best effort only. The existing auth system already owns real auth. # This command layer requires a bearer token and records who the UI says is signed in when available. try: username = request.headers.get("x-minerops-username") except Exception: username = None if not username: username = "authenticated-user" return user_id, username @app.on_event("startup") async def startup_ensure_miner_command_tables(): await ensure_miner_command_tables() # ----------------------------- # Step 87: Real reboot command helpers # ----------------------------- def minerops_env_enabled_default_true(name: str) -> bool: import os value = os.environ.get(name) if value is None or str(value).strip() == "": return True value = str(value).strip().lower() return value not in {"0", "false", "no", "off", "disabled"} def minerops_env_enabled(name: str) -> bool: import os value = str(os.environ.get(name, "") or "").strip().lower() return value in {"1", "true", "yes", "on", "enabled"} def minerops_get_whatsminer_api_port() -> int: import os raw = str(os.environ.get("MINEROPS_WHATSMINER_API_PORT", "4028") or "4028").strip() try: return int(raw) except Exception: return 4028 def minerops_normalize_target_ip(target_ip: str | None) -> str | None: if not target_ip: return target_ip cleaned = str(target_ip).strip() # Stored IPs can sometimes be CIDR notation like 10.30.0.53/32. # The socket connection needs only the host address. if "/" in cleaned: cleaned = cleaned.split("/", 1)[0].strip() if cleaned.startswith("[") and cleaned.endswith("]"): cleaned = cleaned[1:-1].strip() return cleaned def minerops_send_whatsminer_reboot_sync(target_ip: str, port: int = 4028, timeout_seconds: float = 10.0) -> dict: import json import socket import time target_ip = minerops_normalize_target_ip(target_ip) if not target_ip: raise RuntimeError("No target IP available for reboot command.") command = {"cmd": "reboot"} payload = json.dumps(command).encode("utf-8") + b"\n" started = time.time() chunks = [] with socket.create_connection((target_ip, port), timeout=timeout_seconds) as sock: sock.settimeout(timeout_seconds) sock.sendall(payload) try: sock.shutdown(socket.SHUT_WR) except Exception: pass while True: try: chunk = sock.recv(65536) if not chunk: break chunks.append(chunk) if sum(len(item) for item in chunks) > 1048576: break except socket.timeout: break raw_response = b"".join(chunks).decode("utf-8", errors="replace").strip() parsed_response = None if raw_response: try: parsed_response = json.loads(raw_response) except Exception: parsed_response = None return { "sent": True, "target_ip": target_ip, "target_port": port, "command": command, "duration_seconds": round(time.time() - started, 3), "raw_response": raw_response, "json_response": parsed_response, } async def minerops_send_whatsminer_reboot(target_ip: str) -> dict: import asyncio port = minerops_get_whatsminer_api_port() return await asyncio.to_thread( minerops_send_whatsminer_reboot_sync, target_ip, port, 10.0, ) @app.post("/api/miners/{miner_id}/commands/reboot") async def dry_run_reboot_miner_command(miner_id: str, request: Request): minerops_command_require_bearer(request) try: body = await request.json() except Exception: body = {} dry_run = bool(body.get("dry_run", True)) confirm_text = str(body.get("confirm_text", "") or "").strip() target = await get_miner_command_target(miner_id) if not target: raise HTTPException(status_code=404, detail="Miner not found.") user_id, username = await minerops_command_user_from_request(request) request_payload = { "dry_run": dry_run, "confirm_text": confirm_text, "source": "miner_detail_control_card", "real_reboot_enabled": minerops_env_enabled_default_true("MINEROPS_ENABLE_REAL_REBOOT"), } if dry_run: event_id = await insert_miner_command_event( miner_id=miner_id, command_type="reboot", command_status="dry_run_planned", dry_run=True, target_ip=target.get("target_ip"), target_mac=target.get("target_mac"), requested_by_user_id=user_id, requested_by_username=username, request_payload=request_payload, response_payload={ "message": "Dry run only. No reboot command was sent.", "target": target, }, ) return { "ok": True, "event_id": event_id, "miner_id": miner_id, "command_type": "reboot", "command_status": "dry_run_planned", "dry_run": True, "target_ip": target.get("target_ip"), "target_mac": target.get("target_mac"), "model": target.get("model"), "message": "Dry run complete. No reboot command was sent.", } if not minerops_env_enabled_default_true("MINEROPS_ENABLE_REAL_REBOOT"): event_id = await insert_miner_command_event( miner_id=miner_id, command_type="reboot", command_status="blocked_real_reboot_disabled", dry_run=False, target_ip=target.get("target_ip"), target_mac=target.get("target_mac"), requested_by_user_id=user_id, requested_by_username=username, request_payload=request_payload, response_payload={ "message": "Real reboot is disabled. Set MINEROPS_ENABLE_REAL_REBOOT=true to enable.", }, error_message="Real reboot disabled by environment setting.", ) raise HTTPException( status_code=403, detail={ "message": "Real reboot is disabled. Set MINEROPS_ENABLE_REAL_REBOOT=true to enable.", "event_id": event_id, }, ) if confirm_text != "REBOOT": event_id = await insert_miner_command_event( miner_id=miner_id, command_type="reboot", command_status="blocked_confirmation_missing", dry_run=False, target_ip=target.get("target_ip"), target_mac=target.get("target_mac"), requested_by_user_id=user_id, requested_by_username=username, request_payload=request_payload, response_payload={ "message": "Confirmation text did not match REBOOT.", }, error_message="Confirmation text did not match REBOOT.", ) raise HTTPException( status_code=400, detail={ "message": "Type REBOOT to confirm.", "event_id": event_id, }, ) target_ip = minerops_normalize_target_ip(target.get("target_ip")) if not target_ip: event_id = await insert_miner_command_event( miner_id=miner_id, command_type="reboot", command_status="failed_no_target_ip", dry_run=False, target_ip=None, target_mac=target.get("target_mac"), requested_by_user_id=user_id, requested_by_username=username, request_payload=request_payload, response_payload={ "message": "Miner has no current IP address.", }, error_message="Miner has no current IP address.", ) raise HTTPException( status_code=400, detail={ "message": "Miner has no current IP address.", "event_id": event_id, }, ) request_event_id = await insert_miner_command_event( miner_id=miner_id, command_type="reboot", command_status="real_reboot_requested", dry_run=False, target_ip=target_ip, target_mac=target.get("target_mac"), requested_by_user_id=user_id, requested_by_username=username, request_payload=request_payload, response_payload={ "message": "Real reboot requested. Command send starting.", }, ) try: reboot_result = await minerops_send_whatsminer_reboot(target_ip) result_event_id = await insert_miner_command_event( miner_id=miner_id, command_type="reboot", command_status="real_reboot_sent", dry_run=False, target_ip=target_ip, target_mac=target.get("target_mac"), requested_by_user_id=user_id, requested_by_username=username, request_payload=request_payload, response_payload=reboot_result, ) return { "ok": True, "request_event_id": request_event_id, "event_id": result_event_id, "miner_id": miner_id, "command_type": "reboot", "command_status": "real_reboot_sent", "dry_run": False, "target_ip": target_ip, "target_mac": target.get("target_mac"), "model": target.get("model"), "message": "Real reboot command was sent.", "response": reboot_result, } except Exception as exc: result_event_id = await insert_miner_command_event( miner_id=miner_id, command_type="reboot", command_status="real_reboot_send_failed", dry_run=False, target_ip=target_ip, target_mac=target.get("target_mac"), requested_by_user_id=user_id, requested_by_username=username, request_payload=request_payload, response_payload={ "message": "Real reboot command failed.", }, error_message=str(exc), ) raise HTTPException( status_code=502, detail={ "message": "Real reboot command failed.", "event_id": result_event_id, "error": str(exc), }, ) @app.get("/api/miners/{miner_id}/commands") async def list_miner_command_events(miner_id: str, request: Request): minerops_command_require_bearer(request) await ensure_miner_command_tables() conn = await minerops_command_db_connect() try: rows = await conn.fetch( """ SELECT id, miner_id, command_type, command_status, dry_run, requested_by_user_id, requested_by_username, target_ip, target_mac, request_payload, response_payload, error_message, created_at, completed_at FROM miner_command_events WHERE miner_id = $1 ORDER BY created_at DESC LIMIT 25 """, miner_id, ) return { "miner_id": miner_id, "commands": [dict(row) for row in rows], } finally: await conn.close() # ----------------------------- # Step 90: Power control commands # ----------------------------- def minerops_whatsminer_power_command_name(action: str) -> str: import os if action == "power_off": return str(os.environ.get("MINEROPS_WHATSMINER_POWER_OFF_CMD", "power_off") or "power_off").strip() if action == "power_on": return str(os.environ.get("MINEROPS_WHATSMINER_POWER_ON_CMD", "power_on") or "power_on").strip() if action == "suspend": return str(os.environ.get("MINEROPS_WHATSMINER_SUSPEND_CMD", "api3_service:stop") or "api3_service:stop").strip() if action == "resume": return str(os.environ.get("MINEROPS_WHATSMINER_RESUME_CMD", "api3_service:start") or "api3_service:start").strip() raise RuntimeError(f"Unknown power action: {action}") def minerops_send_whatsminer_command_sync( target_ip: str, command_name: str, port: int = 4028, timeout_seconds: float = 10.0, ) -> dict: import json import socket import time target_ip = minerops_normalize_target_ip(target_ip) if not target_ip: raise RuntimeError("No target IP available for command.") command = {"cmd": command_name} payload = json.dumps(command).encode("utf-8") + b"\n" started = time.time() chunks = [] with socket.create_connection((target_ip, port), timeout=timeout_seconds) as sock: sock.settimeout(timeout_seconds) sock.sendall(payload) try: sock.shutdown(socket.SHUT_WR) except Exception: pass while True: try: chunk = sock.recv(65536) if not chunk: break chunks.append(chunk) if sum(len(item) for item in chunks) > 1048576: break except socket.timeout: break raw_response = b"".join(chunks).decode("utf-8", errors="replace").strip() parsed_response = None if raw_response: try: parsed_response = json.loads(raw_response) except Exception: parsed_response = None return { "sent": True, "target_ip": target_ip, "target_port": port, "command": command, "duration_seconds": round(time.time() - started, 3), "raw_response": raw_response, "json_response": parsed_response, } # ----------------------------- # Step 95: WhatsMiner API3 authenticated service control # ----------------------------- def minerops_api3_account() -> str: import os return str(os.environ.get("MINEROPS_WHATSMINER_API3_ACCOUNT", "super") or "super").strip() def minerops_api3_password() -> str: import os return str(os.environ.get("MINEROPS_WHATSMINER_API3_PASSWORD", "super") or "super").strip() def minerops_api3_port() -> int: import os raw = str(os.environ.get("MINEROPS_WHATSMINER_API3_PORT", "4433") or "4433").strip() try: return int(raw) except Exception: return 4433 def minerops_api3_token(command: str, password: str, salt: str, ts: int) -> str: import base64 import hashlib source = f"{command}{password}{salt}{ts}" digest = hashlib.sha256(source.encode("utf-8")).digest() return base64.b64encode(digest).decode("utf-8")[:8] def minerops_api3_recv_all(sock, length: int) -> bytes: data = b"" while len(data) < length: chunk = sock.recv(length - len(data)) if not chunk: break data += chunk return data def minerops_api3_send_payload_sync(target_ip: str, payload: dict, timeout_seconds: float = 10.0) -> dict: import json import socket import struct import time target_ip = minerops_normalize_target_ip(target_ip) if not target_ip: raise RuntimeError("No target IP available for API3 command.") port = minerops_api3_port() started = time.time() request_json = json.dumps(payload, separators=(",", ":")) request_bytes = request_json.encode("utf-8") with socket.create_connection((target_ip, port), timeout=timeout_seconds) as sock: sock.settimeout(timeout_seconds) # WhatsMiner API3 uses a 4-byte little-endian JSON length prefix. sock.sendall(struct.pack(" 1048576: raise RuntimeError(f"API3 response length looked invalid: {response_length}") response_bytes = minerops_api3_recv_all(sock, response_length) if len(response_bytes) != response_length: raise RuntimeError("API3 response ended early.") response_text = response_bytes.decode("utf-8", errors="replace") response_json = json.loads(response_text) return { "sent": True, "protocol": "whatsminer_api3", "target_ip": target_ip, "target_port": port, "duration_seconds": round(time.time() - started, 3), "request": { "cmd": payload.get("cmd"), "param": payload.get("param"), "account": payload.get("account"), "has_token": bool(payload.get("token")), }, "response": response_json, } def minerops_api3_get_salt_sync(target_ip: str) -> tuple[str, dict]: response = minerops_api3_send_payload_sync( target_ip, { "cmd": "get.device.info", "param": None, }, ) response_json = response.get("response") or {} if response_json.get("code") != 0: raise RuntimeError(f"API3 get.device.info failed: {response_json}") msg = response_json.get("msg") or {} salt = msg.get("salt") if not salt: raise RuntimeError(f"API3 get.device.info did not return salt: {response_json}") return str(salt), response def minerops_api3_authenticated_payload(command: str, param): import time account = minerops_api3_account() password = minerops_api3_password() ts = int(time.time()) return account, password, ts def minerops_api3_service_sync(target_ip: str, service_action: str) -> dict: import time if service_action not in {"start", "stop", "restart"}: raise RuntimeError(f"Invalid API3 service action: {service_action}") command = "set.miner.service" account = minerops_api3_account() password = minerops_api3_password() salt, salt_response = minerops_api3_get_salt_sync(target_ip) ts = int(time.time()) token = minerops_api3_token(command, password, salt, ts) payload = { "cmd": command, "param": service_action, "ts": ts, "token": token, "account": account, } result = minerops_api3_send_payload_sync(target_ip, payload) response_json = result.get("response") or {} result["salt_response"] = salt_response result["api3_command"] = command result["api3_param"] = service_action if response_json.get("code") != 0: raise RuntimeError(f"API3 {command} {service_action} failed: {response_json}") return result async def minerops_api3_service(target_ip: str, service_action: str) -> dict: import asyncio return await asyncio.to_thread( minerops_api3_service_sync, target_ip, service_action, ) async def minerops_send_control_command_item(target_ip: str, command_name: str) -> dict: item = str(command_name or "").strip() if item.startswith("api3_service:"): service_action = item.split(":", 1)[1].strip() return await minerops_api3_service(target_ip, service_action) return await minerops_send_whatsminer_command(target_ip, item) def minerops_split_command_sequence(command_name: str) -> list[str]: parts = [] for item in str(command_name or "").split("+"): cleaned = item.strip() if cleaned: parts.append(cleaned) return parts async def minerops_send_whatsminer_command_sequence(target_ip: str, command_name: str) -> dict: import asyncio commands = minerops_split_command_sequence(command_name) if not commands: raise RuntimeError("No command supplied.") results = [] for item in commands: result = await minerops_send_control_command_item(target_ip, item) results.append(result) # Small pause gives the miner time to apply the first command before the next one. if len(commands) > 1: await asyncio.sleep(1.0) return { "sent": True, "target_ip": minerops_normalize_target_ip(target_ip), "command_sequence": commands, "results": results, } async def minerops_send_whatsminer_command(target_ip: str, command_name: str) -> dict: import asyncio port = minerops_get_whatsminer_api_port() return await asyncio.to_thread( minerops_send_whatsminer_command_sync, target_ip, command_name, port, 10.0, ) async def minerops_power_command(miner_id: str, request: Request, action: str): minerops_command_require_bearer(request) try: body = await request.json() except Exception: body = {} dry_run = bool(body.get("dry_run", False)) if action not in {"power_off", "power_on", "suspend", "resume"}: raise HTTPException(status_code=400, detail="Invalid power action.") if not minerops_env_enabled_default_true("MINEROPS_ENABLE_REAL_POWER_CONTROL"): raise HTTPException( status_code=403, detail="Real power control is disabled. Set MINEROPS_ENABLE_REAL_POWER_CONTROL=true or leave it unset to enable.", ) target = await get_miner_command_target(miner_id) if not target: raise HTTPException(status_code=404, detail="Miner not found.") target_ip = minerops_normalize_target_ip(target.get("target_ip")) if not target_ip: event_id = await insert_miner_command_event( miner_id=miner_id, command_type=action, command_status="failed_no_target_ip", dry_run=dry_run, target_ip=None, target_mac=target.get("target_mac"), request_payload={ "action": action, "dry_run": dry_run, }, response_payload={ "message": "Miner has no current IP address.", }, error_message="Miner has no current IP address.", ) raise HTTPException( status_code=400, detail={ "message": "Miner has no current IP address.", "event_id": event_id, }, ) user_id, username = await minerops_command_user_from_request(request) command_name = minerops_whatsminer_power_command_name(action) request_payload = { "action": action, "dry_run": dry_run, "whatsminer_command": command_name, "source": "miner_detail_control_card", "real_power_control_enabled": minerops_env_enabled_default_true("MINEROPS_ENABLE_REAL_POWER_CONTROL"), } if dry_run: event_id = await insert_miner_command_event( miner_id=miner_id, command_type=action, command_status="dry_run_planned", dry_run=True, target_ip=target_ip, target_mac=target.get("target_mac"), requested_by_user_id=user_id, requested_by_username=username, request_payload=request_payload, response_payload={ "message": "Dry run only. No power command was sent.", "target": target, "whatsminer_command": command_name, }, ) return { "ok": True, "event_id": event_id, "miner_id": miner_id, "command_type": action, "command_status": "dry_run_planned", "dry_run": True, "target_ip": target_ip, "target_mac": target.get("target_mac"), "model": target.get("model"), "whatsminer_command": command_name, "message": "Dry run complete. No power command was sent.", } request_event_id = await insert_miner_command_event( miner_id=miner_id, command_type=action, command_status=f"{action}_requested", dry_run=False, target_ip=target_ip, target_mac=target.get("target_mac"), requested_by_user_id=user_id, requested_by_username=username, request_payload=request_payload, response_payload={ "message": f"{action} requested. Command send starting.", "whatsminer_command": command_name, }, ) try: api3_creds = await minerops_get_api3_credentials_for_miner(miner_id) command_result = await minerops_send_whatsminer_command_sequence( target_ip, command_name, api3_creds.get("account"), api3_creds.get("password"), ) result_event_id = await insert_miner_command_event( miner_id=miner_id, command_type=action, command_status=f"{action}_sent", dry_run=False, target_ip=target_ip, target_mac=target.get("target_mac"), requested_by_user_id=user_id, requested_by_username=username, request_payload=request_payload, response_payload=command_result, ) return { "ok": True, "request_event_id": request_event_id, "event_id": result_event_id, "miner_id": miner_id, "command_type": action, "command_status": f"{action}_sent", "dry_run": False, "target_ip": target_ip, "target_mac": target.get("target_mac"), "model": target.get("model"), "whatsminer_command": command_name, "message": f"{action} command was sent.", "response": command_result, } except Exception as exc: result_event_id = await insert_miner_command_event( miner_id=miner_id, command_type=action, command_status=f"{action}_send_failed", dry_run=False, target_ip=target_ip, target_mac=target.get("target_mac"), requested_by_user_id=user_id, requested_by_username=username, request_payload=request_payload, response_payload={ "message": f"{action} command failed.", "whatsminer_command": command_name, }, error_message=str(exc), ) raise HTTPException( status_code=502, detail={ "message": f"{action} command failed.", "event_id": result_event_id, "error": str(exc), }, ) @app.post("/api/miners/{miner_id}/commands/power-off") async def real_power_off_miner_command(miner_id: str, request: Request): return await minerops_power_command(miner_id, request, "power_off") @app.post("/api/miners/{miner_id}/commands/power-on") async def real_power_on_miner_command(miner_id: str, request: Request): return await minerops_power_command(miner_id, request, "power_on") @app.post("/api/miners/{miner_id}/commands/suspend") async def real_suspend_miner_command(miner_id: str, request: Request): return await minerops_power_command(miner_id, request, "suspend") @app.post("/api/miners/{miner_id}/commands/resume") async def real_resume_miner_command(miner_id: str, request: Request): return await minerops_power_command(miner_id, request, "resume") # ----------------------------- # Step 96: Miner API3 credentials and password change # ----------------------------- async def ensure_miner_api_credentials_table(): conn = await minerops_command_db_connect() try: await conn.execute( """ CREATE TABLE IF NOT EXISTS miner_api_credentials ( miner_id TEXT PRIMARY KEY, api_account TEXT NOT NULL DEFAULT 'super', api_password TEXT NOT NULL, password_source TEXT NOT NULL DEFAULT 'minerops', updated_by_username TEXT, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """ ) finally: await conn.close() @app.on_event("startup") async def startup_ensure_miner_api_credentials_table(): await ensure_miner_api_credentials_table() async def minerops_get_api3_credentials_for_miner(miner_id: str) -> dict: await ensure_miner_api_credentials_table() conn = await minerops_command_db_connect() try: row = await conn.fetchrow( """ SELECT api_account, api_password FROM miner_api_credentials WHERE miner_id = $1 """, miner_id, ) finally: await conn.close() if row: return { "account": row["api_account"], "password": row["api_password"], "source": "miner_override", } return { "account": minerops_api3_account(), "password": minerops_api3_password(), "source": "environment_default", } async def minerops_save_api3_credentials_for_miner( miner_id: str, account: str, password: str, username: str | None = None, ): await ensure_miner_api_credentials_table() conn = await minerops_command_db_connect() try: await conn.execute( """ INSERT INTO miner_api_credentials ( miner_id, api_account, api_password, password_source, updated_by_username, updated_at ) VALUES ($1, $2, $3, 'password_change', $4, now()) ON CONFLICT (miner_id) DO UPDATE SET api_account = EXCLUDED.api_account, api_password = EXCLUDED.api_password, password_source = EXCLUDED.password_source, updated_by_username = EXCLUDED.updated_by_username, updated_at = now() """, miner_id, account, password, username, ) finally: await conn.close() def minerops_api3_encrypt_param(param: str, command: str, password: str, salt: str, ts: int) -> str: import base64 import hashlib from Crypto.Cipher import AES source = f"{command}{password}{salt}{ts}" aes_key = hashlib.sha256(source.encode("utf-8")).digest() pad_len = 16 - (len(param) % 16) padded_param = param + (chr(pad_len) * pad_len) cipher = AES.new(aes_key, AES.MODE_ECB) encrypted_bytes = cipher.encrypt(padded_param.encode("utf-8")) return base64.b64encode(encrypted_bytes).decode("utf-8") def minerops_api3_service_sync_with_credentials( target_ip: str, service_action: str, account: str | None = None, password: str | None = None, ) -> dict: import time if service_action not in {"start", "stop", "restart"}: raise RuntimeError(f"Invalid API3 service action: {service_action}") command = "set.miner.service" account = str(account or minerops_api3_account()).strip() password = str(password or minerops_api3_password()).strip() salt, salt_response = minerops_api3_get_salt_sync(target_ip) ts = int(time.time()) token = minerops_api3_token(command, password, salt, ts) payload = { "cmd": command, "param": service_action, "ts": ts, "token": token, "account": account, } result = minerops_api3_send_payload_sync(target_ip, payload) response_json = result.get("response") or {} result["salt_response"] = salt_response result["api3_command"] = command result["api3_param"] = service_action result["api3_account"] = account if response_json.get("code") != 0: raise RuntimeError(f"API3 {command} {service_action} failed: {response_json}") return result async def minerops_api3_service_with_credentials( target_ip: str, service_action: str, account: str | None = None, password: str | None = None, ) -> dict: import asyncio return await asyncio.to_thread( minerops_api3_service_sync_with_credentials, target_ip, service_action, account, password, ) async def minerops_send_control_command_item( target_ip: str, command_name: str, account_override: str | None = None, password_override: str | None = None, ) -> dict: item = str(command_name or "").strip() if item.startswith("api3_service:"): service_action = item.split(":", 1)[1].strip() return await minerops_api3_service_with_credentials( target_ip, service_action, account_override, password_override, ) return await minerops_send_whatsminer_command(target_ip, item) async def minerops_send_whatsminer_command_sequence( target_ip: str, command_name: str, account_override: str | None = None, password_override: str | None = None, ) -> dict: import asyncio commands = minerops_split_command_sequence(command_name) if not commands: raise RuntimeError("No command supplied.") results = [] for item in commands: result = await minerops_send_control_command_item( target_ip, item, account_override, password_override, ) results.append(result) if len(commands) > 1: await asyncio.sleep(1.0) return { "sent": True, "target_ip": minerops_normalize_target_ip(target_ip), "command_sequence": commands, "results": results, "credential_source": "miner_override" if password_override else "environment_default", } def minerops_api3_change_password_sync( target_ip: str, account_to_change: str, old_password: str, new_password: str, ) -> dict: import json import time account_to_change = str(account_to_change or "super").strip() old_password = str(old_password or "") new_password = str(new_password or "") if not account_to_change: raise RuntimeError("Account is required.") if not old_password: raise RuntimeError("Old password is required.") if not new_password: raise RuntimeError("New password is required.") command = "set.user.change_passwd" salt, salt_response = minerops_api3_get_salt_sync(target_ip) ts = int(time.time()) token = minerops_api3_token(command, old_password, salt, ts) param_data = { "account": account_to_change, "new": new_password, "old": old_password, } encrypted_param = minerops_api3_encrypt_param( json.dumps(param_data, separators=(",", ":")), command, old_password, salt, ts, ) payload = { "cmd": command, "ts": ts, "token": token, "account": account_to_change, "param": encrypted_param, } result = minerops_api3_send_payload_sync(target_ip, payload) response_json = result.get("response") or {} result["salt_response"] = salt_response result["api3_command"] = command result["api3_account"] = account_to_change result["passwords_logged"] = False if response_json.get("code") != 0: raise RuntimeError(f"API3 password change failed: {response_json}") return result async def minerops_api3_change_password( target_ip: str, account_to_change: str, old_password: str, new_password: str, ) -> dict: import asyncio return await asyncio.to_thread( minerops_api3_change_password_sync, target_ip, account_to_change, old_password, new_password, ) @app.post("/api/miners/{miner_id}/api3/change-password") async def change_miner_api3_password(miner_id: str, request: Request): minerops_command_require_bearer(request) try: body = await request.json() except Exception: body = {} account = str(body.get("account") or "super").strip() old_password = str(body.get("old_password") or "") new_password = str(body.get("new_password") or "") update_stored = bool(body.get("update_stored", True)) if not old_password or not new_password: raise HTTPException( status_code=400, detail="Old password and new password are required.", ) target = await get_miner_command_target(miner_id) if not target: raise HTTPException(status_code=404, detail="Miner not found.") target_ip = minerops_normalize_target_ip(target.get("target_ip")) if not target_ip: raise HTTPException(status_code=400, detail="Miner has no current IP address.") user_id, username = await minerops_command_user_from_request(request) request_event_id = await insert_miner_command_event( miner_id=miner_id, command_type="change_password", command_status="password_change_requested", dry_run=False, target_ip=target_ip, target_mac=target.get("target_mac"), requested_by_user_id=user_id, requested_by_username=username, request_payload={ "account": account, "update_stored": update_stored, "passwords_logged": False, }, response_payload={ "message": "Password change requested. Password values are not logged.", }, ) try: result = await minerops_api3_change_password( target_ip, account, old_password, new_password, ) if update_stored: await minerops_save_api3_credentials_for_miner( miner_id, account, new_password, username, ) result_event_id = await insert_miner_command_event( miner_id=miner_id, command_type="change_password", command_status="password_change_sent", dry_run=False, target_ip=target_ip, target_mac=target.get("target_mac"), requested_by_user_id=user_id, requested_by_username=username, request_payload={ "account": account, "update_stored": update_stored, "passwords_logged": False, }, response_payload={ "message": "Password change command sent successfully.", "api3_command": "set.user.change_passwd", "account": account, "stored_in_minerops": update_stored, "miner_response": result.get("response"), }, ) return { "ok": True, "event_id": result_event_id, "request_event_id": request_event_id, "miner_id": miner_id, "target_ip": target_ip, "target_mac": target.get("target_mac"), "account": account, "stored_in_minerops": update_stored, "message": "Password changed. Password values were not logged.", "response": result.get("response"), } except Exception as exc: result_event_id = await insert_miner_command_event( miner_id=miner_id, command_type="change_password", command_status="password_change_failed", dry_run=False, target_ip=target_ip, target_mac=target.get("target_mac"), requested_by_user_id=user_id, requested_by_username=username, request_payload={ "account": account, "update_stored": update_stored, "passwords_logged": False, }, response_payload={ "message": "Password change failed. Password values are not logged.", }, error_message=str(exc), ) raise HTTPException( status_code=502, detail={ "message": "Password change failed.", "event_id": result_event_id, "error": str(exc), }, ) MINEROPS_API3_CREDENTIALS_JS = r""" (function () { function getToken() { const keys = [ "minerops_token", "access_token", "token", "jwt", "auth_token" ]; for (const key of keys) { const localValue = localStorage.getItem(key); if (localValue) return localValue; const sessionValue = sessionStorage.getItem(key); if (sessionValue) return sessionValue; } return ""; } function minerIdFromPage() { const card = document.getElementById("minerControlCard"); if (card && card.dataset.minerId) { return card.dataset.minerId; } const match = window.location.pathname.match(/\/miners\/([^\/]+)\/detail/); if (match) { return decodeURIComponent(match[1]); } return ""; } function setStatus(message, kind) { const box = document.getElementById("minerControlStatus"); if (!box) { alert(message); return; } box.className = "status-box " + (kind || ""); box.textContent = message; } async function fetchJson(url, options) { const response = await fetch(url, options || {}); let data = null; try { data = await response.json(); } catch (error) { data = null; } if (!response.ok) { let message = response.status + " " + response.statusText; if (data && data.detail) { if (typeof data.detail === "string") { message = data.detail; } else if (data.detail.message) { message = data.detail.message + (data.detail.error ? ": " + data.detail.error : ""); } else { message = JSON.stringify(data.detail); } } throw new Error(message); } return data; } function ensureApi3Ui() { const card = document.getElementById("minerControlCard"); if (!card || document.getElementById("api3CredentialBox")) { return; } const reminder = document.createElement("div"); reminder.id = "webApiReminder"; reminder.className = "status-box warn"; reminder.style.marginTop = "12px"; reminder.innerHTML = "New miner reminder: enable WhatsMiner WebAPI/API3 before using Reboot, Suspend, Resume, or password changes. After adding or replacing a miner, enable WebAPI in WhatsMinerTool or the miner controls will not work."; const box = document.createElement("div"); box.id = "api3CredentialBox"; box.className = "card"; box.style.marginTop = "12px"; box.innerHTML = [ "

Miner API Password

", "

Change this miner's API password and save it in MinerOps for future Suspend/Resume commands. Password values are not written to command history.

", "
", " ", " ", " ", " ", "
" ].join(""); card.appendChild(reminder); card.appendChild(box); const button = document.getElementById("changeMinerApiPasswordButton"); if (button) { button.onclick = changeMinerApiPassword; } } async function changeMinerApiPassword() { const minerId = minerIdFromPage(); const token = getToken(); if (!minerId) { setStatus("Could not detect miner ID.", "bad"); return; } if (!token) { setStatus("Log in first.", "bad"); return; } const account = (document.getElementById("api3AccountInput") || {}).value || "super"; const oldPassword = (document.getElementById("api3OldPasswordInput") || {}).value || ""; const newPassword = (document.getElementById("api3NewPasswordInput") || {}).value || ""; if (!oldPassword || !newPassword) { setStatus("Enter the current password and new password.", "bad"); return; } if (!confirm("Change the API password on this miner now?")) { setStatus("Password change cancelled.", "warn"); return; } setStatus("Changing miner API password...", "warn"); try { const data = await fetchJson("/api/miners/" + encodeURIComponent(minerId) + "/api3/change-password", { method: "POST", headers: { "Authorization": "Bearer " + token, "Content-Type": "application/json" }, body: JSON.stringify({ account: account, old_password: oldPassword, new_password: newPassword, update_stored: true }) }); document.getElementById("api3OldPasswordInput").value = ""; document.getElementById("api3NewPasswordInput").value = ""; setStatus(data.message || "Password changed and saved for this miner.", "ok"); if (typeof window.loadMinerCommandHistory === "function") { window.loadMinerCommandHistory(); } } catch (error) { setStatus("Password change error: " + error.message, "bad"); if (typeof window.loadMinerCommandHistory === "function") { window.loadMinerCommandHistory(); } } } document.addEventListener("DOMContentLoaded", function () { ensureApi3Ui(); setTimeout(ensureApi3Ui, 500); setTimeout(ensureApi3Ui, 1500); }); })(); """ @app.get("/assets/minerops-api3-credentials.js") async def minerops_api3_credentials_js(): return Response( content=MINEROPS_API3_CREDENTIALS_JS, media_type="application/javascript", ) # ----------------------------- # Phase 5F: Hub mode status and UI helper # ----------------------------- @app.get("/api/hub/mode") async def minerops_hub_mode_status(): import os def env_true(name: str) -> bool: value = str(os.environ.get(name, "") or "").strip().lower() return value in {"1", "true", "yes", "on", "enabled"} hub_mode = env_true("MINEROPS_HUB_MODE") or env_true("MINEROPS_DISABLE_LOCAL_SCANNER") scanner_enabled = str(os.environ.get("SCHEDULED_SCAN_ENABLED", "true") or "true").strip().lower() not in {"0", "false", "no", "off", "disabled"} return { "hub_mode": hub_mode, "local_scanner_enabled": scanner_enabled, "local_scanner_disabled": hub_mode or not scanner_enabled, "message": "Hub Mode: Windows site agent should poll miners and push updates to this hub." if hub_mode else "Local scanner mode." } MINEROPS_HUB_MODE_JS = r""" (function () { async function fetchHubMode() { try { const response = await fetch("/api/hub/mode", { cache: "no-store" }); if (!response.ok) return null; return await response.json(); } catch (error) { return null; } } function ensureBanner(data) { if (!data || !data.hub_mode) return; let banner = document.getElementById("mineropsHubModeBanner"); if (!banner) { banner = document.createElement("div"); banner.id = "mineropsHubModeBanner"; banner.style.cssText = [ "position:sticky", "top:0", "z-index:9998", "margin:0 0 12px 0", "padding:10px 14px", "border-radius:10px", "border:1px solid #facc15", "background:#fef9c3", "color:#713f12", "font-weight:700", "box-shadow:0 2px 8px rgba(15,23,42,.12)" ].join(";"); const target = document.querySelector("main") || document.body; target.insertBefore(banner, target.firstChild); } banner.textContent = "Hub Mode: local miner scanning is disabled on this server. The Windows site agent will poll miners and push updates here."; } function disableScanButtons(data) { if (!data || !data.hub_mode) return; const scanWords = [ "run scan", "scan now", "known-miner scan", "run known" ]; document.querySelectorAll("button, a").forEach(function (el) { const text = (el.textContent || "").trim().toLowerCase(); if (!text) return; const isScanControl = scanWords.some(function (word) { return text.includes(word); }); if (!isScanControl) return; el.dataset.originalText = el.dataset.originalText || el.textContent; el.textContent = "Hub Mode: use Windows Agent"; el.title = "Local scanner is disabled on the hub. The Windows site agent will poll miners."; el.disabled = true; el.style.opacity = "0.65"; el.style.cursor = "not-allowed"; el.onclick = function (event) { event.preventDefault(); alert("Hub Mode is enabled. The Windows site agent will poll miners and push updates to this hub."); return false; }; }); } function updateScannerPanel(data) { if (!data || !data.hub_mode) return; const state = document.getElementById("scannerState"); if (state) state.textContent = "Hub Mode"; const next = document.getElementById("scannerNextRun"); if (next) next.textContent = "Windows Agent"; const started = document.getElementById("scannerLastStarted"); if (started) started.textContent = "Local scanner disabled. Waiting for Windows site agent updates."; const raw = document.getElementById("rawOutput"); if (raw && raw.textContent && raw.textContent.includes("Scanner is idle")) { raw.textContent = "Hub Mode: local scanner disabled. Waiting for Windows site agent updates."; } } async function applyHubModeUi() { const data = await fetchHubMode(); if (!data || !data.hub_mode) return; ensureBanner(data); disableScanButtons(data); updateScannerPanel(data); } document.addEventListener("DOMContentLoaded", function () { applyHubModeUi(); setTimeout(applyHubModeUi, 500); setTimeout(applyHubModeUi, 1500); setInterval(applyHubModeUi, 5000); }); })(); """ @app.get("/assets/minerops-hub-mode.js") async def minerops_hub_mode_js(): return Response( content=MINEROPS_HUB_MODE_JS, media_type="application/javascript", ) # ----------------------------- # Phase 5G: Windows site agent hub endpoints # ----------------------------- def minerops_agent_secret_expected() -> str: return settings.minerops_agent_shared_secret.strip() def minerops_require_agent_secret(request: Request): import hmac expected = minerops_agent_secret_expected() provided = str(request.headers.get("X-MinerOps-Agent-Secret", "") or "").strip() if not expected: raise HTTPException( status_code=500, detail="MINEROPS_AGENT_SHARED_SECRET is not configured on the hub.", ) if not provided or not hmac.compare_digest(provided, expected): raise HTTPException( status_code=401, detail="Invalid or missing agent secret.", ) async def ensure_minerops_agent_tables(): conn = await minerops_command_db_connect() try: await conn.execute( """ CREATE TABLE IF NOT EXISTS minerops_site_agents ( agent_id TEXT PRIMARY KEY, site_name TEXT NOT NULL, hostname TEXT, version TEXT, ip_address TEXT, last_checkin_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_payload JSONB NOT NULL DEFAULT '{}'::jsonb ) """ ) await conn.execute( """ CREATE TABLE IF NOT EXISTS minerops_agent_snapshots ( id BIGSERIAL PRIMARY KEY, agent_id TEXT NOT NULL, site_name TEXT NOT NULL, snapshot_kind TEXT NOT NULL DEFAULT 'miner_poll', received_at TIMESTAMPTZ NOT NULL DEFAULT now(), miner_count INTEGER NOT NULL DEFAULT 0, payload JSONB NOT NULL ) """ ) await conn.execute( """ CREATE INDEX IF NOT EXISTS idx_minerops_agent_snapshots_received ON minerops_agent_snapshots (received_at DESC) """ ) await conn.execute( """ CREATE TABLE IF NOT EXISTS minerops_agent_command_results ( id BIGSERIAL PRIMARY KEY, agent_id TEXT NOT NULL, site_name TEXT NOT NULL, command_id TEXT, received_at TIMESTAMPTZ NOT NULL DEFAULT now(), status TEXT NOT NULL, payload JSONB NOT NULL ) """ ) await conn.execute( """ CREATE INDEX IF NOT EXISTS idx_minerops_agent_command_results_received ON minerops_agent_command_results (received_at DESC) """ ) finally: await conn.close() @app.on_event("startup") async def startup_ensure_minerops_agent_tables(): await ensure_minerops_agent_tables() @app.get("/api/agent/config") async def minerops_agent_config(request: Request): minerops_require_agent_secret(request) return { "ok": True, "site_name": "Plexus", "poll_interval_seconds": 120, "hub_url": "https://plexus.lussier.cc", "ip_ranges": [ { "name": "Unit 4", "cidr": "10.30.0.0/22", "enabled": True, "api_port": 4028, "api3_port": 4433, }, { "name": "Unit 6", "cidr": "10.80.0.0/22", "enabled": True, "api_port": 4028, "api3_port": 4433, }, { "name": "Unit 12", "cidr": "10.90.0.0/22", "enabled": False, "api_port": 4028, "api3_port": 4433, }, ], "message": "Agent config from hub.", } @app.post("/api/agent/miner-snapshots") async def minerops_agent_miner_snapshots( request: Request, authenticated_agent_id: str = Depends(require_agent_identity), zone_service: AgentZoneService = Depends(get_agent_zone_service), ): import json await ensure_minerops_agent_tables() try: body = await request.json() except Exception: body = {} zone_service.validate_agent_identity( authenticated_agent_id, str(body.get("agent_id") or "").strip() or None ) site_name = str(body.get("site_name") or "Unassigned").strip() miners = body.get("miners") if not isinstance(miners, list): miners = [] miner_ids = [] for item in miners: if not isinstance(item, dict): continue try: miner_ids.append(UUID(str(item.get("miner_id") or ""))) except ValueError: continue ownership = zone_service.associate_miners_from_agent( authenticated_agent_id, miner_ids ) conn = await minerops_command_db_connect() try: row = await conn.fetchrow( """ INSERT INTO minerops_agent_snapshots ( agent_id, agent_zone_id, site_name, snapshot_kind, miner_count, payload ) VALUES ($1, $2::uuid, $3, 'miner_poll', $4, $5::jsonb) RETURNING id, received_at """, authenticated_agent_id, str(ownership.agent_zone_id) if ownership.agent_zone_id else None, site_name, len(miners), json.dumps(body), ) finally: await conn.close() apply_result = await minerops_apply_agent_snapshot_to_live_tables( body, ownership ) return { "ok": True, "snapshot_id": row["id"] if row else None, "received_at": row["received_at"].isoformat() if row and row["received_at"] else None, "miner_count": len(miners), "agent_zone_id": str(ownership.agent_zone_id) if ownership.agent_zone_id else None, "routing_reason": ownership.routing_reason, "apply_result": apply_result, "message": "Miner snapshot received, staged, and applied to live tables.", } @app.get("/api/agent/commands/pending-disabled-old") async def minerops_agent_commands_pending(request: Request): minerops_require_agent_secret(request) # Command queue will be wired in the next step. return { "ok": True, "commands": [], "message": "No pending commands.", } @app.post("/api/agent/commands/result-disabled-old") async def minerops_agent_commands_result(request: Request): import json minerops_require_agent_secret(request) await ensure_minerops_agent_tables() try: body = await request.json() except Exception: body = {} agent_id = str(body.get("agent_id") or "plexus-windows-agent").strip() site_name = str(body.get("site_name") or "Plexus").strip() command_id = str(body.get("command_id") or "").strip() status = str(body.get("status") or "unknown").strip() conn = await minerops_command_db_connect() try: row = await conn.fetchrow( """ INSERT INTO minerops_agent_command_results ( agent_id, site_name, command_id, status, payload ) VALUES ($1, $2, $3, $4, $5::jsonb) RETURNING id, received_at """, agent_id, site_name, command_id, status, json.dumps(body), ) finally: await conn.close() return { "ok": True, "result_id": row["id"] if row else None, "received_at": row["received_at"].isoformat() if row and row["received_at"] else None, "message": "Command result received.", } # ----------------------------- # Phase 5H.1: Agent setup page # ----------------------------- @app.get("/api/agent/setup-info") async def minerops_agent_setup_info(current_user: dict = Depends(get_current_user)): secret = minerops_agent_secret_expected() return { "ok": True, "hub_url": "https://plexus.lussier.cc", "agent_id": settings.minerops_agent_id, "site_name": "Plexus", "secret_configured": bool(secret), "config_path": "C:\\MinerOpsAgent\\agent-config.json", "message": "Agent credentials are provisioned locally and are never returned to browsers.", } @app.get("/agent/setup") async def minerops_agent_setup_page(): html = """ MinerOps Agent Setup
MINEROPS CONSOLE

Windows Agent Setup

Plexus Mining Site
Loading...

Agent Connection Info

Agent credentials must be provisioned directly on the Windows host.

WebAPI Reminder

For every new or replaced WhatsMiner, enable WebAPI/API3 before Reboot, Suspend, Resume, password changes, or agent polling will work.
""" return HTMLResponse(content=html) # ----------------------------- # Phase 5I.1: Agent known miners endpoint # ----------------------------- @app.get("/api/agent/known-miners") async def minerops_agent_known_miners( authenticated_agent_id: str = Depends(require_agent_identity), zone_service: AgentZoneService = Depends(get_agent_zone_service), ): routing = zone_service.routing_context(authenticated_agent_id) if routing.routing_reason or routing.agent_zone_id is None: raise HTTPException( status_code=409, detail=( "Agent routing unavailable: " + (routing.routing_reason or "agent_zone_unassigned") ), ) conn = await minerops_command_db_connect() try: rows = await conn.fetch( """ SELECT miners.id::text AS miner_id, host(miners.current_ip) AS ip_address, miners.current_mac AS mac_address, COALESCE(rooms.name, 'Unassigned') AS room_name, COALESCE(NULLIF(miners.miner_type, ''), NULLIF(miners.model, ''), '') AS model, COALESCE(miners.status, '') AS status FROM miners LEFT JOIN rooms ON rooms.id = miners.room_id WHERE miners.current_ip IS NOT NULL AND miners.agent_zone_id = $1::uuid ORDER BY rooms.sort_order NULLS LAST, rooms.name NULLS LAST, miners.current_ip """, str(routing.agent_zone_id), ) finally: await conn.close() miners = [] for row in rows: miners.append( { "miner_id": row["miner_id"], "ip_address": row["ip_address"], "mac_address": row["mac_address"], "room_name": row["room_name"], "model": row["model"], "status": row["status"], } ) return { "ok": True, "count": len(miners), "miners": miners, "message": "Known miners for site agent polling.", } # ----------------------------- # Phase 5J: Apply Windows Agent snapshots to live miner tables # ----------------------------- def minerops_agent_to_float(value): if value is None: return None try: text = str(value).strip() if text == "": return None return float(text) except Exception: return None def minerops_agent_to_int(value): number = minerops_agent_to_float(value) if number is None: return None try: return int(number) except Exception: return None def minerops_agent_to_bool(value): if value is None: return None if isinstance(value, bool): return value text = str(value).strip().lower() if text in {"true", "1", "yes", "y", "stable"}: return True if text in {"false", "0", "no", "n", "unstable"}: return False return None def minerops_agent_parse_summary(miner_payload: dict) -> dict: import json responses = miner_payload.get("responses") or {} summary = responses.get("summary") or {} raw = summary.get("raw") or "" parsed = summary.get("json") if parsed is None and raw: try: parsed = json.loads(raw) except Exception: parsed = None if not isinstance(parsed, dict): parsed = {} msg = parsed.get("Msg") or parsed.get("msg") or {} if not isinstance(msg, dict): msg = {} return { "raw": parsed, "msg": msg, } async def minerops_apply_agent_snapshot_to_live_tables( snapshot_body: dict, ownership: MinerZoneAssociationResult ) -> dict: import json import uuid from datetime import datetime, timezone miners = snapshot_body.get("miners") if not isinstance(miners, list): miners = [] now = datetime.now(timezone.utc) applied = 0 online_count = 0 offline_count = 0 errors = [] conn = await minerops_command_db_connect() try: for item in miners: if not isinstance(item, dict): continue miner_id = str(item.get("miner_id") or "").strip() ip_address = str(item.get("ip_address") or "").strip() online = bool(item.get("online")) if not miner_id: continue try: miner_uuid = UUID(miner_id) except ValueError: errors.append({"miner_id": miner_id, "error": "invalid_miner_id"}) continue if ownership.routing_reason: errors.append( {"miner_id": miner_id, "error": ownership.routing_reason} ) continue zone_outcome = ownership.outcomes.get(miner_uuid, "miner_not_found") if zone_outcome not in {"assigned", "preserved"}: errors.append({"miner_id": miner_id, "error": zone_outcome}) continue parsed = minerops_agent_parse_summary(item) raw_summary = parsed["raw"] msg = parsed["msg"] status = "online" if online else "offline" mhs_av = minerops_agent_to_float(msg.get("MHS av")) mhs_1m = minerops_agent_to_float(msg.get("MHS 1m")) mhs_15m = minerops_agent_to_float(msg.get("MHS 15m")) hs_rt = minerops_agent_to_float(msg.get("HS RT")) power_w = minerops_agent_to_float(msg.get("Power")) power_rate = minerops_agent_to_float(msg.get("Power Rate")) env_temp = minerops_agent_to_float(msg.get("Env Temp")) chip_temp_min = minerops_agent_to_float(msg.get("Chip Temp Min")) chip_temp_max = minerops_agent_to_float(msg.get("Chip Temp Max")) chip_temp_avg = minerops_agent_to_float(msg.get("Chip Temp Avg")) fan_in = minerops_agent_to_int(msg.get("Fan Speed In")) fan_out = minerops_agent_to_int(msg.get("Fan Speed Out")) uptime = minerops_agent_to_int(msg.get("Uptime")) hash_stable = minerops_agent_to_bool(msg.get("Hash Stable")) pool_rejected = minerops_agent_to_float(msg.get("Pool Rejected%")) try: if online: online_count += 1 await conn.execute( """ UPDATE miners SET status = $2, current_ip = COALESCE(NULLIF($3, '')::inet, current_ip), last_seen_at = $4, updated_at = now() WHERE id = $1::uuid """, miner_id, status, ip_address, now, ) await conn.execute( """ INSERT INTO miner_status_snapshots ( id, miner_id, observed_ip, observed_at, mhs_av, mhs_1m, mhs_15m, hs_rt, power_w, power_rate_w_per_th, env_temp_c, chip_temp_min_c, chip_temp_max_c, chip_temp_avg_c, fan_speed_in_rpm, fan_speed_out_rpm, uptime_seconds, hash_stable, pool_rejected_percent, raw_summary_json, created_at ) VALUES ( $1::uuid, $2::uuid, NULLIF($3, '')::inet, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20::jsonb, now() ) """, str(uuid.uuid4()), miner_id, ip_address, now, mhs_av, mhs_1m, mhs_15m, hs_rt, power_w, power_rate, env_temp, chip_temp_min, chip_temp_max, chip_temp_avg, fan_in, fan_out, uptime, hash_stable, pool_rejected, json.dumps(raw_summary), ) else: offline_count += 1 await conn.execute( """ UPDATE miners SET status = $2, current_ip = COALESCE(NULLIF($3, '')::inet, current_ip), updated_at = now() WHERE id = $1::uuid """, miner_id, status, ip_address, ) applied += 1 except Exception as exc: if len(errors) < 20: errors.append( {"miner_id": miner_id, "ip_address": ip_address, "error": str(exc)} ) finally: await conn.close() return { "applied": applied, "online": online_count, "offline": offline_count, "errors": errors, } # ----------------------------- # Phase 5K: Agent command queue # ----------------------------- async def minerops_ensure_agent_command_queue_table(): conn = await minerops_command_db_connect() try: await conn.execute( """ CREATE TABLE IF NOT EXISTS minerops_agent_command_queue ( id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, command_type TEXT NOT NULL, command_status TEXT NOT NULL DEFAULT 'pending', requested_by_user_id TEXT, requested_by_username TEXT, request_payload JSONB NOT NULL DEFAULT '{}'::jsonb, result_payload JSONB, error_message TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), claimed_at TIMESTAMPTZ, completed_at TIMESTAMPTZ ) """ ) await conn.execute( """ CREATE INDEX IF NOT EXISTS idx_minerops_agent_command_queue_agent_status ON minerops_agent_command_queue(agent_id, command_status, created_at) """ ) finally: await conn.close() @app.on_event("startup") async def startup_ensure_minerops_agent_command_queue_table(): await minerops_ensure_agent_command_queue_table() @app.post("/api/agent/commands/run-poll-now") async def minerops_queue_agent_poll_now(current_user: dict = Depends(get_current_user)): import uuid import json await minerops_ensure_agent_command_queue_table() command_id = str(uuid.uuid4()) username = str(current_user.get("username") or current_user.get("email") or "unknown") user_id = str(current_user.get("id") or "") conn = await minerops_command_db_connect() try: await conn.execute( """ INSERT INTO minerops_agent_command_queue ( id, agent_id, command_type, command_status, requested_by_user_id, requested_by_username, request_payload, created_at ) VALUES ( $1, $2, $3, 'pending', $4, $5, $6::jsonb, now() ) """, command_id, "plexus-windows-agent", "poll_known_miners_now", user_id, username, json.dumps({"source": "web", "reason": "manual_poll_now"}), ) finally: await conn.close() return { "ok": True, "command_id": command_id, "command_type": "poll_known_miners_now", "message": "Agent poll command queued.", } @app.get("/api/agent/commands/status") async def minerops_agent_command_status(current_user: dict = Depends(get_current_user)): await minerops_ensure_agent_command_queue_table() conn = await minerops_command_db_connect() try: rows = await conn.fetch( """ SELECT id, agent_id, command_type, command_status, requested_by_username, error_message, created_at, claimed_at, completed_at, result_payload FROM minerops_agent_command_queue ORDER BY created_at DESC LIMIT 20 """ ) finally: await conn.close() commands = [] for row in rows: commands.append( { "id": row["id"], "agent_id": row["agent_id"], "command_type": row["command_type"], "command_status": row["command_status"], "requested_by_username": row["requested_by_username"], "error_message": row["error_message"], "created_at": row["created_at"].isoformat() if row["created_at"] else None, "claimed_at": row["claimed_at"].isoformat() if row["claimed_at"] else None, "completed_at": row["completed_at"].isoformat() if row["completed_at"] else None, "result_payload": row["result_payload"], } ) return { "ok": True, "commands": commands, } # ----------------------------- # Phase 5K.5: Agent control web page # ----------------------------- @app.get("/api/agent/status-summary") async def minerops_agent_status_summary(current_user: dict = Depends(get_current_user)): await minerops_ensure_agent_command_queue_table() await ensure_minerops_agent_tables() conn = await minerops_command_db_connect() try: agent = await conn.fetchrow( """ SELECT agent_id, site_name, hostname, version, ip_address, last_checkin_at, last_payload FROM minerops_site_agents WHERE agent_id = 'plexus-windows-agent' ORDER BY last_checkin_at DESC LIMIT 1 """ ) snapshot = await conn.fetchrow( """ SELECT id, received_at, miner_count, payload FROM minerops_agent_snapshots WHERE agent_id = 'plexus-windows-agent' ORDER BY id DESC LIMIT 1 """ ) latest_counts = await conn.fetchrow( """ SELECT COUNT(*) FILTER (WHERE status = 'online') AS online_count, COUNT(*) FILTER (WHERE status = 'offline') AS offline_count, COUNT(*) AS total_count FROM miners """ ) recent_commands = await conn.fetch( """ SELECT id, command_type, command_status, requested_by_username, created_at, claimed_at, completed_at, error_message, result_payload FROM minerops_agent_command_queue ORDER BY created_at DESC LIMIT 10 """ ) finally: await conn.close() commands = [] for row in recent_commands: commands.append( { "id": row["id"], "command_type": row["command_type"], "command_status": row["command_status"], "requested_by_username": row["requested_by_username"], "created_at": row["created_at"].isoformat() if row["created_at"] else None, "claimed_at": row["claimed_at"].isoformat() if row["claimed_at"] else None, "completed_at": row["completed_at"].isoformat() if row["completed_at"] else None, "error_message": row["error_message"], "result_payload": row["result_payload"], } ) latest_snapshot_payload = snapshot["payload"] if snapshot else {} latest_miners = latest_snapshot_payload.get("miners") if isinstance(latest_snapshot_payload, dict) else [] latest_online = 0 latest_offline = 0 if isinstance(latest_miners, list): for item in latest_miners: if isinstance(item, dict) and item.get("online"): latest_online += 1 else: latest_offline += 1 return { "ok": True, "agent": { "agent_id": agent["agent_id"] if agent else None, "site_name": agent["site_name"] if agent else None, "hostname": agent["hostname"] if agent else None, "version": agent["version"] if agent else None, "ip_address": agent["ip_address"] if agent else None, "last_checkin_at": agent["last_checkin_at"].isoformat() if agent and agent["last_checkin_at"] else None, }, "latest_snapshot": { "id": snapshot["id"] if snapshot else None, "received_at": snapshot["received_at"].isoformat() if snapshot and snapshot["received_at"] else None, "miner_count": snapshot["miner_count"] if snapshot else 0, "online": latest_online, "offline": latest_offline, }, "live_database_counts": { "total": latest_counts["total_count"] if latest_counts else 0, "online": latest_counts["online_count"] if latest_counts else 0, "offline": latest_counts["offline_count"] if latest_counts else 0, }, "recent_commands": commands, } @app.get("/agent/control") async def minerops_agent_control_page(): html = """ MinerOps Agent Control
MINEROPS CONSOLE

Agent Control

Windows Agent for Plexus Mining Site
Loading...

Manual Actions

This queues a command on the hub. The Windows Agent picks it up, polls known miners, sends a snapshot back, and updates the dashboard.
DB Total
-
DB Online
-
Latest Snapshot Online
-
Latest Snapshot Offline
-

Agent Status

Agent ID-
Hostname-
Version-
Last Check-in-
Latest Snapshot-

Discovery Summary

Loading discovery summary...

Note: An unmatched discovery does not always mean new hardware. It may be an existing miner with a changed IP, missing MAC, or intermittent hardware. Use Enrich / Match before Add as New.

New Miner Discoveries

Found Room IP MAC Model Status Matched Miner Actions
Loading...

Recent Agent Commands

Created Type Status Requested By Claimed Completed Error
Loading...
""" return HTMLResponse(content=html) # ----------------------------- # Phase 5L: Agent new miner discovery # ----------------------------- async def minerops_ensure_agent_discovery_table(): conn = await minerops_command_db_connect() try: await conn.execute( """ CREATE TABLE IF NOT EXISTS minerops_agent_discoveries ( id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, site_name TEXT NOT NULL, room_name TEXT, ip_address INET, mac_address TEXT, model TEXT, hostname TEXT, miner_sn TEXT, raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb, discovery_status TEXT NOT NULL DEFAULT 'new', matched_miner_id UUID, first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """ ) await conn.execute( """ CREATE INDEX IF NOT EXISTS idx_minerops_agent_discoveries_mac ON minerops_agent_discoveries(mac_address) """ ) await conn.execute( """ CREATE INDEX IF NOT EXISTS idx_minerops_agent_discoveries_ip ON minerops_agent_discoveries(ip_address) """ ) finally: await conn.close() @app.on_event("startup") async def startup_ensure_minerops_agent_discovery_table(): await minerops_ensure_agent_discovery_table() @app.post("/api/agent/commands/scan-new-miners") async def minerops_queue_agent_scan_new_miners(current_user: dict = Depends(get_current_user)): import uuid import json await minerops_ensure_agent_command_queue_table() command_id = str(uuid.uuid4()) username = str(current_user.get("username") or current_user.get("email") or "unknown") user_id = str(current_user.get("id") or "") conn = await minerops_command_db_connect() try: await conn.execute( """ INSERT INTO minerops_agent_command_queue ( id, agent_id, command_type, command_status, requested_by_user_id, requested_by_username, request_payload, created_at ) VALUES ( $1, $2, $3, 'pending', $4, $5, $6::jsonb, now() ) """, command_id, "plexus-windows-agent", "discover_new_miners", user_id, username, json.dumps({"source": "web", "reason": "manual_new_miner_scan"}), ) finally: await conn.close() return { "ok": True, "command_id": command_id, "command_type": "discover_new_miners", "message": "New-miner discovery command queued.", } @app.post("/api/agent/discoveries") async def minerops_agent_post_discoveries( request: Request, authenticated_agent_id: str = Depends(require_agent_identity), zone_service: AgentZoneService = Depends(get_agent_zone_service), ): import json import uuid await minerops_ensure_agent_discovery_table() body = await request.json() zone_service.validate_agent_identity( authenticated_agent_id, str(body.get("agent_id") or "").strip() or None ) site_name = str(body.get("site_name") or "Unassigned").strip() discoveries = body.get("discoveries") or [] if not isinstance(discoveries, list): discoveries = [] ownership_context = zone_service.associate_miners_from_agent( authenticated_agent_id, [] ) conn = await minerops_command_db_connect() inserted = 0 matched_existing = 0 staged_new = 0 errors = [] try: for item in discoveries: if not isinstance(item, dict): continue ip_address = str(item.get("ip_address") or "").strip() mac_address = str(item.get("mac_address") or "").strip().upper() room_name = str(item.get("room_name") or "").strip() model = str(item.get("model") or "").strip() hostname = str(item.get("hostname") or "").strip() miner_sn = str(item.get("miner_sn") or "").strip() matched = None if mac_address: matched = await conn.fetchrow( "SELECT id FROM miners WHERE upper(current_mac) = upper($1) LIMIT 1", mac_address, ) if not matched and ip_address: matched = await conn.fetchrow( "SELECT id FROM miners WHERE current_ip = NULLIF($1, '')::inet LIMIT 1", ip_address, ) status = "known" if matched else "new" if matched: matched_existing += 1 association = zone_service.associate_miners_from_agent( authenticated_agent_id, [matched["id"]] ) outcome = association.outcomes.get(matched["id"]) if outcome not in {"assigned", "preserved"}: status = "zone_conflict" if outcome else "known" if outcome: errors.append( {"miner_id": str(matched["id"]), "error": outcome} ) else: staged_new += 1 try: await conn.execute( """ INSERT INTO minerops_agent_discoveries ( id, agent_id, agent_zone_id, site_name, room_name, ip_address, mac_address, model, hostname, miner_sn, raw_payload, discovery_status, matched_miner_id, first_seen_at, last_seen_at, created_at, updated_at ) VALUES ( $1, $2, $3::uuid, $4, NULLIF($5, ''), NULLIF($6, '')::inet, NULLIF($7, ''), NULLIF($8, ''), NULLIF($9, ''), NULLIF($10, ''), $11::jsonb, $12, $13::uuid, now(), now(), now(), now() ) ON CONFLICT (id) DO NOTHING """, str(uuid.uuid4()), authenticated_agent_id, str(ownership_context.agent_zone_id) if ownership_context.agent_zone_id else None, site_name, room_name, ip_address, mac_address, model, hostname, miner_sn, json.dumps(item), status, str(matched["id"]) if matched else None, ) inserted += 1 except Exception as exc: if len(errors) < 20: errors.append( {"ip_address": ip_address, "mac_address": mac_address, "error": str(exc)} ) finally: await conn.close() return { "ok": True, "received": len(discoveries), "inserted": inserted, "matched_existing": matched_existing, "staged_new": staged_new, "agent_zone_id": str(ownership_context.agent_zone_id) if ownership_context.agent_zone_id else None, "routing_reason": ownership_context.routing_reason, "errors": errors, "message": "Discovery results received.", } @app.get("/api/agent/discoveries") async def minerops_agent_discoveries(current_user: dict = Depends(get_current_user)): await minerops_ensure_agent_discovery_table() conn = await minerops_command_db_connect() try: rows = await conn.fetch( """ SELECT id, agent_id, site_name, room_name, ip_address::text AS ip_address, mac_address, model, hostname, miner_sn, discovery_status, matched_miner_id::text AS matched_miner_id, first_seen_at, last_seen_at, created_at FROM minerops_agent_discoveries ORDER BY created_at DESC LIMIT 100 """ ) finally: await conn.close() items = [] for row in rows: items.append( { "id": row["id"], "agent_id": row["agent_id"], "site_name": row["site_name"], "room_name": row["room_name"], "ip_address": row["ip_address"], "mac_address": row["mac_address"], "model": row["model"], "hostname": row["hostname"], "miner_sn": row["miner_sn"], "discovery_status": row["discovery_status"], "matched_miner_id": row["matched_miner_id"], "first_seen_at": row["first_seen_at"].isoformat() if row["first_seen_at"] else None, "last_seen_at": row["last_seen_at"].isoformat() if row["last_seen_at"] else None, "created_at": row["created_at"].isoformat() if row["created_at"] else None, } ) return { "ok": True, "discoveries": items, } @app.post("/api/agent/discoveries/review") async def minerops_agent_discovery_review( request: Request, current_user: dict = Depends(get_current_user), zone_service: AgentZoneService = Depends(get_agent_zone_service), ): del current_user payload = await request.json() ip_address = str(payload.get("ip_address") or "").strip() action = str(payload.get("action") or "").strip().lower() if not ip_address: raise HTTPException(status_code=400, detail="ip_address is required.") valid_actions = {"add_to_inventory", "ignore", "non_miner"} if action not in valid_actions: raise HTTPException(status_code=400, detail="Invalid discovery action.") conn = await minerops_command_db_connect() try: discovery = await conn.fetchrow(""" SELECT id, agent_id, agent_zone_id, site_name, room_name, ip_address::text AS ip_address, mac_address, model, hostname, miner_sn, discovery_status FROM minerops_agent_discoveries WHERE ip_address = $1::inet ORDER BY created_at DESC LIMIT 1 """, ip_address) if not discovery: raise HTTPException(status_code=404, detail="Discovery not found.") if action == "ignore": await conn.execute(""" UPDATE minerops_agent_discoveries SET discovery_status = 'ignored', updated_at = now() WHERE ip_address = $1::inet """, ip_address) return { "ok": True, "action": action, "ip_address": ip_address, "message": "Discovery ignored." } if action == "non_miner": await conn.execute(""" UPDATE minerops_agent_discoveries SET discovery_status = 'non_miner', updated_at = now() WHERE ip_address = $1::inet """, ip_address) return { "ok": True, "action": action, "ip_address": ip_address, "message": "Discovery marked as non-miner." } mac = discovery["mac_address"] if mac is not None: mac = str(mac).strip() or None existing = None if mac: existing = await conn.fetchrow(""" SELECT id::text AS id FROM miners WHERE upper(current_mac) = upper($1) LIMIT 1 """, mac) if existing is None: existing = await conn.fetchrow(""" SELECT id::text AS id FROM miners WHERE current_ip = $1::inet LIMIT 1 """, ip_address) if existing: association = zone_service.associate_miners_from_agent( discovery["agent_id"], [UUID(existing["id"])] ) ownership_status = association.outcomes.get( UUID(existing["id"]), association.routing_reason or "preserved" ) await conn.execute(""" UPDATE minerops_agent_discoveries SET discovery_status = 'known', matched_miner_id = $2::uuid, updated_at = now() WHERE ip_address = $1::inet """, ip_address, existing["id"]) return { "ok": True, "action": action, "ip_address": ip_address, "miner_id": existing["id"], "ownership_status": ownership_status, "message": "Discovery already matched an existing inventory miner." } room_id = None routing = zone_service.routing_context( discovery["agent_id"], discovery["agent_zone_id"] ) site_id = routing.site_id if routing.routing_reason is None else None agent_zone_id = ( routing.agent_zone_id if routing.routing_reason is None else None ) room_name = discovery["room_name"] if room_name and site_id: room = await conn.fetchrow( """ SELECT id::text AS id FROM rooms WHERE site_id = $1::uuid AND lower(name) = lower($2) ORDER BY id LIMIT 1 """, str(site_id), str(room_name), ) if room: room_id = room["id"] model = discovery["model"] or "WhatsMiner" hostname = discovery["hostname"] miner_sn = discovery["miner_sn"] new_miner = await conn.fetchrow(""" INSERT INTO miners ( site_id, agent_zone_id, room_id, current_mac, current_ip, hostname, miner_sn, model, miner_type, status, notes, first_seen_at, last_seen_at, created_at, updated_at ) VALUES ( $1::uuid, $2::uuid, $3::uuid, $4, $5::inet, $6, $7, $8, $8, 'discovered', $9, now(), now(), now(), now() ) RETURNING id::text AS id """, site_id, agent_zone_id, room_id, mac, ip_address, hostname, miner_sn, model, "Added from Agent Discovery. Review MAC, serial, location, and notes." ) await conn.execute(""" UPDATE minerops_agent_discoveries SET discovery_status = 'added', matched_miner_id = $2::uuid, updated_at = now() WHERE ip_address = $1::inet """, ip_address, new_miner["id"]) return { "ok": True, "action": action, "ip_address": ip_address, "miner_id": new_miner["id"], "message": "Discovery added to inventory." } finally: await conn.close() @app.post("/api/agent/miner-enrichment") async def minerops_agent_miner_enrichment( request: Request, authenticated_agent_id: str = Depends(require_agent_identity), zone_service: AgentZoneService = Depends(get_agent_zone_service), ): import json routing = zone_service.routing_context(authenticated_agent_id) if routing.routing_reason or routing.agent_zone_id is None: raise HTTPException( status_code=409, detail=( "Agent routing unavailable: " + (routing.routing_reason or "agent_zone_unassigned") ), ) payload = await request.json() ip_address = str(payload.get("ip_address") or "").strip() miner_id = str(payload.get("miner_id") or "").strip() def clean(value): if value is None: return None value = str(value).strip() return value or None def normalize_mac(value): value = clean(value) if not value: return None value = value.replace("-", ":").upper() return value def jsonb_value(value): if value is None: return None if isinstance(value, (dict, list)): return json.dumps(value) text_value = str(value).strip() if not text_value: return None try: return json.dumps(json.loads(text_value)) except Exception: return json.dumps({"raw": text_value}) if not ip_address and not miner_id: raise HTTPException(status_code=400, detail="ip_address or miner_id is required.") conn = await minerops_command_db_connect() try: miner = None if miner_id: miner = await conn.fetchrow(""" SELECT id::text AS id FROM miners WHERE id = $1::uuid AND agent_zone_id = $2::uuid LIMIT 1 """, miner_id, str(routing.agent_zone_id)) if miner is None and ip_address: miner = await conn.fetchrow(""" SELECT id::text AS id FROM miners WHERE current_ip = $1::inet AND agent_zone_id = $2::uuid LIMIT 1 """, ip_address, str(routing.agent_zone_id)) if miner is None: raise HTTPException(status_code=404, detail="Miner not found.") miner_id = miner["id"] mac = normalize_mac(payload.get("mac_address")) hostname = clean(payload.get("hostname")) miner_sn = clean(payload.get("miner_sn")) model = clean(payload.get("model")) miner_type = clean(payload.get("miner_type")) or model api_version = clean(payload.get("api_version")) firmware_version = clean(payload.get("firmware_version")) platform = clean(payload.get("platform")) raw_version_json = jsonb_value(payload.get("raw_version")) raw_miner_info_json = jsonb_value(payload.get("raw_miner_info")) await conn.execute(""" UPDATE miners SET current_mac = COALESCE($2, current_mac), hostname = COALESCE($3, hostname), miner_sn = COALESCE($4, miner_sn), model = COALESCE($5, model), miner_type = COALESCE($6, miner_type), api_version = COALESCE($7, api_version), firmware_version = COALESCE($8, firmware_version), platform = COALESCE($9, platform), updated_at = now() WHERE id = $1::uuid """, miner_id, mac, hostname, miner_sn, model, miner_type, api_version, firmware_version, platform ) if raw_version_json is not None or raw_miner_info_json is not None: await conn.execute(""" UPDATE miner_status_snapshots SET raw_version_json = COALESCE($2::jsonb, raw_version_json), raw_miner_info_json = COALESCE($3::jsonb, raw_miner_info_json) WHERE id = ( SELECT id FROM miner_status_snapshots WHERE miner_id = $1::uuid ORDER BY created_at DESC LIMIT 1 ) """, miner_id, raw_version_json, raw_miner_info_json) updated = await conn.fetchrow(""" SELECT id::text AS id, current_mac, host(current_ip) AS current_ip, hostname, miner_sn, model, miner_type, api_version, firmware_version, platform, status, updated_at FROM miners WHERE id = $1::uuid """, miner_id) return { "ok": True, "miner": dict(updated), "message": "Miner enrichment saved." } finally: await conn.close() @app.post("/api/agent/miner-enrichment-v2") async def minerops_agent_miner_enrichment_v2( request: Request, authenticated_agent_id: str = Depends(require_agent_identity), zone_service: AgentZoneService = Depends(get_agent_zone_service), ): import json routing = zone_service.routing_context(authenticated_agent_id) if routing.routing_reason or routing.agent_zone_id is None: raise HTTPException( status_code=409, detail=( "Agent routing unavailable: " + (routing.routing_reason or "agent_zone_unassigned") ), ) payload = await request.json() ip_address = str(payload.get("ip_address") or "").strip() miner_id = str(payload.get("miner_id") or "").strip() def clean(value): if value is None: return None value = str(value).strip() return value or None def normalize_mac(value): value = clean(value) if not value: return None return value.replace("-", ":").upper() def jsonb_text(value): if value is None: return None if isinstance(value, (dict, list)): return json.dumps(value) value = str(value).strip() if not value: return None try: return json.dumps(json.loads(value)) except Exception: return json.dumps({"raw": value}) def safe_dt(value): try: return value.isoformat() except Exception: return str(value) if value is not None else None if not ip_address and not miner_id: raise HTTPException(status_code=400, detail="ip_address or miner_id is required.") mac = normalize_mac(payload.get("mac_address")) hostname = clean(payload.get("hostname")) miner_sn = clean(payload.get("miner_sn")) model = clean(payload.get("model")) or "WhatsMiner" miner_type = clean(payload.get("miner_type")) or model api_version = clean(payload.get("api_version")) firmware_version = clean(payload.get("firmware_version")) platform = clean(payload.get("platform")) raw_version_json = jsonb_text(payload.get("raw_version")) raw_miner_info_json = jsonb_text(payload.get("raw_miner_info")) conn = await minerops_command_db_connect() try: source = None if miner_id: source = await conn.fetchrow(""" SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes FROM miners WHERE id = $1::uuid AND agent_zone_id = $2::uuid LIMIT 1 """, miner_id, str(routing.agent_zone_id)) if source is None and ip_address: source = await conn.fetchrow(""" SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes FROM miners WHERE current_ip = $1::inet AND agent_zone_id = $2::uuid LIMIT 1 """, ip_address, str(routing.agent_zone_id)) if source is None: raise HTTPException(status_code=404, detail="Source miner not found.") source_id = source["id"] target_id = source_id merged_from_miner_id = None source_removed = False if mac: existing_mac_miner = await conn.fetchrow(""" SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes FROM miners WHERE upper(current_mac) = upper($1) AND agent_zone_id = $2::uuid LIMIT 1 """, mac, str(routing.agent_zone_id)) if existing_mac_miner and existing_mac_miner["id"] != source_id: target_id = existing_mac_miner["id"] merged_from_miner_id = source_id await conn.execute(""" UPDATE miners SET current_ip = NULL, status = 'merged', notes = COALESCE(notes, '') || E'\nMerged into miner ' || $2 || ' after enrichment found matching MAC ' || $3 || '.', updated_at = now() WHERE id = $1::uuid """, source_id, target_id, mac) await conn.execute(""" UPDATE miners SET current_mac = COALESCE($2, current_mac), current_ip = COALESCE($3::inet, current_ip), hostname = COALESCE($4, hostname), miner_sn = COALESCE($5, miner_sn), model = COALESCE($6, model), miner_type = COALESCE($7, miner_type), api_version = COALESCE($8, api_version), firmware_version = COALESCE($9, firmware_version), platform = COALESCE($10, platform), status = 'online', last_seen_at = now(), updated_at = now() WHERE id = $1::uuid """, target_id, mac, ip_address or None, hostname, miner_sn, model, miner_type, api_version, firmware_version, platform ) if merged_from_miner_id: await conn.execute(""" UPDATE miner_status_snapshots SET miner_id = $2::uuid WHERE miner_id = $1::uuid """, merged_from_miner_id, target_id) try: await conn.execute(""" DELETE FROM miners WHERE id = $1::uuid """, merged_from_miner_id) source_removed = True except Exception: source_removed = False if raw_version_json is not None or raw_miner_info_json is not None: await conn.execute(""" UPDATE miner_status_snapshots SET raw_version_json = CASE WHEN $2 IS NULL THEN raw_version_json ELSE $2::jsonb END, raw_miner_info_json = CASE WHEN $3 IS NULL THEN raw_miner_info_json ELSE $3::jsonb END WHERE id = ( SELECT id FROM miner_status_snapshots WHERE miner_id = $1::uuid ORDER BY created_at DESC LIMIT 1 ) """, target_id, raw_version_json, raw_miner_info_json) if ip_address: try: await conn.execute(""" UPDATE minerops_agent_discoveries SET discovery_status = 'known', matched_miner_id = $2::uuid, updated_at = now() WHERE ip_address = $1::inet AND agent_zone_id = $3::uuid """, ip_address, target_id, str(routing.agent_zone_id)) except Exception: pass updated = await conn.fetchrow(""" SELECT id::text AS id, current_mac, host(current_ip) AS current_ip, hostname, miner_sn, model, miner_type, api_version, firmware_version, platform, status, updated_at FROM miners WHERE id = $1::uuid """, target_id) result = dict(updated) result["updated_at"] = safe_dt(result.get("updated_at")) return { "ok": True, "miner": result, "target_miner_id": target_id, "merged_from_miner_id": merged_from_miner_id, "source_removed": source_removed, "message": "Miner enrichment saved." } finally: await conn.close() @app.post("/api/agent/miner-enrichment-v3") async def minerops_agent_miner_enrichment_v3( request: Request, authenticated_agent_id: str = Depends(require_agent_identity), zone_service: AgentZoneService = Depends(get_agent_zone_service), ): import json routing = zone_service.routing_context(authenticated_agent_id) if routing.routing_reason or routing.agent_zone_id is None: raise HTTPException( status_code=409, detail=( "Agent routing unavailable: " + (routing.routing_reason or "agent_zone_unassigned") ), ) payload = await request.json() ip_address = str(payload.get("ip_address") or "").strip() miner_id = str(payload.get("miner_id") or "").strip() def clean(value): if value is None: return None value = str(value).strip() return value or None def normalize_mac(value): value = clean(value) if not value: return None return value.replace("-", ":").upper() def jsonb_text(value): if value is None: return None if isinstance(value, (dict, list)): return json.dumps(value) value = str(value).strip() if not value: return None try: return json.dumps(json.loads(value)) except Exception: return json.dumps({"raw": value}) mac = normalize_mac(payload.get("mac_address")) hostname = clean(payload.get("hostname")) miner_sn = clean(payload.get("miner_sn")) model = clean(payload.get("model")) or "WhatsMiner" miner_type = clean(payload.get("miner_type")) or model api_version = clean(payload.get("api_version")) firmware_version = clean(payload.get("firmware_version")) platform = clean(payload.get("platform")) raw_version_json = jsonb_text(payload.get("raw_version")) raw_miner_info_json = jsonb_text(payload.get("raw_miner_info")) if not ip_address and not miner_id: raise HTTPException(status_code=400, detail="ip_address or miner_id is required.") conn = await minerops_command_db_connect() try: target_id = None matched_by = None if mac: row = await conn.fetchrow(""" SELECT id::text AS id FROM miners WHERE upper(current_mac) = upper($1) AND agent_zone_id = $2::uuid LIMIT 1 """, mac, str(routing.agent_zone_id)) if row: target_id = row["id"] matched_by = "mac" if target_id is None and miner_id: row = await conn.fetchrow(""" SELECT id::text AS id FROM miners WHERE id = $1::uuid AND agent_zone_id = $2::uuid LIMIT 1 """, miner_id, str(routing.agent_zone_id)) if row: target_id = row["id"] matched_by = "miner_id" if target_id is None and ip_address: row = await conn.fetchrow(""" SELECT id::text AS id FROM miners WHERE current_ip = $1::inet AND agent_zone_id = $2::uuid LIMIT 1 """, ip_address, str(routing.agent_zone_id)) if row: target_id = row["id"] matched_by = "ip" if target_id: await conn.execute(""" UPDATE miners SET current_mac = COALESCE($2, current_mac), current_ip = COALESCE($3::inet, current_ip), hostname = COALESCE($4, hostname), miner_sn = COALESCE($5, miner_sn), model = COALESCE($6, model), miner_type = COALESCE($7, miner_type), api_version = COALESCE($8, api_version), firmware_version = COALESCE($9, firmware_version), platform = COALESCE($10, platform), status = 'online', last_seen_at = now(), updated_at = now() WHERE id = $1::uuid """, target_id, mac, ip_address or None, hostname, miner_sn, model, miner_type, api_version, firmware_version, platform ) if raw_version_json is not None or raw_miner_info_json is not None: await conn.execute(""" UPDATE miner_status_snapshots SET raw_version_json = CASE WHEN $2 IS NULL THEN raw_version_json ELSE $2::jsonb END, raw_miner_info_json = CASE WHEN $3 IS NULL THEN raw_miner_info_json ELSE $3::jsonb END WHERE id = ( SELECT id FROM miner_status_snapshots WHERE miner_id = $1::uuid ORDER BY created_at DESC LIMIT 1 ) """, target_id, raw_version_json, raw_miner_info_json) if ip_address: await conn.execute(""" UPDATE minerops_agent_discoveries SET mac_address = COALESCE($2, mac_address), model = COALESCE($3, model), hostname = COALESCE($4, hostname), miner_sn = COALESCE($5, miner_sn), discovery_status = 'matched', matched_miner_id = $6::uuid, raw_payload = COALESCE(raw_payload, '{}'::jsonb) || $7::jsonb, updated_at = now() WHERE ip_address = $1::inet AND agent_zone_id = $8::uuid """, ip_address, mac, model, hostname, miner_sn, target_id, json.dumps({"enrichment": payload, "matched_by": matched_by}), str(routing.agent_zone_id) ) updated = await conn.fetchrow(""" SELECT id::text AS id, current_mac, host(current_ip) AS current_ip, hostname, miner_sn, model, miner_type, api_version, firmware_version, platform, status, updated_at FROM miners WHERE id = $1::uuid """, target_id) return { "ok": True, "matched": True, "matched_by": matched_by, "miner": dict(updated), "message": "Discovery enriched and matched to existing inventory." } if ip_address: await conn.execute(""" UPDATE minerops_agent_discoveries SET mac_address = COALESCE($2, mac_address), model = COALESCE($3, model), hostname = COALESCE($4, hostname), miner_sn = COALESCE($5, miner_sn), discovery_status = 'needs_review', raw_payload = COALESCE(raw_payload, '{}'::jsonb) || $6::jsonb, updated_at = now() WHERE ip_address = $1::inet AND agent_zone_id = $7::uuid """, ip_address, mac, model, hostname, miner_sn, json.dumps({"enrichment": payload, "reason": "no_inventory_match"}), str(routing.agent_zone_id) ) return { "ok": True, "matched": False, "ip_address": ip_address, "mac_address": mac, "message": "Discovery enriched but did not match existing inventory. Review before adding." } finally: await conn.close() @app.post("/api/agent/discoveries/enrich-match") async def minerops_queue_discovery_enrich_match(request: Request, current_user: dict = Depends(get_current_user)): payload = await request.json() ip_address = str(payload.get("ip_address") or "").strip() if not ip_address: raise HTTPException(status_code=400, detail="ip_address is required.") conn = await minerops_command_db_connect() try: discovery = await conn.fetchrow(""" SELECT room_name FROM minerops_agent_discoveries WHERE ip_address = $1::inet ORDER BY created_at DESC LIMIT 1 """, ip_address) room_name = discovery["room_name"] if discovery else None command = await conn.fetchrow(""" INSERT INTO minerops_agent_command_queue ( id, agent_id, command_type, command_status, requested_by_username, request_payload, created_at ) VALUES ( gen_random_uuid()::text, 'plexus-windows-agent', 'enrich_match_discovery', 'pending', 'admin', jsonb_build_object( 'ip_address', $1::text, 'room_name', $2::text, 'source', 'agent-control-discovery' ), now() ) RETURNING id """, ip_address, room_name) return { "ok": True, "command_id": command["id"], "ip_address": ip_address, "message": "Enrich / Match command queued." } finally: await conn.close() @app.get("/api/agent/discoveries/summary") async def minerops_agent_discoveries_summary(request: Request, current_user: dict = Depends(get_current_user)): conn = await minerops_command_db_connect() try: rows = await conn.fetch(""" SELECT discovery_status, COUNT(*)::int AS count FROM minerops_agent_discoveries GROUP BY discovery_status ORDER BY discovery_status """) latest = await conn.fetchrow(""" SELECT MAX(COALESCE(updated_at, created_at)) AS latest_at, COUNT(*)::int AS total FROM minerops_agent_discoveries """) counts = {} for row in rows: counts[row["discovery_status"] or "unknown"] = row["count"] return { "ok": True, "total": latest["total"] if latest else 0, "counts": counts, "latest_at": latest["latest_at"].isoformat() if latest and latest["latest_at"] else None } finally: await conn.close() @app.post("/api/miners/{miner_id}/agent-control") async def minerops_queue_agent_miner_control(miner_id: str, request: Request, current_user: dict = Depends(get_current_user)): payload = await request.json() action = str(payload.get("action") or "").strip().lower() dry_run = bool(payload.get("dry_run", True)) performance_mode = str(payload.get("performance_mode") or payload.get("mode") or "").strip().lower() allowed_actions = {"reboot", "suspend", "resume", "performance_mode"} if action == "performance_mode" and performance_mode not in {"low", "normal", "high"}: raise HTTPException(status_code=400, detail="performance_mode must be one of: low, normal, high.") if action not in allowed_actions: raise HTTPException(status_code=400, detail="Invalid action. Allowed: reboot, suspend, resume, performance_mode.") conn = await minerops_command_db_connect() try: miner = await conn.fetchrow(""" SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, model, miner_type, status FROM miners WHERE id = $1::uuid LIMIT 1 """, miner_id) if not miner: raise HTTPException(status_code=404, detail="Miner not found.") if not miner["current_ip"]: raise HTTPException(status_code=400, detail="Miner has no current IP.") command = await conn.fetchrow(""" INSERT INTO minerops_agent_command_queue ( id, agent_id, command_type, command_status, requested_by_username, request_payload, created_at ) VALUES ( gen_random_uuid()::text, 'plexus-windows-agent', 'miner_control', 'pending', 'admin', jsonb_build_object( 'miner_id', $1::text, 'ip_address', $2::text, 'mac_address', $3::text, 'model', $4::text, 'miner_type', $5::text, 'action', $6::text, 'dry_run', $7::boolean, 'performance_mode', $8::text, 'source', 'miner-detail-agent-control' ), now() ) RETURNING id """, miner["id"], miner["current_ip"], miner["current_mac"], miner["model"], miner["miner_type"], action, dry_run, performance_mode ) return { "ok": True, "command_id": command["id"], "miner_id": miner["id"], "ip_address": miner["current_ip"], "action": action, "dry_run": dry_run, "message": "Miner control command queued for Windows Agent." } finally: await conn.close() def minerops_ensure_manual_detail_columns(): conn = None try: conn = get_db_connection() with conn.cursor() as cur: cur.execute( """ ALTER TABLE miners ADD COLUMN IF NOT EXISTS power_sn TEXT, ADD COLUMN IF NOT EXISTS manual_details JSONB NOT NULL DEFAULT '{}'::jsonb, ADD COLUMN IF NOT EXISTS manual_details_updated_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS manual_details_updated_by TEXT; """ ) conn.commit() finally: if conn is not None: conn.close() @app.get("/api/miners/{miner_id}/detail-extra") def miner_detail_extra(miner_id: str, current_user: dict = Depends(get_current_user)): import json as _json import datetime as _datetime import decimal as _decimal import uuid as _uuid def clean(value): if value is None: return None if isinstance(value, (str, int, float, bool)): return value if isinstance(value, (_datetime.datetime, _datetime.date)): return value.isoformat() if isinstance(value, _decimal.Decimal): return float(value) if isinstance(value, _uuid.UUID): return str(value) if isinstance(value, dict): return {str(k): clean(v) for k, v in value.items()} if isinstance(value, (list, tuple)): return [clean(v) for v in value] return str(value) def sort_hashboard_key(row): value = row.get("slot_number", row.get("slot", row.get("Slot", ""))) try: return int(value) except Exception: return 9999 minerops_ensure_manual_detail_columns() try: minerops_ensure_pool_info_table() except Exception: pass conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT m.*, rooms.name AS room_name FROM miners m LEFT JOIN rooms ON rooms.id = m.room_id WHERE m.id = %s::uuid LIMIT 1; """, (miner_id,), ) miner_row = cur.fetchone() if not miner_row: raise HTTPException(status_code=404, detail="Miner not found.") miner = dict(miner_row) cur.execute( """ SELECT * FROM miner_status_snapshots WHERE miner_id = %s::uuid ORDER BY created_at DESC LIMIT 1; """, (miner_id,), ) snapshot_row = cur.fetchone() latest_snapshot = dict(snapshot_row) if snapshot_row else {} pool_info = [] try: cur.execute( """ WITH latest_pool AS ( SELECT DISTINCT ON (pool_index) * FROM miner_pool_snapshots WHERE miner_id = %s::uuid ORDER BY pool_index, created_at DESC ) SELECT * FROM latest_pool ORDER BY pool_index; """, (miner_id,), ) pool_info = [dict(row) for row in cur.fetchall()] except Exception: pool_info = [] hashboards = [] try: cur.execute( """ SELECT * FROM miner_hashboards WHERE miner_id = %s::uuid; """, (miner_id,), ) hashboards = [dict(row) for row in cur.fetchall()] hashboards = sorted(hashboards, key=sort_hashboard_key) except Exception as exc: hashboards = [ { "error": "Could not load hashboards", "detail": str(exc), } ] raw_summary = latest_snapshot.get("raw_summary_json") or {} summary_obj = {} try: if isinstance(raw_summary, str): raw_summary = _json.loads(raw_summary) if isinstance(raw_summary, dict): if isinstance(raw_summary.get("SUMMARY"), list) and raw_summary.get("SUMMARY"): summary_obj = raw_summary["SUMMARY"][0] or {} elif isinstance(raw_summary.get("Msg"), dict): summary_obj = raw_summary["Msg"] or {} except Exception: summary_obj = {} summary_keys = [] if isinstance(summary_obj, dict): summary_keys = sorted([str(k) for k in summary_obj.keys()]) data = { "miner": miner, "latest_snapshot": latest_snapshot, "summary_fields": summary_obj, "summary_keys": summary_keys, "pool_info": pool_info, "hashboards": hashboards, } return api_response(data=clean(data)) finally: if conn is not None: conn.close() @app.patch("/api/miners/{miner_id}/manual-details") async def miner_manual_details_update(miner_id: str, request: Request, current_user: dict = Depends(get_current_user)): import json as _json minerops_ensure_manual_detail_columns() body = await request.json() editable_columns = [ "miner_sn", "power_sn", "hostname", "model", "miner_type", "firmware_version", "api_version", "platform", "notes", ] updates = {} for column in editable_columns: if column in body: value = body.get(column) if value is None: updates[column] = None else: updates[column] = str(value).strip() manual_details = body.get("manual_details", None) conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( "SELECT id FROM miners WHERE id = %s::uuid LIMIT 1;", (miner_id,), ) if not cur.fetchone(): raise HTTPException(status_code=404, detail="Miner not found.") set_parts = [] params = [] for column, value in updates.items(): set_parts.append(f"{column} = %s") params.append(value) if manual_details is not None: if isinstance(manual_details, str): manual_details = manual_details.strip() if manual_details: try: manual_details = _json.loads(manual_details) except Exception: raise HTTPException(status_code=400, detail="Manual details must be valid JSON.") else: manual_details = {} if not isinstance(manual_details, dict): raise HTTPException(status_code=400, detail="Manual details must be a JSON object.") set_parts.append("manual_details = %s::jsonb") params.append(_json.dumps(manual_details)) username = ( current_user.get("username") or current_user.get("email") or current_user.get("id") or "admin" ) set_parts.append("manual_details_updated_at = now()") set_parts.append("manual_details_updated_by = %s") params.append(str(username)) set_parts.append("updated_at = now()") params.append(miner_id) sql = "UPDATE miners SET " + ", ".join(set_parts) + " WHERE id = %s::uuid RETURNING *;" cur.execute(sql, params) updated = dict(cur.fetchone()) conn.commit() return api_response( data={"miner": updated}, meta={"message": "Manual miner details saved."}, ) finally: if conn is not None: conn.close() @app.get("/assets/minerops-miner-detail-extra.js") def minerops_miner_detail_extra_js(): from fastapi.responses import Response as _Response js = r""" (function () { function getToken() { const keys = [ "minerops_token", "mineropsToken", "access_token", "authToken", "token", "jwt" ]; for (const key of keys) { const value = localStorage.getItem(key) || sessionStorage.getItem(key); if (value) { return value; } } return ""; } function authHeaders(extra) { const token = getToken(); const headers = Object.assign({}, extra || {}); if (token) { headers["Authorization"] = "Bearer " + token; } return headers; } function escapeHtml(value) { if (value === null || value === undefined) { return ""; } return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function fmtNumber(value, decimals) { const n = Number(value); if (!Number.isFinite(n)) { return ""; } return n.toFixed(decimals || 0); } function fmtSeconds(value) { const seconds = Number(value || 0); if (!seconds || seconds <= 0) { return ""; } const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); if (days > 0) { return days + "d " + hours + "h"; } if (hours > 0) { return hours + "h " + minutes + "m"; } return minutes + "m"; } function findMinerId() { const match = window.location.pathname.match(/\/miners\/([^\/]+)\/detail/); if (!match) { return ""; } return match[1]; } async function apiGet(url) { const response = await fetch(url, { headers: authHeaders(), credentials: "same-origin" }); if (!response.ok) { throw new Error(await response.text()); } const payload = await response.json(); return payload.data || payload; } async function apiPatch(url, body) { const response = await fetch(url, { method: "PATCH", headers: authHeaders({ "Content-Type": "application/json" }), credentials: "same-origin", body: JSON.stringify(body) }); if (!response.ok) { throw new Error(await response.text()); } const payload = await response.json(); return payload.data || payload; } function row(label, value) { return ` ${escapeHtml(label)} ${escapeHtml(value || "")} `; } function inputRow(label, name, value) { return ` `; } function textareaRow(label, name, value) { return ` `; } function ensureCard() { let card = document.getElementById("minerExtraDetailsCard"); if (card) { return card; } card = document.createElement("div"); card.id = "minerExtraDetailsCard"; card.className = "card"; card.style.marginTop = "16px"; const main = document.querySelector("main") || document.querySelector(".container") || document.body; main.appendChild(card); return card; } function render(data) { const card = ensureCard(); const miner = data.miner || {}; const snapshot = data.latest_snapshot || {}; const summary = data.summary_fields || {}; const pools = data.pool_info || []; const hashboards = data.hashboards || []; const manualDetails = miner.manual_details || {}; const manualDetailsText = JSON.stringify(manualDetails, null, 2); const ths = snapshot.mhs_av ? Number(snapshot.mhs_av) / 1000000.0 : null; card.innerHTML = `

Extra Miner Details / Manual Edit

This card shows the latest stored miner details, runtime readings, pool info, and editable manual fields.

Inventory Details

${row("Room", miner.room_name)} ${row("MAC", miner.current_mac)} ${row("IP", miner.current_ip)} ${row("Model", miner.model)} ${row("Miner Type", miner.miner_type)} ${row("Miner SN", miner.miner_sn)} ${row("Power SN", miner.power_sn)} ${row("Hostname", miner.hostname)} ${row("Firmware", miner.firmware_version)} ${row("API Version", miner.api_version)} ${row("Platform", miner.platform)} ${row("Status", miner.status)} ${row("Manual Updated By", miner.manual_details_updated_by)} ${row("Manual Updated At", miner.manual_details_updated_at)}

Latest Runtime

${row("TH/s", ths === null ? "" : fmtNumber(ths, 2))} ${row("Power W", fmtNumber(snapshot.power_w, 0))} ${row("Power Rate", fmtNumber(snapshot.power_rate_w_per_th, 2))} ${row("Fan In", fmtNumber(snapshot.fan_speed_in, 0))} ${row("Fan Out", fmtNumber(snapshot.fan_speed_out, 0))} ${row("Env C", fmtNumber(snapshot.env_temp_c, 1))} ${row("Chip Avg C", fmtNumber(snapshot.chip_temp_avg_c, 1))} ${row("Chip Max C", fmtNumber(snapshot.chip_temp_max_c, 1))} ${row("Uptime", fmtSeconds(snapshot.uptime_seconds))} ${row("Elapsed", fmtSeconds(snapshot.elapsed_seconds))} ${row("Pool Rejected %", fmtNumber(snapshot.pool_rejected_percent, 4))}

Pool Info

${pools.map(pool => ` `).join("")}
Pool Status Active Worker/User URL Accepted Rejected Rejected % Last Share
${escapeHtml(pool.pool_index || "")} ${escapeHtml(pool.pool_status || "")} ${escapeHtml(pool.stratum_active === true ? "Yes" : pool.stratum_active === false ? "No" : "")} ${escapeHtml(pool.worker_user || "")} ${escapeHtml(pool.url || "")} ${escapeHtml(pool.accepted || 0)} ${escapeHtml(pool.rejected || 0)} ${escapeHtml(fmtNumber(pool.pool_rejected_percent, 4))} ${escapeHtml(pool.last_share_time_epoch || "")}

Hashboards

${hashboards.map(board => ` `).join("")}
Slot PCB SN Status Temp Chip Avg Chip Max Hashrate
${escapeHtml(board.slot_number || board.slot || "")} ${escapeHtml(board.pcb_sn || board.hashboard_sn || "")} ${escapeHtml(board.status || "")} ${escapeHtml(fmtNumber(board.temperature_c || board.temperature, 1))} ${escapeHtml(fmtNumber(board.chip_temp_avg_c, 1))} ${escapeHtml(fmtNumber(board.chip_temp_max_c, 1))} ${escapeHtml(fmtNumber(board.mhs_av, 2))}
Available raw summary fields
${escapeHtml((data.summary_keys || []).join("\\n"))}

Manual Edit Missing Details

${inputRow("Miner SN", "miner_sn", miner.miner_sn)} ${inputRow("Power SN", "power_sn", miner.power_sn)} ${inputRow("Hostname", "hostname", miner.hostname)} ${inputRow("Model", "model", miner.model)} ${inputRow("Miner Type", "miner_type", miner.miner_type)}
${inputRow("Firmware Version", "firmware_version", miner.firmware_version)} ${inputRow("API Version", "api_version", miner.api_version)} ${inputRow("Platform", "platform", miner.platform)} ${textareaRow("Notes", "notes", miner.notes)}
${textareaRow("Manual Details JSON", "manual_details", manualDetailsText)}
`; const form = document.getElementById("minerManualDetailsForm"); form.addEventListener("submit", async function (event) { event.preventDefault(); const status = document.getElementById("minerManualDetailsStatus"); status.textContent = "Saving..."; const formData = new FormData(form); const body = {}; for (const [key, value] of formData.entries()) { body[key] = value; } try { await apiPatch("/api/miners/" + encodeURIComponent(findMinerId()) + "/manual-details", body); status.textContent = "Saved."; await load(); } catch (error) { status.textContent = "Save failed: " + error.message; } }); } async function load() { const minerId = findMinerId(); if (!minerId) { return; } const data = await apiGet("/api/miners/" + encodeURIComponent(minerId) + "/detail-extra"); render(data); } document.addEventListener("DOMContentLoaded", function () { if (!findMinerId()) { return; } load().catch(function (error) { console.error("Miner extra details failed:", error); }); }); })(); """ return _Response(content=js, media_type="application/javascript") @app.get("/assets/minerops-miner-detail-extra-v2.js") def minerops_miner_detail_extra_v2_js(): from fastapi.responses import Response as _Response js = r""" (function () { if (window.__mineropsExtraDetailsV2Loaded) { return; } window.__mineropsExtraDetailsV2Loaded = true; function getToken() { const keys = [ "minerops_token", "mineropsToken", "access_token", "authToken", "token", "jwt" ]; for (const key of keys) { const value = localStorage.getItem(key) || sessionStorage.getItem(key); if (value) { return value; } } return ""; } function authHeaders(extra) { const token = getToken(); const headers = Object.assign({}, extra || {}); if (token) { headers["Authorization"] = "Bearer " + token; } return headers; } function escapeHtml(value) { if (value === null || value === undefined) { return ""; } return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function firstValue() { for (const value of arguments) { if (value !== null && value !== undefined && value !== "") { return value; } } return ""; } function numberValue() { const value = firstValue.apply(null, arguments); const number = Number(value); if (!Number.isFinite(number)) { return ""; } return number; } function fmtNumber(value, decimals) { const number = Number(value); if (!Number.isFinite(number)) { return ""; } return number.toFixed(decimals || 0); } function fmtSeconds(value) { const seconds = Number(value || 0); if (!seconds || seconds <= 0) { return ""; } const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); if (days > 0) { return days + "d " + hours + "h"; } if (hours > 0) { return hours + "h " + minutes + "m"; } return minutes + "m"; } function fmtEpoch(value) { const number = Number(value); if (!Number.isFinite(number) || number <= 0) { return ""; } try { return new Date(number * 1000).toLocaleString(); } catch (error) { return String(value); } } function findMinerId() { const match = window.location.pathname.match(/\/miners\/([^\/]+)\/detail/); if (!match) { return ""; } return decodeURIComponent(match[1]); } async function apiGet(url) { const response = await fetch(url, { headers: authHeaders(), credentials: "same-origin" }); if (!response.ok) { throw new Error(await response.text()); } const payload = await response.json(); return payload.data || payload; } async function apiPatch(url, body) { const response = await fetch(url, { method: "PATCH", headers: authHeaders({ "Content-Type": "application/json" }), credentials: "same-origin", body: JSON.stringify(body) }); if (!response.ok) { throw new Error(await response.text()); } const payload = await response.json(); return payload.data || payload; } function tableRow(label, value) { return ` ${escapeHtml(label)} ${escapeHtml(value)} `; } function inputRow(label, name, value) { return ` `; } function textareaRow(label, name, value) { return ` `; } function ensureCard() { let card = document.getElementById("minerExtraDetailsCardV2"); if (card) { return card; } card = document.createElement("div"); card.id = "minerExtraDetailsCardV2"; card.className = "card"; card.style.marginTop = "16px"; card.innerHTML = "

Loading Extra Miner Details...

"; const main = document.querySelector("main") || document.querySelector(".container") || document.querySelector(".content") || document.body; main.appendChild(card); return card; } function render(data) { const card = ensureCard(); const miner = data.miner || {}; const snapshot = data.latest_snapshot || {}; const summary = data.summary_fields || {}; const pools = data.pool_info || []; const hashboards = data.hashboards || []; const mhsAv = numberValue(snapshot.mhs_av, summary["MHS av"]); const ths = mhsAv ? (mhsAv / 1000000.0) : ""; const powerW = numberValue(snapshot.power_w, summary["Power"]); const powerRate = numberValue(snapshot.power_rate_w_per_th, summary["Power Rate"]); const fanIn = numberValue(snapshot.fan_speed_in, summary["Fan Speed In"], summary["Fan In"], summary["Fan1"]); const fanOut = numberValue(snapshot.fan_speed_out, summary["Fan Speed Out"], summary["Fan Out"], summary["Fan2"]); const envTemp = numberValue(snapshot.env_temp_c, summary["Env Temp"]); const chipAvg = numberValue(snapshot.chip_temp_avg_c, summary["Chip Temp Avg"], summary["Temperature"]); const chipMax = numberValue(snapshot.chip_temp_max_c, summary["Chip Temp Max"], summary["Temperature"]); const uptime = numberValue(snapshot.uptime_seconds, summary["Uptime"]); const elapsed = numberValue(snapshot.elapsed_seconds, summary["Elapsed"]); const poolRejected = numberValue(snapshot.pool_rejected_percent, summary["Pool Rejected%"]); const manualDetails = miner.manual_details || {}; const manualDetailsText = JSON.stringify(manualDetails, null, 2); card.innerHTML = `

Extra Miner Details / Manual Edit

Shows inventory, latest runtime telemetry, pool details, hashboard info, and manual editable fields.

Inventory Details

${tableRow("Room", miner.room_name)} ${tableRow("MAC", miner.current_mac)} ${tableRow("IP", miner.current_ip)} ${tableRow("Model", miner.model)} ${tableRow("Miner Type", miner.miner_type)} ${tableRow("Miner SN", miner.miner_sn)} ${tableRow("Power SN", miner.power_sn)} ${tableRow("Hostname", miner.hostname)} ${tableRow("Firmware", miner.firmware_version)} ${tableRow("API Version", miner.api_version)} ${tableRow("Platform", miner.platform)} ${tableRow("Status", miner.status)} ${tableRow("Manual Updated By", miner.manual_details_updated_by)} ${tableRow("Manual Updated At", miner.manual_details_updated_at)}

Latest Runtime Telemetry

${tableRow("TH/s", fmtNumber(ths, 2))} ${tableRow("Power W", fmtNumber(powerW, 0))} ${tableRow("Power Rate", fmtNumber(powerRate, 2))} ${tableRow("Fan In", fmtNumber(fanIn, 0))} ${tableRow("Fan Out", fmtNumber(fanOut, 0))} ${tableRow("Env C", fmtNumber(envTemp, 1))} ${tableRow("Chip Avg C", fmtNumber(chipAvg, 1))} ${tableRow("Chip Max C", fmtNumber(chipMax, 1))} ${tableRow("Uptime", fmtSeconds(uptime))} ${tableRow("Elapsed", fmtSeconds(elapsed))} ${tableRow("Pool Rejected %", fmtNumber(poolRejected, 4))}

Pool Info

${pools.map(pool => ` `).join("") || ``}
Pool Status Active Worker/User URL Accepted Rejected Rejected % Last Share
${escapeHtml(pool.pool_index || "")} ${escapeHtml(pool.pool_status || "")} ${escapeHtml(pool.stratum_active === true ? "Yes" : pool.stratum_active === false ? "No" : "")} ${escapeHtml(pool.worker_user || "")} ${escapeHtml(pool.url || "")} ${escapeHtml(pool.accepted || 0)} ${escapeHtml(pool.rejected || 0)} ${escapeHtml(fmtNumber(pool.pool_rejected_percent, 4))} ${escapeHtml(fmtEpoch(pool.last_share_time_epoch))}
No pool info has been collected for this miner yet.

Hashboards

${hashboards.map(board => ` `).join("") || ``}
Slot PCB SN Status Temp Chip Avg Chip Max Hashrate
${escapeHtml(firstValue(board.slot_number, board.slot))} ${escapeHtml(firstValue(board.pcb_sn, board.hashboard_sn))} ${escapeHtml(board.status || "")} ${escapeHtml(fmtNumber(firstValue(board.temperature_c, board.temperature), 1))} ${escapeHtml(fmtNumber(board.chip_temp_avg_c, 1))} ${escapeHtml(fmtNumber(board.chip_temp_max_c, 1))} ${escapeHtml(fmtNumber(board.mhs_av, 2))}
No hashboard details found yet.
Available raw summary fields
${escapeHtml((data.summary_keys || []).join("\\n"))}

Manual Edit Missing Details

${inputRow("Miner SN", "miner_sn", miner.miner_sn)} ${inputRow("Power SN", "power_sn", miner.power_sn)} ${inputRow("Hostname", "hostname", miner.hostname)} ${inputRow("Model", "model", miner.model)} ${inputRow("Miner Type", "miner_type", miner.miner_type)}
${inputRow("Firmware Version", "firmware_version", miner.firmware_version)} ${inputRow("API Version", "api_version", miner.api_version)} ${inputRow("Platform", "platform", miner.platform)} ${textareaRow("Notes", "notes", miner.notes)}
${textareaRow("Manual Details JSON", "manual_details", manualDetailsText)}
`; const form = document.getElementById("minerManualDetailsFormV2"); form.addEventListener("submit", async function (event) { event.preventDefault(); const status = document.getElementById("minerManualDetailsStatusV2"); status.textContent = "Saving..."; const formData = new FormData(form); const body = {}; for (const pair of formData.entries()) { body[pair[0]] = pair[1]; } try { await apiPatch("/api/miners/" + encodeURIComponent(findMinerId()) + "/manual-details", body); status.textContent = "Saved."; await load(); } catch (error) { status.textContent = "Save failed: " + error.message; } }); } async function load() { const minerId = findMinerId(); if (!minerId) { return; } const card = ensureCard(); try { const data = await apiGet("/api/miners/" + encodeURIComponent(minerId) + "/detail-extra"); render(data); } catch (error) { card.innerHTML = `

Extra Miner Details / Manual Edit

Could not load extra miner details.
${escapeHtml(error.message)}
`; } } function start() { if (!findMinerId()) { return; } load(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", start); } else { start(); } })(); """ return _Response(content=js, media_type="application/javascript") @app.middleware("http") async def minerops_inject_detail_extra_v2(request, call_next): from fastapi.responses import HTMLResponse as _HTMLResponse response = await call_next(request) try: if request.scope.get("endpoint") is internal_alpha_miner_detail_page: return response path_value = request.url.path or "" if not (path_value.startswith("/miners/") and path_value.endswith("/detail")): return response content_type = response.headers.get("content-type", "") if "text/html" not in content_type.lower(): return response body = b"" async for chunk in response.body_iterator: body += chunk html = body.decode("utf-8", errors="replace") script_tag = '' if script_tag not in html: lower_html = html.lower() close_index = lower_html.rfind("") if close_index >= 0: html = html[:close_index] + script_tag + "\n" + html[close_index:] else: html = html + "\n" + script_tag headers = dict(response.headers) headers.pop("content-length", None) headers.pop("content-encoding", None) return _HTMLResponse( content=html, status_code=response.status_code, headers=headers, ) except Exception: return response @app.get("/api/miners/{miner_id}/detail-clean-v3") def miner_detail_clean_v3(miner_id: str, current_user: dict = Depends(get_current_user)): import json as _json import datetime as _datetime import decimal as _decimal import uuid as _uuid def clean(value): if value is None: return None if isinstance(value, (str, int, float, bool)): return value if isinstance(value, (_datetime.datetime, _datetime.date)): return value.isoformat() if isinstance(value, _decimal.Decimal): return float(value) if isinstance(value, _uuid.UUID): return str(value) if isinstance(value, dict): return {str(k): clean(v) for k, v in value.items()} if isinstance(value, (list, tuple)): return [clean(v) for v in value] return str(value) def fnum(*values): for value in values: try: if value is None or value == "": continue return float(value) except Exception: continue return None def bval(*values): for value in values: if isinstance(value, bool): return value if isinstance(value, str): if value.lower() in ("true", "yes", "1", "y"): return True if value.lower() in ("false", "no", "0", "n"): return False return None try: minerops_ensure_manual_detail_columns() except Exception: pass try: minerops_ensure_pool_info_table() except Exception: pass conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT m.*, rooms.name AS room_name, host(m.current_ip) AS current_ip_text FROM miners m LEFT JOIN rooms ON rooms.id = m.room_id WHERE m.id = %s::uuid LIMIT 1; """, (miner_id,), ) miner_row = cur.fetchone() if not miner_row: raise HTTPException(status_code=404, detail="Miner not found.") miner = dict(miner_row) miner_ip = miner.get("current_ip_text") or "" miner_mac = miner.get("current_mac") or "" cur.execute( """ SELECT * FROM miner_status_snapshots WHERE miner_id = %s::uuid ORDER BY created_at DESC LIMIT 1; """, (miner_id,), ) snapshot_row = cur.fetchone() snapshot = dict(snapshot_row) if snapshot_row else {} raw_summary = snapshot.get("raw_summary_json") or {} summary = {} try: if isinstance(raw_summary, str): raw_summary = _json.loads(raw_summary) if isinstance(raw_summary, dict): if isinstance(raw_summary.get("SUMMARY"), list) and raw_summary.get("SUMMARY"): summary = raw_summary["SUMMARY"][0] or {} elif isinstance(raw_summary.get("Msg"), dict): summary = raw_summary["Msg"] or {} except Exception: summary = {} telemetry = { "ths": None, "power_w": fnum(snapshot.get("power_w"), summary.get("Power")), "w_th": fnum(snapshot.get("power_rate_w_per_th"), summary.get("Power Rate")), "fan_in": fnum(snapshot.get("fan_speed_in"), summary.get("Fan Speed In"), summary.get("Fan In"), summary.get("Fan1")), "fan_out": fnum(snapshot.get("fan_speed_out"), summary.get("Fan Speed Out"), summary.get("Fan Out"), summary.get("Fan2")), "env_c": fnum(snapshot.get("env_temp_c"), summary.get("Env Temp")), "chip_avg_c": fnum(snapshot.get("chip_temp_avg_c"), summary.get("Chip Temp Avg"), summary.get("Temperature")), "chip_max_c": fnum(snapshot.get("chip_temp_max_c"), summary.get("Chip Temp Max"), summary.get("Temperature")), "uptime_seconds": fnum(snapshot.get("uptime_seconds"), summary.get("Uptime")), "elapsed_seconds": fnum(snapshot.get("elapsed_seconds"), summary.get("Elapsed")), "pool_rejected_percent": fnum(snapshot.get("pool_rejected_percent"), summary.get("Pool Rejected%")), "hash_stable": snapshot.get("hash_stable"), "observed_at": snapshot.get("created_at"), } mhs_av = fnum(snapshot.get("mhs_av"), summary.get("MHS av")) if mhs_av is not None: telemetry["ths"] = mhs_av / 1000000.0 if telemetry["hash_stable"] is None: telemetry["hash_stable"] = bval(summary.get("Hash Stable")) pool_info = [] try: cur.execute( """ WITH matched_pool AS ( SELECT * FROM miner_pool_snapshots WHERE miner_id = %s::uuid OR host(ip_address) = %s OR upper(mac_address) = upper(%s) ), latest_pool AS ( SELECT DISTINCT ON (pool_index) * FROM matched_pool ORDER BY pool_index, created_at DESC ) SELECT * FROM latest_pool ORDER BY pool_index NULLS LAST; """, (miner_id, miner_ip, miner_mac), ) pool_info = [dict(row) for row in cur.fetchall()] except Exception: pool_info = [] return api_response( data=clean( { "miner": miner, "telemetry": telemetry, "pool_info": pool_info, "summary_keys": sorted([str(k) for k in summary.keys()]) if isinstance(summary, dict) else [], } ) ) finally: if conn is not None: conn.close() @app.get("/assets/minerops-miner-detail-clean-v3.js") def minerops_miner_detail_clean_v3_js(): from fastapi.responses import Response as _Response js = r""" (function () { if (window.__mineropsCleanDetailV3Loaded) { return; } window.__mineropsCleanDetailV3Loaded = true; function getToken() { const keys = ["minerops_token", "mineropsToken", "access_token", "authToken", "token", "jwt"]; for (const key of keys) { const value = localStorage.getItem(key) || sessionStorage.getItem(key); if (value) { return value; } } return ""; } function authHeaders(extra) { const token = getToken(); const headers = Object.assign({}, extra || {}); if (token) { headers["Authorization"] = "Bearer " + token; } return headers; } function escapeHtml(value) { if (value === null || value === undefined) { return ""; } return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function findMinerId() { const match = window.location.pathname.match(/\/miners\/([^\/]+)\/detail/); return match ? decodeURIComponent(match[1]) : ""; } function fmtNumber(value, decimals) { const number = Number(value); if (!Number.isFinite(number)) { return ""; } return number.toFixed(decimals || 0); } function fmtSeconds(value) { const seconds = Number(value || 0); if (!seconds || seconds <= 0) { return ""; } const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); if (days > 0) { return days + "d " + hours + "h"; } if (hours > 0) { return hours + "h " + minutes + "m"; } return minutes + "m"; } function fmtEpoch(value) { const number = Number(value); if (!Number.isFinite(number) || number <= 0) { return ""; } return new Date(number * 1000).toLocaleString(); } async function apiGet(url) { const response = await fetch(url, { headers: authHeaders(), credentials: "same-origin" }); if (!response.ok) { throw new Error(await response.text()); } const payload = await response.json(); return payload.data || payload; } async function apiPatch(url, body) { const response = await fetch(url, { method: "PATCH", headers: authHeaders({"Content-Type": "application/json"}), credentials: "same-origin", body: JSON.stringify(body) }); if (!response.ok) { throw new Error(await response.text()); } const payload = await response.json(); return payload.data || payload; } function findCardByHeading(text) { const headings = Array.from(document.querySelectorAll("h1,h2,h3")); for (const heading of headings) { if ((heading.textContent || "").trim().toLowerCase() === text.toLowerCase()) { return heading.closest(".card") || heading.parentElement; } } return null; } function removeMessyExtraCards() { const ids = [ "minerExtraDetailsCard", "minerExtraDetailsCardV2", "minerOpsCleanDetailV3Card" ]; for (const id of ids) { const element = document.getElementById(id); if (element) { element.remove(); } } } function metricCard(label, value) { return `
${escapeHtml(label)}
${escapeHtml(value || "-")}
`; } function replaceTopTelemetry(data) { const telemetry = data.telemetry || {}; const oldCard = findCardByHeading("Latest Telemetry"); if (!oldCard) { return; } oldCard.innerHTML = `

Latest Telemetry

${metricCard("TH/s", fmtNumber(telemetry.ths, 2))} ${metricCard("Power W", fmtNumber(telemetry.power_w, 0))} ${metricCard("W/TH", fmtNumber(telemetry.w_th, 2))} ${metricCard("Max Chip C", fmtNumber(telemetry.chip_max_c, 1))} ${metricCard("Avg Chip C", fmtNumber(telemetry.chip_avg_c, 1))} ${metricCard("Env C", fmtNumber(telemetry.env_c, 1))} ${metricCard("Fan In", fmtNumber(telemetry.fan_in, 0))} ${metricCard("Fan Out", fmtNumber(telemetry.fan_out, 0))} ${metricCard("Uptime", fmtSeconds(telemetry.uptime_seconds))} ${metricCard("Elapsed", fmtSeconds(telemetry.elapsed_seconds))} ${metricCard("Pool Rejected %", fmtNumber(telemetry.pool_rejected_percent, 4))} ${metricCard("Hash Stable", telemetry.hash_stable === true ? "Yes" : telemetry.hash_stable === false ? "No" : "")}
`; } function inputRow(label, name, value) { return ` `; } function textareaRow(label, name, value) { return ` `; } function renderPoolAndEdit(data) { removeMessyExtraCards(); const miner = data.miner || {}; const pools = data.pool_info || []; const manualDetails = miner.manual_details || {}; const manualDetailsText = JSON.stringify(manualDetails, null, 2); const card = document.createElement("div"); card.id = "minerOpsCleanDetailV3Card"; card.className = "card"; card.style.marginTop = "16px"; card.innerHTML = `

Pool Info / Manual Edit

Pool Info

${ pools.length ? pools.map(pool => ` `).join("") : `` }
Pool Status Active Worker/User URL Accepted Rejected Rejected % Last Share
${escapeHtml(pool.pool_index || "")} ${escapeHtml(pool.pool_status || "")} ${escapeHtml(pool.stratum_active === true ? "Yes" : pool.stratum_active === false ? "No" : "")} ${escapeHtml(pool.worker_user || "")} ${escapeHtml(pool.url || "")} ${escapeHtml(pool.accepted || 0)} ${escapeHtml(pool.rejected || 0)} ${escapeHtml(fmtNumber(pool.pool_rejected_percent, 4))} ${escapeHtml(fmtEpoch(pool.last_share_time_epoch))}
No pool info found for this miner yet. Run the Windows pool poller again if needed.

Manual Edit Missing Details

${inputRow("Miner SN", "miner_sn", miner.miner_sn)} ${inputRow("Power SN", "power_sn", miner.power_sn)} ${inputRow("Hostname", "hostname", miner.hostname)} ${inputRow("Model", "model", miner.model)} ${inputRow("Miner Type", "miner_type", miner.miner_type)}
${inputRow("Firmware Version", "firmware_version", miner.firmware_version)} ${inputRow("API Version", "api_version", miner.api_version)} ${inputRow("Platform", "platform", miner.platform)} ${textareaRow("Notes", "notes", miner.notes)}
${textareaRow("Manual Details JSON", "manual_details", manualDetailsText)}
`; const statusCard = findCardByHeading("Status"); const main = document.querySelector("main") || document.querySelector(".container") || document.body; if (statusCard && statusCard.parentNode) { statusCard.parentNode.insertBefore(card, statusCard.nextSibling); } else { main.appendChild(card); } const form = document.getElementById("minerOpsManualEditV3"); form.addEventListener("submit", async function (event) { event.preventDefault(); const status = document.getElementById("minerOpsManualEditV3Status"); status.textContent = "Saving..."; const formData = new FormData(form); const body = {}; for (const pair of formData.entries()) { body[pair[0]] = pair[1]; } try { await apiPatch("/api/miners/" + encodeURIComponent(findMinerId()) + "/manual-details", body); status.textContent = "Saved."; await load(); } catch (error) { status.textContent = "Save failed: " + error.message; } }); } async function load() { const minerId = findMinerId(); if (!minerId) { return; } const data = await apiGet("/api/miners/" + encodeURIComponent(minerId) + "/detail-clean-v3"); replaceTopTelemetry(data); renderPoolAndEdit(data); setTimeout(removeMessyExtraCards, 500); setTimeout(function () { replaceTopTelemetry(data); renderPoolAndEdit(data); }, 1200); } function start() { if (!findMinerId()) { return; } load().catch(function (error) { console.error("Clean miner detail V3 failed:", error); }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", start); } else { start(); } })(); """ return _Response(content=js, media_type="application/javascript") @app.middleware("http") async def minerops_inject_clean_detail_v3(request, call_next): from fastapi.responses import HTMLResponse as _HTMLResponse response = await call_next(request) try: if request.scope.get("endpoint") is internal_alpha_miner_detail_page: return response path_value = request.url.path or "" if not (path_value.startswith("/miners/") and path_value.endswith("/detail")): return response content_type = response.headers.get("content-type", "") if "text/html" not in content_type.lower(): return response body = b"" async for chunk in response.body_iterator: body += chunk html = body.decode("utf-8", errors="replace") script_tag = '' if script_tag not in html: lower_html = html.lower() close_index = lower_html.rfind("") if close_index >= 0: html = html[:close_index] + script_tag + "\n" + html[close_index:] else: html = html + "\n" + script_tag headers = dict(response.headers) headers.pop("content-length", None) headers.pop("content-encoding", None) return _HTMLResponse( content=html, status_code=response.status_code, headers=headers, ) except Exception: return response from fastapi import Request as MinerOpsApi3Request, HTTPException as MinerOpsApi3HTTPException @app.post("/api/agent/api3-enrichment") async def minerops_agent_api3_enrichment( request: MinerOpsApi3Request, authenticated_agent_id: str = Depends(require_agent_identity), zone_service: AgentZoneService = Depends(get_agent_zone_service), ): import json import uuid import psycopg from psycopg.types.json import Json def as_text(value): if value is None: return None value = str(value).strip() return value if value else None def fnum(value): try: if value is None or value == "": return None return float(value) except Exception: return None def fint(value): try: if value is None or value == "": return None return int(float(value)) except Exception: return None def jdict(value): return value if isinstance(value, dict) else {} def jlist(value): if value is None: return [] if isinstance(value, list): return value return [value] routing = zone_service.routing_context(authenticated_agent_id) if ( routing.routing_reason or routing.agent_zone_id is None or routing.site_id is None ): raise MinerOpsApi3HTTPException( status_code=409, detail=( "Agent routing unavailable: " + (routing.routing_reason or "agent_zone_unassigned") ), ) body = await request.json() agent_id = authenticated_agent_id site_name = None items = body.get("items") or [] if not isinstance(items, list): raise MinerOpsApi3HTTPException(status_code=400, detail="items must be a list.") conn = None processed = 0 matched = 0 api3_ok = 0 api3_failed = 0 miner_updates = 0 status_rows = 0 pool_rows = 0 hashboard_rows = 0 try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( "SELECT name FROM sites WHERE id = %s", (routing.site_id,), ) site_row = cur.fetchone() if not site_row: raise MinerOpsApi3HTTPException( status_code=409, detail="Agent Site assignment no longer exists.", ) site_name = str(site_row["name"]) cur.execute( """ ALTER TABLE miners ADD COLUMN IF NOT EXISTS power_sn TEXT, ADD COLUMN IF NOT EXISTS psu_model TEXT, ADD COLUMN IF NOT EXISTS customer_sn TEXT, ADD COLUMN IF NOT EXISTS api3_supported BOOLEAN, ADD COLUMN IF NOT EXISTS api3_api_version TEXT, ADD COLUMN IF NOT EXISTS api3_api_switch TEXT, ADD COLUMN IF NOT EXISTS api3_last_seen_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS api3_last_error TEXT, ADD COLUMN IF NOT EXISTS configured_performance_mode TEXT, ADD COLUMN IF NOT EXISTS configured_performance_mode_source TEXT, ADD COLUMN IF NOT EXISTS configured_performance_mode_updated_at TIMESTAMPTZ; """ ) cur.execute( """ ALTER TABLE miner_status_snapshots ADD COLUMN IF NOT EXISTS ip_address INET, ADD COLUMN IF NOT EXISTS mac_address TEXT, ADD COLUMN IF NOT EXISTS power_rate_w_per_th DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS fan_speed_in DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS fan_speed_out DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS env_temp_c DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS elapsed_seconds DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS uptime_seconds DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS pool_rejected_percent DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS raw_summary_json JSONB; """ ) cur.execute( """ CREATE TABLE IF NOT EXISTS miner_pool_snapshots ( id BIGSERIAL PRIMARY KEY, miner_id UUID, ip_address INET, mac_address TEXT, model TEXT, pool_index INTEGER, pool_status TEXT, stratum_active BOOLEAN, worker_user TEXT, url TEXT, accepted BIGINT, rejected BIGINT, pool_rejected_percent DOUBLE PRECISION, last_share_time_epoch BIGINT, raw_pool_json JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); """ ) cur.execute( """ ALTER TABLE miner_pool_snapshots ADD COLUMN IF NOT EXISTS model TEXT, ADD COLUMN IF NOT EXISTS pool_index INTEGER, ADD COLUMN IF NOT EXISTS pool_status TEXT, ADD COLUMN IF NOT EXISTS stratum_active BOOLEAN, ADD COLUMN IF NOT EXISTS worker_user TEXT, ADD COLUMN IF NOT EXISTS url TEXT, ADD COLUMN IF NOT EXISTS accepted BIGINT, ADD COLUMN IF NOT EXISTS rejected BIGINT, ADD COLUMN IF NOT EXISTS pool_rejected_percent DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS last_share_time_epoch BIGINT, ADD COLUMN IF NOT EXISTS raw_pool_json JSONB; """ ) cur.execute( """ CREATE TABLE IF NOT EXISTS miner_api3_enrichment_snapshots ( id BIGSERIAL PRIMARY KEY, miner_id UUID, agent_id TEXT, site_name TEXT, ip_address INET, mac_address TEXT, api_supported BOOLEAN, api_error TEXT, api_version TEXT, api_switch TEXT, model TEXT, miner_sn TEXT, power_sn TEXT, psu_model TEXT, device_info JSONB, miner_setting JSONB, miner_status JSONB, fan_setting JSONB, custom_data JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); """ ) cur.execute( """ CREATE TABLE IF NOT EXISTS miner_api3_hashboard_snapshots ( id BIGSERIAL PRIMARY KEY, miner_id UUID, ip_address INET, mac_address TEXT, slot INTEGER, pcb_sn TEXT, hash_average_ths DOUBLE PRECISION, factory_hash_ths DOUBLE PRECISION, freq DOUBLE PRECISION, effective_chips INTEGER, chip_temp_min_c DOUBLE PRECISION, chip_temp_avg_c DOUBLE PRECISION, chip_temp_max_c DOUBLE PRECISION, raw_hashboard_json JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); """ ) for item in items: if not isinstance(item, dict): continue processed += 1 ip = as_text(item.get("ip")) api_supported = bool(item.get("api3_supported")) api_error = as_text(item.get("api3_error")) if api_supported: api3_ok += 1 else: api3_failed += 1 device_info = jdict(item.get("device_info")) miner_setting = jdict(item.get("miner_setting")) miner_status = jdict(item.get("miner_status")) fan_setting = jdict(item.get("fan_setting")) custom_data = jdict(item.get("custom_data")) device_msg = jdict(device_info.get("msg")) network = jdict(device_msg.get("network")) miner = jdict(device_msg.get("miner")) system = jdict(device_msg.get("system")) power = jdict(device_msg.get("power")) setting_msg = jdict(miner_setting.get("msg")) status_msg = jdict(miner_status.get("msg")) summary = jdict(status_msg.get("summary")) pools = jlist(status_msg.get("pools")) edevs = jlist(status_msg.get("edevs")) custom_msg = jdict(custom_data.get("msg")) mac = as_text(network.get("mac")) or as_text(item.get("mac")) ip_from_api = as_text(network.get("ip")) if ip_from_api: ip = ip_from_api model = as_text(miner.get("type")) miner_sn = as_text(miner.get("miner-sn")) customer_sn = as_text(custom_msg.get("CustomerSn")) api_version = as_text(system.get("api")) api_switch = as_text(system.get("apiswitch")) firmware = as_text(system.get("fwversion")) platform = as_text(system.get("platform")) power_sn = as_text(power.get("sn")) psu_model = as_text(power.get("model")) power_mode = as_text(setting_msg.get("power-mode")) miner_id = None if mac: cur.execute( """ SELECT id FROM miners WHERE upper(current_mac) = upper(%s) AND agent_zone_id = %s ORDER BY updated_at DESC NULLS LAST LIMIT 1; """, (mac, routing.agent_zone_id), ) row = cur.fetchone() if row: miner_id = row["id"] if miner_id is None and ip: cur.execute( """ SELECT id FROM miners WHERE current_ip = %s::inet AND agent_zone_id = %s ORDER BY updated_at DESC NULLS LAST LIMIT 1; """, (ip, routing.agent_zone_id), ) row = cur.fetchone() if row: miner_id = row["id"] if miner_id is None: continue if miner_id is not None: matched += 1 set_parts = [ "api3_supported = %s", "api3_last_seen_at = now()", "api3_last_error = %s", "updated_at = now()", ] params = [api_supported, api_error] def add_update(column, value, overwrite_blank_only=False): if value is None: return if overwrite_blank_only: set_parts.append(f"{column} = COALESCE(NULLIF({column}, ''), %s)") else: set_parts.append(f"{column} = %s") params.append(value) add_update("current_mac", mac, True) add_update("current_ip", ip, False) add_update("model", model, True) add_update("miner_type", model, True) add_update("miner_sn", miner_sn, True) add_update("customer_sn", customer_sn, False) add_update("power_sn", power_sn, True) add_update("psu_model", psu_model, True) add_update("api_version", api_version, False) add_update("api3_api_version", api_version, False) add_update("api3_api_switch", api_switch, False) add_update("firmware_version", firmware, False) add_update("platform", platform, False) if power_mode: set_parts.append("configured_performance_mode = %s") params.append(power_mode.lower()) set_parts.append("configured_performance_mode_source = %s") params.append("api3_get_miner_setting") set_parts.append("configured_performance_mode_updated_at = now()") params.append(str(miner_id)) cur.execute( "UPDATE miners SET " + ", ".join(set_parts) + " WHERE id = %s::uuid;", params, ) miner_updates += 1 cur.execute( """ INSERT INTO miner_api3_enrichment_snapshots ( miner_id, agent_id, site_name, ip_address, mac_address, api_supported, api_error, api_version, api_switch, model, miner_sn, power_sn, psu_model, device_info, miner_setting, miner_status, fan_setting, custom_data ) VALUES ( %s, %s, %s, %s::inet, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb ); """, ( str(miner_id) if miner_id is not None else None, agent_id, site_name, ip, mac, api_supported, api_error, api_version, api_switch, model, miner_sn, power_sn, psu_model, json.dumps(device_info), json.dumps(miner_setting), json.dumps(miner_status), json.dumps(fan_setting), json.dumps(custom_data), ), ) if miner_id is not None and summary: ths = fnum(summary.get("hash-average")) mhs_av = ths * 1000000.0 if ths is not None else None cur.execute( """ INSERT INTO miner_status_snapshots ( miner_id, ip_address, mac_address, mhs_av, power_w, power_rate_w_per_th, chip_temp_avg_c, chip_temp_max_c, fan_speed_in, fan_speed_out, env_temp_c, elapsed_seconds, uptime_seconds, pool_rejected_percent, raw_summary_json, created_at ) VALUES ( %s::uuid, %s::inet, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, now() ); """, ( str(miner_id), ip, mac, mhs_av, fnum(summary.get("power-realtime")), fnum(summary.get("power-rate")), fnum(summary.get("chip-temp-avg")), fnum(summary.get("chip-temp-max")), fnum(summary.get("fan-speed-in")), fnum(summary.get("fan-speed-out")), fnum(summary.get("environment-temperature")), fnum(summary.get("elapsed")), fnum(summary.get("bootup-time")), None, json.dumps(miner_status), ), ) status_rows += 1 if miner_id is not None and pools: for pool in pools: if not isinstance(pool, dict): continue cur.execute( """ INSERT INTO miner_pool_snapshots ( miner_id, ip_address, mac_address, model, pool_index, pool_status, stratum_active, worker_user, url, accepted, rejected, pool_rejected_percent, last_share_time_epoch, raw_pool_json, created_at ) VALUES ( %s::uuid, %s::inet, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, now() ); """, ( str(miner_id), ip, mac, model, fint(pool.get("id")), as_text(pool.get("status")), pool.get("stratum-active") if isinstance(pool.get("stratum-active"), bool) else None, as_text(pool.get("account")), as_text(pool.get("url")), None, None, fnum(pool.get("reject-rate")), fint(pool.get("last-share-time")), json.dumps(pool), ), ) pool_rows += 1 if miner_id is not None and edevs: for board in edevs: if not isinstance(board, dict): continue slot = fint(board.get("slot")) pcb_sn = None if slot is not None: pcb_sn = as_text(miner.get(f"pcbsn{slot}")) cur.execute( """ INSERT INTO miner_api3_hashboard_snapshots ( miner_id, ip_address, mac_address, slot, pcb_sn, hash_average_ths, factory_hash_ths, freq, effective_chips, chip_temp_min_c, chip_temp_avg_c, chip_temp_max_c, raw_hashboard_json, created_at ) VALUES ( %s::uuid, %s::inet, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, now() ); """, ( str(miner_id), ip, mac, slot, pcb_sn, fnum(board.get("hash-average")), fnum(board.get("factory-hash")), fnum(board.get("freq")), fint(board.get("effective-chips")), fnum(board.get("chip-temp-min")), fnum(board.get("chip-temp-avg")), fnum(board.get("chip-temp-max")), json.dumps(board), ), ) hashboard_rows += 1 conn.commit() return api_response( data={ "ok": True, "processed": processed, "matched": matched, "api3_ok": api3_ok, "api3_failed": api3_failed, "miner_updates": miner_updates, "status_rows": status_rows, "pool_rows": pool_rows, "hashboard_rows": hashboard_rows, } ) finally: if conn is not None: conn.close() # MINEROPS_LOCATION_MANAGEMENT_V1 from fastapi.responses import Response as MinerOpsLocationResponse @app.get("/api/miners/{miner_id}/location-management") def minerops_get_miner_location_management( miner_id: str, 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( """ SELECT id::text AS miner_id, current_mac, host(current_ip) AS current_ip, COALESCE(miner_type, model) AS model, miner_sn, power_sn, status FROM miners WHERE id = %s::uuid LIMIT 1; """, (miner_id,), ) miner = cur.fetchone() if not miner: raise HTTPException(status_code=404, detail="Miner not found") cur.execute( """ SELECT mls.asset_state, mls.state_notes, mls.updated_at, lp.id::text AS location_position_id, lp.room_name, lp.rack_name, lp.shelf_name, lp.shelf_index, lp.position_index, lp.position_label, lp.position_status, lp.position_kind, lp.notes AS position_notes FROM miner_location_states mls LEFT JOIN location_positions lp ON lp.id = mls.location_position_id WHERE mls.miner_id = %s::uuid LIMIT 1; """, (miner_id,), ) state_row = cur.fetchone() cur.execute( """ SELECT id::text AS location_position_id, room_name, rack_name, shelf_name, shelf_index, position_index, position_label, position_status, position_kind, notes AS position_notes FROM location_positions WHERE miner_id = %s::uuid ORDER BY room_name, rack_name, shelf_index, position_index LIMIT 1; """, (miner_id,), ) physical_row = cur.fetchone() current_position = dict(physical_row) if physical_row else None asset_state = "waiting_placement" if state_row: asset_state = state_row.get("asset_state") or asset_state if current_position: asset_state = "shelved" cur.execute( """ SELECT lp.id::text AS location_position_id, lp.room_name, lp.rack_name, lp.shelf_name, lp.shelf_index, lp.position_index, lp.position_label, lp.position_status, lp.position_kind, lp.miner_id::text AS assigned_miner_id, lp.expected_mac, miners.current_mac AS assigned_mac, host(miners.current_ip) AS assigned_ip FROM location_positions lp LEFT JOIN miners ON miners.id = lp.miner_id LEFT JOIN rooms room_order ON lower(room_order.name) = lower(lp.room_name) WHERE lp.position_kind = 'miner_slot' AND lp.position_status NOT IN ('switch', 'blocked') AND ( lp.miner_id IS NULL OR lp.miner_id = %s::uuid ) ORDER BY COALESCE(room_order.sort_order, 999999), lp.room_name, lp.rack_name, lp.shelf_index, lp.position_index; """, (miner_id,), ) available_positions = [dict(row) for row in cur.fetchall()] cur.execute( """ SELECT id::text AS event_id, event_type, event_status, event_title, description, event_at, component_slot FROM miner_maintenance_events WHERE miner_id = %s::uuid AND ( event_type = 'moved' OR component_type = 'location' OR event_title ILIKE '%%location%%' ) ORDER BY event_at DESC NULLS LAST, created_at DESC LIMIT 10; """, (miner_id,), ) recent_location_events = [dict(row) for row in cur.fetchall()] return api_response( data={ "miner": dict(miner), "asset_state": asset_state, "state": dict(state_row) if state_row else None, "current_position": current_position, "available_positions": available_positions, "virtual_states": [ { "value": "waiting_placement", "label": "Unracked / Waiting Placement", }, { "value": "repair", "label": "Repair", }, { "value": "storage", "label": "Inventory / Storage", }, { "value": "decommissioned", "label": "Decommissioned", }, ], "recent_location_events": recent_location_events, } ) finally: if conn is not None: conn.close() MINEROPS_LOCATION_MANAGEMENT_JS = r""" (function () { "use strict"; const pathMatch = window.location.pathname.match(/\/miners\/([^\/]+)\/detail/); if (!pathMatch) { return; } const minerId = decodeURIComponent(pathMatch[1]); function getStoredToken() { const keys = [ "minerops_token", "mineropsToken", "authToken", "access_token", "token" ]; for (const storage of [window.localStorage, window.sessionStorage]) { if (!storage) { continue; } for (const key of keys) { const value = storage.getItem(key); if (value) { return value; } } } return ""; } function authHeaders(extra) { const headers = Object.assign({}, extra || {}); const token = getStoredToken(); if (token && !headers.Authorization) { headers.Authorization = "Bearer " + token; } return headers; } async function apiGet(url) { const response = await fetch(url, { method: "GET", credentials: "same-origin", headers: authHeaders({ "Accept": "application/json" }) }); const payload = await response.json().catch(function () { return null; }); if (!response.ok || (payload && payload.error)) { const message = payload && payload.error ? payload.error : "Request failed"; throw new Error(message); } return payload ? payload.data : null; } async function apiPost(url, body) { const response = await fetch(url, { method: "POST", credentials: "same-origin", headers: authHeaders({ "Accept": "application/json", "Content-Type": "application/json" }), body: JSON.stringify(body) }); const text = await response.text(); let payload = null; try { payload = text ? JSON.parse(text) : null; } catch (error) { payload = null; } if (!response.ok || (payload && payload.error)) { const detail = payload && payload.error ? payload.error : text || "Request failed"; throw new Error(detail); } return payload ? payload.data : null; } function escapeHtml(value) { return String(value == null ? "" : value) .replace(/&/g, "&") .replace(//g, ">") .replace(/\"/g, """) .replace(/'/g, "'"); } function stateLabel(value) { const labels = { shelved: "Shelved", waiting_placement: "Unracked / Waiting Placement", repair: "Repair", storage: "Inventory / Storage", decommissioned: "Decommissioned", unassigned: "Unassigned" }; return labels[value] || value || "Unknown"; } function positionLabel(position) { if (!position) { return "No shelf position assigned"; } const label = [ position.room_name || "-", position.rack_name || "-", position.shelf_name || "-", "Pos " + (position.position_index || "-") ].join(" / "); return label; } function findMountPoint() { const candidates = [ "main", ".minerops-main", ".container", ".content", "body" ]; for (const selector of candidates) { const el = document.querySelector(selector); if (el) { return el; } } return document.body; } function ensureCard() { let card = document.getElementById("mineropsLocationManagementCard"); if (card) { return card; } card = document.createElement("section"); card.id = "mineropsLocationManagementCard"; card.style.margin = "16px 0"; card.style.padding = "16px"; card.style.border = "1px solid #d1d5db"; card.style.borderRadius = "12px"; card.style.background = "#ffffff"; card.style.boxShadow = "0 1px 2px rgba(15, 23, 42, 0.08)"; card.innerHTML = '
Loading location management...
'; const mount = findMountPoint(); const firstLargeCard = mount.querySelector(".card, section, .minerops-card"); if (firstLargeCard && firstLargeCard.parentNode) { firstLargeCard.parentNode.insertBefore(card, firstLargeCard.nextSibling); } else if (mount === document.body) { document.body.insertBefore(card, document.body.firstChild); } else { mount.appendChild(card); } return card; } function renderCard(data) { const card = ensureCard(); const miner = data.miner || {}; const currentPosition = data.current_position || null; const availablePositions = data.available_positions || []; const events = data.recent_location_events || []; const assetState = data.asset_state || "waiting_placement"; const currentLocationText = assetState === "shelved" ? positionLabel(currentPosition) : stateLabel(assetState); const options = availablePositions.map(function (position) { const selected = currentPosition && currentPosition.location_position_id === position.location_position_id ? " selected" : ""; return ''; }).join(""); const eventRows = events.length ? events.map(function (event) { return '
  • ' + '
    ' + escapeHtml(event.event_title || event.event_type || "Location event") + '
    ' + '
    ' + escapeHtml(event.event_at || "") + '
    ' + '
    ' + escapeHtml(event.description || "") + '
    ' + '
  • '; }).join("") : '
  • No location changes recorded yet.
  • '; card.innerHTML = `

    Location Management

    Move this miner between shelves, repair, inventory, or decommissioned.
    Current State
    ${escapeHtml(stateLabel(assetState))}
    Current Location
    ${escapeHtml(currentLocationText)}
    Miner
    ${escapeHtml(miner.current_mac || miner.current_ip || miner.miner_id || "-")}

    Move to Shelf Position

    Move Off Shelf

    Recent Location History

      ${eventRows}
    `; card.querySelector("#mineropsLocationRefreshBtn").addEventListener("click", loadLocationCard); card.querySelector("#mineropsLocationMovePhysicalBtn").addEventListener("click", movePhysical); card.querySelectorAll(".minerops-location-state-btn").forEach(function (button) { button.addEventListener("click", function () { moveVirtual(button.getAttribute("data-state")); }); }); } function showMessage(type, text) { const box = document.getElementById("mineropsLocationMessage"); if (!box) { return; } box.style.display = "block"; box.style.border = type === "error" ? "1px solid #fecaca" : "1px solid #bbf7d0"; box.style.background = type === "error" ? "#fef2f2" : "#f0fdf4"; box.style.color = type === "error" ? "#991b1b" : "#166534"; box.textContent = text; } async function loadLocationCard() { const card = ensureCard(); try { card.innerHTML = '
    Loading location management...
    '; const data = await apiGet("/api/miners/" + encodeURIComponent(minerId) + "/location-management"); renderCard(data); } catch (error) { card.innerHTML = '
    Location management failed to load: ' + escapeHtml(error.message || error) + '
    '; } } async function movePhysical() { const select = document.getElementById("mineropsLocationPositionSelect"); const note = document.getElementById("mineropsLocationNote"); if (!select || !select.value) { showMessage("error", "Choose a shelf position first."); return; } try { showMessage("ok", "Moving miner..."); const result = await apiPost("/api/location/miners/move", { miner_id: minerId, destination_type: "physical", location_position_id: select.value, notes: note ? note.value : "" }); showMessage("ok", "Moved from " + (result.old_location || "-") + " to " + (result.new_location || "-") + "."); setTimeout(loadLocationCard, 700); } catch (error) { showMessage("error", error.message || String(error)); } } async function moveVirtual(state) { const note = document.getElementById("mineropsLocationNote"); try { showMessage("ok", "Moving miner..."); const result = await apiPost("/api/location/miners/move", { miner_id: minerId, destination_type: "virtual", asset_state: state, notes: note ? note.value : "" }); showMessage("ok", "Moved from " + (result.old_location || "-") + " to " + stateLabel(result.asset_state || state) + "."); setTimeout(loadLocationCard, 700); } catch (error) { showMessage("error", error.message || String(error)); } } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", loadLocationCard); } else { loadLocationCard(); } })(); """ @app.get("/assets/minerops-location-management.js") def minerops_location_management_asset(): return MinerOpsLocationResponse( content=MINEROPS_LOCATION_MANAGEMENT_JS, media_type="application/javascript", ) @app.middleware("http") async def minerops_inject_location_management(request, call_next): response = await call_next(request) try: if request.scope.get("endpoint") is internal_alpha_miner_detail_page: return response path = request.url.path or "" if not (path.startswith("/miners/") and path.endswith("/detail")): return response content_type = response.headers.get("content-type", "") if "text/html" not in content_type.lower(): return response body = b"" async for chunk in response.body_iterator: body += chunk html = body.decode("utf-8", errors="ignore") script_tag = '' if script_tag not in html: if "" in html: html = html.replace("", script_tag + "") else: html += script_tag headers = dict(response.headers) headers.pop("content-length", None) return MinerOpsLocationResponse( content=html, status_code=response.status_code, headers=headers, media_type="text/html", ) except Exception: return response # MINEROPS_MAP_CLICK_MOVE_V1 from fastapi.responses import Response as MinerOpsMapMoveResponse MINEROPS_MAP_CLICK_MOVE_JS = r""" (function () { "use strict"; if (window.location.pathname !== "/locations") { return; } let mapData = null; let positionsById = {}; let minersById = {}; let currentTargetPosition = null; let assignTimer = null; function getStoredToken() { const keys = [ "minerops_token", "mineropsToken", "authToken", "access_token", "token" ]; for (const storage of [window.localStorage, window.sessionStorage]) { if (!storage) { continue; } for (const key of keys) { const value = storage.getItem(key); if (value) { return value; } } } return ""; } function authHeaders(extra) { const headers = Object.assign({}, extra || {}); const token = getStoredToken(); if (token && !headers.Authorization) { headers.Authorization = "Bearer " + token; } return headers; } async function apiGet(url) { const response = await fetch(url, { method: "GET", credentials: "same-origin", headers: authHeaders({ "Accept": "application/json" }) }); const payload = await response.json().catch(function () { return null; }); if (!response.ok || (payload && payload.error)) { throw new Error((payload && payload.error) || "Request failed"); } return payload ? payload.data : null; } async function apiPost(url, body) { const response = await fetch(url, { method: "POST", credentials: "same-origin", headers: authHeaders({ "Accept": "application/json", "Content-Type": "application/json" }), body: JSON.stringify(body) }); const text = await response.text(); let payload = null; try { payload = text ? JSON.parse(text) : null; } catch (error) { payload = null; } if (!response.ok || (payload && payload.error)) { throw new Error((payload && payload.error) || text || "Request failed"); } return payload ? payload.data : null; } function escapeHtml(value) { return String(value == null ? "" : value) .replace(/&/g, "&") .replace(//g, ">") .replace(/\"/g, """) .replace(/'/g, "'"); } function positionLabel(position) { if (!position) { return "Unknown position"; } return [ position.room_name || "-", position.rack_name || "-", position.shelf_name || "-", "Pos " + (position.position_index || "-") ].join(" / "); } function minerLabel(miner) { if (!miner) { return "Unknown miner"; } const parts = []; if (miner.current_mac) { parts.push(miner.current_mac); } if (miner.current_ip) { parts.push(miner.current_ip); } if (miner.model || miner.miner_type) { parts.push(miner.miner_type || miner.model); } if (miner.miner_sn) { parts.push("SN " + miner.miner_sn); } return parts.join(" | ") || miner.miner_id || "Unknown miner"; } function getMinerCurrentLocation(minerId) { if (!mapData || !minerId) { return "Unknown"; } const positions = mapData.positions || []; for (const position of positions) { if (position.miner_id === minerId) { return positionLabel(position); } } const virtuals = mapData.virtual_miners || []; for (const miner of virtuals) { if (miner.miner_id === minerId) { const state = miner.asset_state || "waiting_placement"; const labels = { waiting_placement: "Unracked / Waiting Placement", repair: "Repair", storage: "Inventory / Storage", decommissioned: "Decommissioned" }; return labels[state] || state; } } return "Unknown"; } function isMoveTarget(position) { if (!position) { return false; } if (position.miner_id) { return false; } if (position.position_kind !== "miner_slot") { return false; } const status = String(position.position_status || "").toLowerCase(); if (status === "blocked" || status === "switch") { return false; } return true; } function buildMinerIndex(data) { minersById = {}; const positions = data.positions || []; for (const position of positions) { if (!position.miner_id) { continue; } minersById[position.miner_id] = { miner_id: position.miner_id, current_mac: position.current_mac, current_ip: position.current_ip, status: position.miner_status, hostname: position.hostname, miner_sn: position.miner_sn, power_sn: position.power_sn, model: position.model, miner_type: position.miner_type, asset_state: position.asset_state || "shelved" }; } const virtuals = data.virtual_miners || []; for (const miner of virtuals) { if (!miner.miner_id) { continue; } minersById[miner.miner_id] = miner; } } async function refreshMapData() { const data = await apiGet("/api/location/map"); mapData = data || { positions: [], virtual_miners: [] }; positionsById = {}; for (const position of mapData.positions || []) { if (position.location_position_id) { positionsById[position.location_position_id] = position; } } buildMinerIndex(mapData); assignTileTargets(); } function positionFromTileDataset(tile) { if (!tile) { return null; } return { location_position_id: tile.getAttribute("data-location-position-id") || null, room_name: tile.dataset.roomName || "", rack_name: tile.dataset.rackName || "", shelf_name: tile.dataset.shelfName || "", position_index: Number(tile.dataset.positionIndex || 0), position_kind: tile.dataset.positionKind || "", position_status: tile.dataset.positionStatus || "", conflict_reason: tile.dataset.conflictReason || "", miner_id: tile.dataset.minerId || null }; } function assignTileTargets() { const tiles = Array.from(document.querySelectorAll(".tile")); if (!tiles.length) { return; } for (const tile of tiles) { const position = positionFromTileDataset(tile); if (!position || !position.room_name || !position.rack_name || !position.shelf_name || !position.position_index) { tile.classList.remove("minerops-map-empty-move-target"); continue; } tile.setAttribute("data-position-label", positionLabel(position)); if (isMoveTarget(position)) { tile.classList.add("minerops-map-empty-move-target"); tile.setAttribute("title", "Click to move a miner here: " + positionLabel(position)); if (!tile.getAttribute("tabindex")) { tile.setAttribute("tabindex", "0"); } } else { tile.classList.remove("minerops-map-empty-move-target"); } } } function createModal() { if (document.getElementById("mineropsMapMoveModal")) { return; } const style = document.createElement("style"); style.textContent = ` .minerops-map-empty-move-target { cursor: copy !important; outline-offset: 2px; } .minerops-map-empty-move-target:hover { outline: 4px solid #0f766e !important; box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.18); } #mineropsMapMoveModal { position: fixed; inset: 0; z-index: 999999; background: rgba(15, 23, 42, 0.45); display: none; align-items: center; justify-content: center; padding: 18px; } #mineropsMapMoveModal .minerops-map-move-panel { width: min(760px, 100%); max-height: 88vh; overflow: auto; background: white; color: #102a43; border-radius: 12px; border: 1px solid #d9e2ec; box-shadow: 0 12px 36px rgba(15, 23, 42, 0.28); padding: 16px; } #mineropsMapMoveModal .minerops-map-move-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; margin-top: 12px; } #mineropsMapMoveModal label { display: block; font-size: 13px; font-weight: 700; color: #334155; margin-bottom: 5px; } #mineropsMapMoveModal input, #mineropsMapMoveModal select { width: 100%; box-sizing: border-box; border: 1px solid #bcccdc; border-radius: 8px; padding: 9px; } #mineropsMapMoveModal .minerops-map-move-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; } #mineropsMapMoveModal .primary { background: #0f766e; color: white; border-color: #0f766e; } #mineropsMapMoveStatus { display: none; margin-top: 12px; border-radius: 8px; padding: 10px; font-size: 13px; } `; document.head.appendChild(style); const modal = document.createElement("div"); modal.id = "mineropsMapMoveModal"; modal.innerHTML = ` `; document.body.appendChild(modal); document.getElementById("mineropsMapMoveCloseBtn").addEventListener("click", closeModal); document.getElementById("mineropsMapMoveCancelBtn").addEventListener("click", closeModal); document.getElementById("mineropsMapMoveConfirmBtn").addEventListener("click", confirmMove); document.getElementById("mineropsMapMoveSearch").addEventListener("input", renderMinerOptions); document.getElementById("mineropsMapMoveSelect").addEventListener("change", updateSelectedMinerLocation); modal.addEventListener("click", function (event) { if (event.target === modal) { closeModal(); } }); } function showStatus(type, message) { const status = document.getElementById("mineropsMapMoveStatus"); if (!status) { return; } status.style.display = "block"; status.style.background = type === "error" ? "#fef2f2" : "#f0fdf4"; status.style.border = type === "error" ? "1px solid #fecaca" : "1px solid #bbf7d0"; status.style.color = type === "error" ? "#991b1b" : "#166534"; status.textContent = message; } function openModal(position) { createModal(); currentTargetPosition = position; document.getElementById("mineropsMapMovePosition").textContent = positionLabel(position); document.getElementById("mineropsMapMoveSearch").value = ""; document.getElementById("mineropsMapMoveNote").value = ""; document.getElementById("mineropsMapMoveStatus").style.display = "none"; renderMinerOptions(); updateSelectedMinerLocation(); document.getElementById("mineropsMapMoveModal").style.display = "flex"; document.getElementById("mineropsMapMoveSearch").focus(); } function closeModal() { const modal = document.getElementById("mineropsMapMoveModal"); if (modal) { modal.style.display = "none"; } currentTargetPosition = null; } function renderMinerOptions() { const select = document.getElementById("mineropsMapMoveSelect"); const search = String(document.getElementById("mineropsMapMoveSearch").value || "").toLowerCase().trim(); if (!select) { return; } const miners = Object.values(minersById).sort(function (a, b) { return minerLabel(a).localeCompare(minerLabel(b)); }); const filtered = miners.filter(function (miner) { if (!search) { return true; } const haystack = [ miner.current_mac, miner.current_ip, miner.model, miner.miner_type, miner.miner_sn, miner.power_sn, miner.hostname, miner.asset_state ].join(" ").toLowerCase(); return haystack.indexOf(search) !== -1; }); select.innerHTML = filtered.map(function (miner) { return ''; }).join(""); if (!filtered.length) { select.innerHTML = ''; } updateSelectedMinerLocation(); } function updateSelectedMinerLocation() { const select = document.getElementById("mineropsMapMoveSelect"); const box = document.getElementById("mineropsMapMoveCurrentLocation"); if (!select || !box) { return; } const minerId = select.value; if (!minerId) { box.textContent = ""; return; } box.textContent = "Current miner location: " + getMinerCurrentLocation(minerId); } async function confirmMove() { const select = document.getElementById("mineropsMapMoveSelect"); const note = document.getElementById("mineropsMapMoveNote"); if (!currentTargetPosition || !currentTargetPosition.location_position_id) { showStatus("error", "No target position selected."); return; } if (!select || !select.value) { showStatus("error", "Choose a miner first."); return; } try { showStatus("ok", "Moving miner..."); const body = { miner_id: select.value, destination_type: "physical", notes: note ? note.value : "" }; if (currentTargetPosition.location_position_id) { body.location_position_id = currentTargetPosition.location_position_id; } else { body.room_name = currentTargetPosition.room_name; body.rack_name = currentTargetPosition.rack_name; body.shelf_name = currentTargetPosition.shelf_name; body.position_index = Number(currentTargetPosition.position_index || 0); } const result = await apiPost("/api/location/miners/move", body); showStatus("ok", "Moved from " + (result.old_location || "-") + " to " + (result.new_location || "-") + "."); setTimeout(function () { window.location.reload(); }, 900); } catch (error) { showStatus("error", error.message || String(error)); } } function handleTileClick(event) { const tile = event.target.closest(".tile"); if (!tile) { return; } const position = positionFromTileDataset(tile); if (!isMoveTarget(position)) { return; } event.preventDefault(); event.stopPropagation(); openModal(position); } function handleTileKey(event) { if (event.key !== "Enter" && event.key !== " ") { return; } const tile = event.target.closest(".tile"); if (!tile || !tile.classList.contains("minerops-map-empty-move-target")) { return; } event.preventDefault(); tile.click(); } function startAssignLoop() { if (assignTimer) { window.clearInterval(assignTimer); } let attempts = 0; assignTimer = window.setInterval(function () { attempts += 1; assignTileTargets(); if (attempts > 40) { window.clearInterval(assignTimer); assignTimer = null; } }, 750); } async function init() { createModal(); try { await refreshMapData(); } catch (error) { console.warn("MinerOps map click-to-move failed to load map data", error); } document.addEventListener("click", handleTileClick, true); document.addEventListener("keydown", handleTileKey, true); const observer = new MutationObserver(function () { assignTileTargets(); }); observer.observe(document.body, { childList: true, subtree: true }); startAssignLoop(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } })(); """ @app.get("/assets/minerops-map-click-move.js") def minerops_map_click_move_asset(): return MinerOpsMapMoveResponse( content=MINEROPS_MAP_CLICK_MOVE_JS, media_type="application/javascript", ) @app.middleware("http") async def minerops_inject_map_click_move(request, call_next): response = await call_next(request) try: path = request.url.path or "" if path != "/locations": return response content_type = response.headers.get("content-type", "") if "text/html" not in content_type.lower(): return response body = b"" async for chunk in response.body_iterator: body += chunk html = body.decode("utf-8", errors="ignore") script_tag = '' if script_tag not in html: if "" in html: html = html.replace("", script_tag + "") else: html += script_tag headers = dict(response.headers) headers.pop("content-length", None) return MinerOpsMapMoveResponse( content=html, status_code=response.status_code, headers=headers, media_type="text/html", ) except Exception: return response # MINEROPS_REPAIR_QUEUE_V1 from fastapi.responses import HTMLResponse as MinerOpsRepairHTMLResponse from fastapi.responses import Response as MinerOpsRepairResponse class MinerOpsRepairNoteRequest(BaseModel): note: str event_status: str = "in_progress" class MinerOpsRepairStatusRequest(BaseModel): event_status: str note: str | None = None class MinerOpsRepairStateRequest(BaseModel): asset_state: str note: str | None = None def minerops_repair_current_user_id(current_user): try: return get_current_user_id_for_location(current_user) except Exception: return None def minerops_repair_allowed_status(value): value = str(value or "").strip().lower() allowed = { "open", "diagnosing", "waiting_parts", "in_progress", "testing", "ready_to_shelf", "needs_followup", "completed", "failed", "cancelled", } if value not in allowed: raise HTTPException( status_code=400, detail="Invalid repair status", ) return 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), ): 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") 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, request: MinerOpsRepairNoteRequest, current_user: dict = Depends(get_current_user), ): ensure_maintenance_tables() note = str(request.note or "").strip() if not note: raise HTTPException(status_code=400, detail="Repair note is required") event_status = minerops_repair_allowed_status(request.event_status) conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id::text, current_mac FROM miners WHERE id = %s::uuid LIMIT 1 FOR UPDATE; """, (miner_id,), ) miner = cur.fetchone() 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 ( id, miner_id, event_type, event_status, event_title, description, event_at, component_type, component_slot, created_by_user_id ) VALUES ( %s::uuid, %s::uuid, 'repair', %s, 'Repair note', %s, now(), 'repair', 'Repair Queue', %s::uuid ) RETURNING id::text; """, ( str(uuid4()), miner_id, event_status, note, minerops_repair_current_user_id(current_user), ), ) event = cur.fetchone() conn.commit() return api_response( data={ "miner_id": miner_id, "event_id": event["id"] if event else None, "event_status": event_status, "note": note, } ) except Exception: if conn is not None: conn.rollback() raise finally: if conn is not None: conn.close() @app.post("/api/repairs/{miner_id}/status") def minerops_repair_update_status_api( miner_id: str, request: MinerOpsRepairStatusRequest, current_user: dict = Depends(get_current_user), ): ensure_maintenance_tables() event_status = minerops_repair_allowed_status(request.event_status) note = str(request.note or "").strip() conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id::text FROM miners WHERE id = %s::uuid LIMIT 1 FOR UPDATE; """, (miner_id,), ) miner = cur.fetchone() 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 FROM miner_maintenance_events WHERE miner_id = %s::uuid AND event_type = 'repair' ORDER BY event_at DESC, created_at DESC LIMIT 1; """, (miner_id,), ) latest = cur.fetchone() if latest: description_append = "" if note: description_append = "\n\nStatus update note: " + note cur.execute( """ UPDATE miner_maintenance_events SET event_status = %s, description = COALESCE(description, '') || %s, updated_at = now() WHERE id = %s::uuid RETURNING id::text; """, ( event_status, description_append, latest["id"], ), ) event = cur.fetchone() else: description = note or f"Repair status set to {event_status}." cur.execute( """ INSERT INTO miner_maintenance_events ( id, miner_id, event_type, event_status, event_title, description, event_at, component_type, component_slot, created_by_user_id ) VALUES ( %s::uuid, %s::uuid, 'repair', %s, 'Repair status updated', %s, now(), 'repair', 'Repair Queue', %s::uuid ) RETURNING id::text; """, ( str(uuid4()), miner_id, event_status, description, minerops_repair_current_user_id(current_user), ), ) event = cur.fetchone() conn.commit() return api_response( data={ "miner_id": miner_id, "event_id": event["id"] if event else None, "event_status": event_status, } ) except Exception: if conn is not None: conn.rollback() raise finally: if conn is not None: conn.close() @app.post("/api/repairs/{miner_id}/state") def minerops_repair_update_state_api( miner_id: str, request: MinerOpsRepairStateRequest, current_user: dict = Depends(get_current_user), ): ensure_location_tables() ensure_maintenance_tables() asset_state = str(request.asset_state or "").strip().lower() note = str(request.note or "").strip() allowed_states = { "repair", "waiting_placement", "storage", "decommissioned", } if asset_state not in allowed_states: raise HTTPException(status_code=400, detail="Invalid asset state") title_by_state = { "repair": "Repair state updated", "waiting_placement": "Repair completed - waiting placement", "storage": "Moved from repair to storage", "decommissioned": "Miner decommissioned", } description = title_by_state[asset_state] if note: description += ". " + note conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT id::text, current_mac FROM miners WHERE id = %s::uuid LIMIT 1 FOR UPDATE; """, (miner_id,), ) miner = cur.fetchone() 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 SET miner_id = NULL, expected_mac = NULL, position_status = CASE WHEN position_kind = 'miner_slot' THEN 'available' ELSE position_status END, conflict_reason = NULL, updated_at = now() WHERE miner_id = %s::uuid; """, (miner_id,), ) cur.execute( """ INSERT INTO miner_location_states ( miner_id, asset_state, location_position_id, state_notes, updated_by_user_id, updated_at ) VALUES ( %s::uuid, %s, NULL, %s, %s::uuid, now() ) ON CONFLICT (miner_id) DO UPDATE SET asset_state = EXCLUDED.asset_state, location_position_id = NULL, state_notes = CASE WHEN EXCLUDED.state_notes IS NULL OR EXCLUDED.state_notes = '' THEN miner_location_states.state_notes ELSE EXCLUDED.state_notes END, updated_by_user_id = EXCLUDED.updated_by_user_id, updated_at = now(); """, ( miner_id, asset_state, note, minerops_repair_current_user_id(current_user), ), ) if asset_state in ("waiting_placement", "storage", "decommissioned"): cur.execute( """ UPDATE miner_maintenance_events SET event_status = 'completed', updated_at = now() WHERE miner_id = %s::uuid AND event_type = 'repair' AND event_status IN ( 'open', 'diagnosing', 'waiting_parts', 'in_progress', 'testing', 'needs_followup' ); """, (miner_id,), ) if asset_state == "decommissioned": cur.execute( """ UPDATE miners SET status = 'offline', updated_at = now() WHERE id = %s::uuid; """, (miner_id,), ) cur.execute( """ INSERT INTO miner_maintenance_events ( id, miner_id, event_type, event_status, event_title, description, event_at, component_type, component_slot, created_by_user_id ) VALUES ( %s::uuid, %s::uuid, 'repair', 'completed', %s, %s, now(), 'repair', 'Repair Queue', %s::uuid ) RETURNING id::text; """, ( str(uuid4()), miner_id, title_by_state[asset_state], description, minerops_repair_current_user_id(current_user), ), ) event = cur.fetchone() conn.commit() return api_response( data={ "miner_id": miner_id, "asset_state": asset_state, "event_id": event["id"] if event else None, } ) except Exception: if conn is not None: conn.rollback() raise finally: if conn is not None: conn.close() MINEROPS_REPAIR_QUEUE_HTML = r""" MinerOps Repair Queue

    Repair Queue

    Miners currently marked as Repair. Use this page to triage repairs and move completed units back toward placement.
    Dashboard  |  Locations
    Repair miners
    -
    Open / active
    -
    M60S
    -
    M50S++
    -
    Miner Model Serials Reported Problem Repair Status Repair Note Actions
    Loading repairs...
    """ @app.get("/repairs", response_class=MinerOpsRepairHTMLResponse) def minerops_repair_queue_page(): return MINEROPS_REPAIR_QUEUE_HTML # MINEROPS_REPAIR_POLISH_V1 from fastapi.responses import Response as MinerOpsRepairPolishResponse MINEROPS_REPAIR_QUICK_NAV_JS = r""" (function () { "use strict"; if (document.getElementById("mineropsQuickNav")) { return; } const path = window.location.pathname; const links = [ { href: "/dashboard", label: "Dashboard" }, { href: "/repairs", label: "Repairs" }, { href: "/locations", label: "Locations" }, { href: "/system-health", label: "System Health" } ]; const style = document.createElement("style"); style.textContent = ` #mineropsQuickNav { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0 0 14px 0; padding: 10px; background: #ffffff; border: 1px solid #d9e2ec; border-radius: 10px; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); font-family: Segoe UI, Arial, sans-serif; } #mineropsQuickNav .nav-label { font-size: 13px; color: #52606d; font-weight: 700; margin-right: 2px; } #mineropsQuickNav a { display: inline-block; text-decoration: none; color: #0b63ce; border: 1px solid #bcccdc; border-radius: 999px; padding: 6px 10px; font-size: 13px; font-weight: 700; background: #f8fafc; } #mineropsQuickNav a.active { background: #0f766e; border-color: #0f766e; color: #ffffff; } #mineropsQuickNav a:hover { background: #e6f0ff; } #mineropsQuickNav a.active:hover { background: #0f766e; } `; document.head.appendChild(style); const nav = document.createElement("div"); nav.id = "mineropsQuickNav"; nav.innerHTML = 'MinerOps:' + links.map(function (link) { const active = path === link.href ? " active" : ""; return '' + link.label + ''; }).join(""); if (document.body.firstChild) { document.body.insertBefore(nav, document.body.firstChild); } else { document.body.appendChild(nav); } })(); """ MINEROPS_REPAIR_RETURN_MODE_JS = r""" (function () { "use strict"; if (window.location.pathname !== "/locations") { return; } const params = new URLSearchParams(window.location.search); const minerId = params.get("moveMiner"); if (!minerId) { return; } function escapeHtml(value) { return String(value == null ? "" : value) .replace(/&/g, "&") .replace(//g, ">") .replace(/\"/g, """) .replace(/'/g, "'"); } function insertBanner() { if (document.getElementById("mineropsRepairReturnBanner")) { return; } const style = document.createElement("style"); style.textContent = ` #mineropsRepairReturnBanner { margin: 0 0 14px 0; padding: 12px; background: #ecfdf5; border: 1px solid #99f6e4; border-radius: 10px; color: #134e4a; font-family: Segoe UI, Arial, sans-serif; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); } #mineropsRepairReturnBanner strong { display: block; margin-bottom: 4px; } #mineropsRepairReturnBanner code { background: rgba(15, 118, 110, 0.12); border-radius: 5px; padding: 1px 4px; } #mineropsRepairReturnBanner a { color: #0b63ce; font-weight: 700; text-decoration: none; } `; document.head.appendChild(style); const banner = document.createElement("div"); banner.id = "mineropsRepairReturnBanner"; banner.innerHTML = ` Return to Shelf mode is on Click an empty shelf position. The selected repair miner will be preselected in the move dialog.
    Miner ID: ${escapeHtml(minerId)}   clear mode
    `; const quickNav = document.getElementById("mineropsQuickNav"); if (quickNav && quickNav.parentNode) { quickNav.parentNode.insertBefore(banner, quickNav.nextSibling); } else if (document.body.firstChild) { document.body.insertBefore(banner, document.body.firstChild); } else { document.body.appendChild(banner); } } function preselectMiner() { const select = document.getElementById("mineropsMapMoveSelect"); if (!select) { return false; } select.value = minerId; const note = document.getElementById("mineropsMapMoveNote"); if (note && !note.value) { note.value = "Returned from Repair Queue to shelf."; } select.dispatchEvent(new Event("change", { bubbles: true })); const status = document.getElementById("mineropsMapMoveStatus"); if (status) { status.style.display = "block"; status.style.background = "#f0fdf4"; status.style.border = "1px solid #bbf7d0"; status.style.color = "#166534"; if (select.value === minerId) { status.textContent = "Repair miner preselected. Confirm the shelf move when ready."; } else { status.textContent = "Could not preselect the repair miner. Please search/select it manually."; } } return select.value === minerId; } function tryPreselectSeveralTimes() { [60, 160, 350, 700, 1200].forEach(function (delay) { window.setTimeout(preselectMiner, delay); }); } document.addEventListener("click", function (event) { const tile = event.target.closest(".tile"); if (!tile) { return; } tryPreselectSeveralTimes(); }, true); if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", insertBanner); } else { insertBanner(); } })(); """ @app.get("/assets/minerops-repair-quick-nav.js") def minerops_repair_quick_nav_asset(): return MinerOpsRepairPolishResponse( content=MINEROPS_REPAIR_QUICK_NAV_JS, media_type="application/javascript", ) @app.get("/assets/minerops-repair-return-mode.js") def minerops_repair_return_mode_asset(): return MinerOpsRepairPolishResponse( content=MINEROPS_REPAIR_RETURN_MODE_JS, media_type="application/javascript", ) @app.middleware("http") async def minerops_inject_repair_polish_assets(request, call_next): response = await call_next(request) try: path = request.url.path or "" if request.scope.get("endpoint") is internal_alpha_dashboard_page: return response if path not in ("/dashboard", "/locations", "/repairs"): return response content_type = response.headers.get("content-type", "") if "text/html" not in content_type.lower(): return response body = b"" async for chunk in response.body_iterator: body += chunk html = body.decode("utf-8", errors="ignore") scripts = [ '' ] if path == "/locations": scripts.append('') inject = "".join(s for s in scripts if s not in html) if inject: if "" in html: html = html.replace("", inject + "") else: html += inject headers = dict(response.headers) headers.pop("content-length", None) return MinerOpsRepairPolishResponse( content=html, status_code=response.status_code, headers=headers, media_type="text/html", ) except Exception: return response # MINEROPS_MAP_CLICK_MOVE_DATASET_FIX_V1 # MINEROPS_DASHBOARD_REPAIR_SUMMARY_V1 from fastapi.responses import Response as MinerOpsDashboardRepairResponse MINEROPS_DASHBOARD_REPAIR_SUMMARY_JS = r""" (function () { "use strict"; if (window.location.pathname !== "/dashboard") { return; } if (document.getElementById("mineropsDashboardRepairSummary")) { return; } function getStoredToken() { const keys = [ "minerops_token", "mineropsToken", "authToken", "access_token", "token" ]; for (const storage of [window.localStorage, window.sessionStorage]) { if (!storage) { continue; } for (const key of keys) { const value = storage.getItem(key); if (value) { return value; } } } return ""; } function authHeaders(extra) { const headers = Object.assign({}, extra || {}); const token = getStoredToken(); if (token && !headers.Authorization) { headers.Authorization = "Bearer " + token; } return headers; } async function apiGet(url) { const response = await fetch(url, { method: "GET", credentials: "same-origin", headers: authHeaders({ "Accept": "application/json" }) }); const payload = await response.json().catch(function () { return null; }); if (!response.ok || (payload && payload.error)) { throw new Error((payload && payload.error) || "Request failed"); } return payload ? payload.data : null; } function escapeHtml(value) { return String(value == null ? "" : value) .replace(/&/g, "&") .replace(//g, ">") .replace(/\"/g, """) .replace(/'/g, "'"); } function createShell() { const style = document.createElement("style"); style.textContent = ` #mineropsDashboardRepairSummary { margin: 0 0 16px 0; padding: 14px; background: #ffffff; border: 1px solid #d9e2ec; border-radius: 12px; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); font-family: Segoe UI, Arial, sans-serif; color: #102a43; } #mineropsDashboardRepairSummary .repair-header { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; align-items: center; margin-bottom: 10px; } #mineropsDashboardRepairSummary h2 { margin: 0; font-size: 20px; } #mineropsDashboardRepairSummary .repair-muted { color: #52606d; font-size: 13px; } #mineropsDashboardRepairSummary .repair-metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 8px; } #mineropsDashboardRepairSummary .repair-metric { border: 1px solid #d9e2ec; border-radius: 10px; padding: 10px; background: #f8fafc; } #mineropsDashboardRepairSummary .repair-metric .value { font-size: 24px; font-weight: 800; margin-top: 2px; } #mineropsDashboardRepairSummary .repair-actions { display: flex; gap: 8px; flex-wrap: wrap; } #mineropsDashboardRepairSummary a, #mineropsDashboardRepairSummary button { border: 1px solid #829ab1; border-radius: 8px; background: #f8fafc; padding: 7px 10px; font-weight: 700; color: #0b63ce; text-decoration: none; cursor: pointer; font-size: 13px; } #mineropsDashboardRepairSummary a.primary { background: #0f766e; border-color: #0f766e; color: #ffffff; } #mineropsDashboardRepairSummary .repair-list { margin-top: 10px; font-size: 13px; color: #334155; } #mineropsDashboardRepairSummary .repair-list strong { color: #102a43; } `; document.head.appendChild(style); const card = document.createElement("section"); card.id = "mineropsDashboardRepairSummary"; card.innerHTML = `

    Repair Summary

    Loading repair queue...
    In Repair
    -
    Active/Open
    -
    Waiting Parts
    -
    Testing
    -
    `; const quickNav = document.getElementById("mineropsQuickNav"); if (quickNav && quickNav.parentNode) { quickNav.parentNode.insertBefore(card, quickNav.nextSibling); } else { const target = document.querySelector("main, .container, .content, body") || document.body; if (target.firstChild) { target.insertBefore(card, target.firstChild); } else { target.appendChild(card); } } const refresh = document.getElementById("mineropsRepairSummaryRefresh"); if (refresh) { refresh.addEventListener("click", loadRepairSummary); } } function sumStatuses(counts, statuses) { return statuses.reduce(function (sum, status) { return sum + Number(counts[status] || 0); }, 0); } function topEntries(counts, limit) { return Object.entries(counts || {}) .sort(function (a, b) { return Number(b[1]) - Number(a[1]); }) .slice(0, limit); } async function loadRepairSummary() { createShell(); const detail = document.getElementById("dashRepairDetails"); try { const data = await apiGet("/api/repairs/queue"); const summary = data.summary || {}; const statusCounts = summary.status_counts || {}; const modelCounts = summary.model_counts || {}; const errorCounts = summary.error_counts || {}; const total = Number(summary.total || 0); const active = sumStatuses(statusCounts, [ "open", "diagnosing", "waiting_parts", "in_progress", "testing", "needs_followup" ]); document.getElementById("dashRepairTotal").textContent = total; document.getElementById("dashRepairActive").textContent = active; document.getElementById("dashRepairWaitingParts").textContent = statusCounts.waiting_parts || 0; document.getElementById("dashRepairTesting").textContent = statusCounts.testing || 0; const models = topEntries(modelCounts, 4).map(function (entry) { return escapeHtml(entry[0]) + ": " + escapeHtml(entry[1]) + ""; }).join("   "); const errors = topEntries(errorCounts, 4).map(function (entry) { return escapeHtml(entry[0]) + ": " + escapeHtml(entry[1]) + ""; }).join("   "); detail.innerHTML = `
    Models: ${models || "None"}
    Top reported problems: ${errors || "None"}
    `; } catch (error) { if (detail) { detail.innerHTML = 'Repair Summary failed to load: ' + escapeHtml(error.message || error) + ''; } } } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", loadRepairSummary); } else { loadRepairSummary(); } })(); """ @app.get("/assets/minerops-dashboard-repair-summary.js") def minerops_dashboard_repair_summary_asset(): return MinerOpsDashboardRepairResponse( content=MINEROPS_DASHBOARD_REPAIR_SUMMARY_JS, media_type="application/javascript", ) @app.middleware("http") async def minerops_inject_dashboard_repair_summary(request, call_next): response = await call_next(request) try: if request.scope.get("endpoint") is internal_alpha_dashboard_page: return response path = request.url.path or "" if path != "/dashboard": return response content_type = response.headers.get("content-type", "") if "text/html" not in content_type.lower(): return response body = b"" async for chunk in response.body_iterator: body += chunk html = body.decode("utf-8", errors="ignore") script_tag = '' if script_tag not in html: if "" in html: html = html.replace("", script_tag + "") else: html += script_tag headers = dict(response.headers) headers.pop("content-length", None) return MinerOpsDashboardRepairResponse( content=html, status_code=response.status_code, headers=headers, media_type="text/html", ) except Exception: return response # MINEROPS_REPAIR_HISTORY_MODAL_V1 from fastapi.responses import Response as MinerOpsRepairHistoryResponse MINEROPS_REPAIR_HISTORY_JS = r""" (function () { "use strict"; if (window.location.pathname !== "/repairs") { return; } let queueCache = null; let queueCacheAt = 0; function getStoredToken() { const keys = [ "minerops_token", "mineropsToken", "authToken", "access_token", "token" ]; for (const storage of [window.localStorage, window.sessionStorage]) { if (!storage) { continue; } for (const key of keys) { const value = storage.getItem(key); if (value) { return value; } } } return ""; } function authHeaders(extra) { const headers = Object.assign({}, extra || {}); const token = getStoredToken(); if (token && !headers.Authorization) { headers.Authorization = "Bearer " + token; } return headers; } async function apiGet(url) { const response = await fetch(url, { method: "GET", credentials: "same-origin", headers: authHeaders({ "Accept": "application/json" }) }); const payload = await response.json().catch(function () { return null; }); if (!response.ok || (payload && payload.error)) { throw new Error((payload && payload.error) || "Request failed"); } return payload ? payload.data : null; } async function apiPatch(url, body) { const response = await fetch(url, { method: "PATCH", credentials: "same-origin", headers: authHeaders({ "Accept": "application/json", "Content-Type": "application/json" }), body: JSON.stringify(body || {}) }); const payload = await response.json().catch(function () { return null; }); if (!response.ok || (payload && payload.error)) { throw new Error((payload && payload.error) || "Request failed"); } return payload ? payload.data : null; } async function apiDelete(url) { const response = await fetch(url, { method: "DELETE", credentials: "same-origin", headers: authHeaders({ "Accept": "application/json" }) }); const payload = await response.json().catch(function () { return null; }); if (!response.ok || (payload && payload.error)) { throw new Error((payload && payload.error) || "Request failed"); } return payload ? payload.data : null; } function escapeHtml(value) { return String(value == null ? "" : value) .replace(/&/g, "&") .replace(//g, ">") .replace(/\"/g, """) .replace(/'/g, "'"); } function formatDate(value) { if (!value) { return "-"; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return value; } return date.toLocaleString(); } function statusPill(status) { const value = status || "unknown"; const safe = escapeHtml(value); return '' + safe + ''; } function createModal() { if (document.getElementById("mineropsRepairHistoryModal")) { return; } const style = document.createElement("style"); style.textContent = ` #mineropsRepairHistoryModal { position: fixed; inset: 0; z-index: 999999; display: none; align-items: center; justify-content: center; padding: 18px; background: rgba(15, 23, 42, 0.48); font-family: Segoe UI, Arial, sans-serif; } #mineropsRepairHistoryModal .history-panel { width: min(980px, 100%); max-height: 88vh; overflow: auto; background: #ffffff; border: 1px solid #d9e2ec; border-radius: 14px; box-shadow: 0 18px 48px rgba(15, 23, 42, 0.32); color: #102a43; } #mineropsRepairHistoryModal .history-head { position: sticky; top: 0; z-index: 2; background: #ffffff; border-bottom: 1px solid #d9e2ec; padding: 14px; display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; } #mineropsRepairHistoryModal h2 { margin: 0; font-size: 22px; } #mineropsRepairHistoryModal .muted { color: #52606d; font-size: 13px; } #mineropsRepairHistoryModal .history-body { padding: 14px; } #mineropsRepairHistoryModal .history-metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin-bottom: 14px; } #mineropsRepairHistoryModal .history-card { border: 1px solid #d9e2ec; border-radius: 10px; padding: 10px; background: #f8fafc; } #mineropsRepairHistoryModal .history-card strong { display: block; margin-top: 2px; overflow-wrap: anywhere; } #mineropsRepairHistoryModal .history-event { border: 1px solid #d9e2ec; border-radius: 10px; padding: 10px; margin-bottom: 10px; background: #ffffff; } #mineropsRepairHistoryModal .history-event-title { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 4px; } #mineropsRepairHistoryModal .history-event-title strong { font-size: 15px; } #mineropsRepairHistoryModal .history-description { white-space: pre-wrap; margin-top: 6px; font-size: 13px; line-height: 1.35; } #mineropsRepairHistoryModal button { border: 1px solid #829ab1; border-radius: 8px; background: #f8fafc; padding: 8px 10px; font-weight: 700; cursor: pointer; } #mineropsRepairHistoryModal a { color: #0b63ce; font-weight: 700; text-decoration: none; } .minerops-history-pill { display: inline-block; border-radius: 999px; padding: 2px 7px; font-size: 11px; font-weight: 800; background: #e2e8f0; color: #334155; } .minerops-history-pill-open, .minerops-history-pill-diagnosing, .minerops-history-pill-waiting_parts, .minerops-history-pill-in_progress, .minerops-history-pill-testing, .minerops-history-pill-needs_followup { background: #fff7ed; color: #9a3412; } .minerops-history-pill-completed { background: #dcfce7; color: #166534; } .minerops-history-pill-failed, .minerops-history-pill-cancelled { background: #fee2e2; color: #991b1b; } `; document.head.appendChild(style); const modal = document.createElement("div"); modal.id = "mineropsRepairHistoryModal"; modal.innerHTML = ` `; document.body.appendChild(modal); document.getElementById("mineropsRepairHistoryClose").addEventListener("click", closeModal); modal.addEventListener("click", function (event) { if (event.target === modal) { closeModal(); } }); document.addEventListener("keydown", function (event) { if (event.key === "Escape") { closeModal(); } }); modal.addEventListener("click", async function (event) { const editButton = event.target.closest(".repair-history-edit-btn"); const deleteButton = event.target.closest(".repair-history-delete-btn"); if (!editButton && !deleteButton) { return; } event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); const button = editButton || deleteButton; const eventId = button.getAttribute("data-event-id") || ""; const minerId = window.mineropsRepairHistoryCurrentMinerId || ""; const eventMap = window.mineropsRepairHistoryEventById || {}; const historyEvent = eventMap[eventId] || {}; if (!eventId || !minerId) { alert("This history event is missing an event ID or miner ID. Refresh the page and try again."); return; } if (editButton) { const currentDescription = historyEvent.description || ""; const updatedDescription = prompt("Edit repair history note:", currentDescription); if (updatedDescription === null) { return; } try { button.disabled = true; button.textContent = "Saving..."; const response = await fetch( "/api/miners/" + encodeURIComponent(minerId) + "/maintenance-events/" + encodeURIComponent(eventId), { method: "PATCH", credentials: "same-origin", headers: authHeaders({ "Accept": "application/json", "Content-Type": "application/json" }), body: JSON.stringify({ description: updatedDescription }) } ); const payload = await response.json().catch(function () { return null; }); if (!response.ok || (payload && payload.error)) { throw new Error((payload && payload.error) || "Request failed"); } queueCache = null; queueCacheAt = 0; await openHistoryForMiner(minerId); } catch (error) { alert("Could not update history event: " + (error.message || error)); button.disabled = false; button.textContent = "Edit"; } return; } if (deleteButton) { const title = historyEvent.event_title || historyEvent.event_type || "history event"; if (!confirm("Delete this repair history event?\\n\\n" + title + "\\n\\nThis cannot be undone.")) { return; } try { button.disabled = true; button.textContent = "Deleting..."; const response = await fetch( "/api/miners/" + encodeURIComponent(minerId) + "/maintenance-events/" + encodeURIComponent(eventId), { method: "DELETE", credentials: "same-origin", headers: authHeaders({ "Accept": "application/json" }) } ); const payload = await response.json().catch(function () { return null; }); if (!response.ok || (payload && payload.error)) { throw new Error((payload && payload.error) || "Request failed"); } queueCache = null; queueCacheAt = 0; await openHistoryForMiner(minerId); } catch (error) { alert("Could not delete history event: " + (error.message || error)); button.disabled = false; button.textContent = "Delete"; } } }, true); } function openModalShell() { createModal(); document.getElementById("mineropsRepairHistoryModal").style.display = "flex"; document.getElementById("mineropsRepairHistoryBody").innerHTML = "Loading repair history..."; } function closeModal() { const modal = document.getElementById("mineropsRepairHistoryModal"); if (modal) { modal.style.display = "none"; } } async function loadQueue() { const now = Date.now(); if (queueCache && now - queueCacheAt < 15000) { return queueCache; } const data = await apiGet("/api/repairs/queue"); queueCache = data.items || []; queueCacheAt = now; return queueCache; } function normalizeEvents(data) { if (!data) { return []; } if (Array.isArray(data)) { return data; } if (Array.isArray(data.items)) { return data.items; } if (Array.isArray(data.events)) { return data.events; } return []; } function eventIsRepairRelated(event) { const eventType = String(event.event_type || "").toLowerCase(); const componentType = String(event.component_type || "").toLowerCase(); const title = String(event.event_title || "").toLowerCase(); return ( eventType === "repair" || eventType === "moved" || componentType === "repair" || componentType === "location" || title.indexOf("repair") !== -1 || title.indexOf("location") !== -1 || title.indexOf("moved") !== -1 ); } function renderHistory(item, events) { const body = document.getElementById("mineropsRepairHistoryBody"); const subtitle = document.getElementById("mineropsRepairHistorySubtitle"); const detailLink = document.getElementById("mineropsRepairHistoryDetailLink"); const repairEvents = events.filter(eventIsRepairRelated); subtitle.textContent = (item.current_mac || item.miner_id || "Miner") + " | " + (item.model || "-"); detailLink.href = "/miners/" + encodeURIComponent(item.miner_id) + "/detail"; const eventHtml = repairEvents.length ? repairEvents.map(function (event) { return `
    ${escapeHtml(event.event_title || event.event_type || "Event")} ${statusPill(event.event_status || "")} ${escapeHtml(event.event_type || "")}
    ${escapeHtml(formatDate(event.event_at || event.created_at || ""))}
    ${escapeHtml(event.description || "")}
    Component: ${escapeHtml(event.component_type || "-")} ${event.component_slot ? " | Slot: " + escapeHtml(event.component_slot) : ""}
    `; }).join("") : '
    No repair or location history found for this miner.
    '; body.innerHTML = `
    MAC
    ${escapeHtml(item.current_mac || "-")}
    Model
    ${escapeHtml(item.model || "-")}
    Miner SN
    ${escapeHtml(item.miner_sn || "-")}
    PSU SN
    ${escapeHtml(item.power_sn || "-")}
    Reported Problem
    ${escapeHtml(item.reported_error || "No reported error")}
    Latest Status
    ${escapeHtml(item.latest_event_status || "unknown")}
    Current repair note
    ${escapeHtml(item.state_notes || "No current state note.")}

    Repair and Location Timeline

    ${eventHtml} `; const eventById = {}; for (const event of repairEvents) { if (event.id) { eventById[event.id] = event; } } window.mineropsRepairHistoryCurrentMinerId = item.miner_id; window.mineropsRepairHistoryEventById = eventById; body.querySelectorAll(".repair-history-edit-btn").forEach(function (button) { button.addEventListener("click", async function () { const eventId = button.getAttribute("data-event-id"); const event = eventById[eventId]; if (!event) { alert("Could not find this history event."); return; } const currentDescription = event.description || ""; const updatedDescription = prompt("Edit repair history note:", currentDescription); if (updatedDescription === null) { return; } try { button.disabled = true; button.textContent = "Saving..."; await apiPatch( "/api/miners/" + encodeURIComponent(item.miner_id) + "/maintenance-events/" + encodeURIComponent(eventId), { description: updatedDescription } ); await openHistoryForMiner(item.miner_id); } catch (error) { alert("Could not update history event: " + (error.message || error)); button.disabled = false; button.textContent = "Edit"; } }); }); body.querySelectorAll(".repair-history-delete-btn").forEach(function (button) { button.addEventListener("click", async function () { const eventId = button.getAttribute("data-event-id"); const event = eventById[eventId]; if (!event) { alert("Could not find this history event."); return; } const title = event.event_title || event.event_type || "history event"; if (!confirm("Delete this repair history event?\\n\\n" + title + "\\n\\nThis cannot be undone.")) { return; } try { button.disabled = true; button.textContent = "Deleting..."; await apiDelete( "/api/miners/" + encodeURIComponent(item.miner_id) + "/maintenance-events/" + encodeURIComponent(eventId) ); queueCache = null; queueCacheAt = 0; await openHistoryForMiner(item.miner_id); } catch (error) { alert("Could not delete history event: " + (error.message || error)); button.disabled = false; button.textContent = "Delete"; } }); }); } async function openHistoryForMiner(minerId) { openModalShell(); try { const queue = await loadQueue(); const item = queue.find(function (row) { return row.miner_id === minerId; }); if (!item) { throw new Error("Miner is not currently in the Repair Queue."); } const eventData = await apiGet("/api/miners/" + encodeURIComponent(minerId) + "/maintenance-events"); const events = normalizeEvents(eventData); renderHistory(item, events); } catch (error) { document.getElementById("mineropsRepairHistorySubtitle").textContent = "Error"; document.getElementById("mineropsRepairHistoryBody").innerHTML = '
    Repair history failed to load: ' + escapeHtml(error.message || error) + '
    '; } } function addHistoryButtons() { document.querySelectorAll("#repairTableBody tr[data-miner-id]").forEach(function (row) { if (row.querySelector(".repair-history-btn")) { return; } const actions = row.querySelector(".row-actions"); if (!actions) { return; } const button = document.createElement("button"); button.type = "button"; button.className = "repair-history-btn"; button.textContent = "History"; button.addEventListener("click", function () { openHistoryForMiner(row.getAttribute("data-miner-id")); }); actions.insertBefore(button, actions.firstChild); }); } function installObserver() { const target = document.getElementById("repairTableBody"); if (!target) { return; } const observer = new MutationObserver(addHistoryButtons); observer.observe(target, { childList: true, subtree: true }); addHistoryButtons(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", function () { createModal(); installObserver(); }); } else { createModal(); installObserver(); } })(); """ @app.get("/assets/minerops-repair-history-modal.js") def minerops_repair_history_modal_asset(): return MinerOpsRepairHistoryResponse( content=MINEROPS_REPAIR_HISTORY_JS, media_type="application/javascript", ) @app.middleware("http") async def minerops_inject_repair_history_modal(request, call_next): response = await call_next(request) try: path = request.url.path or "" if path != "/repairs": return response content_type = response.headers.get("content-type", "") if "text/html" not in content_type.lower(): return response body = b"" async for chunk in response.body_iterator: body += chunk html = body.decode("utf-8", errors="ignore") script_tag = '' if script_tag not in html: if "" in html: html = html.replace("", script_tag + "") else: html += script_tag headers = dict(response.headers) headers.pop("content-length", None) return MinerOpsRepairHistoryResponse( content=html, status_code=response.status_code, headers=headers, media_type="text/html", ) except Exception: return response # MINEROPS_SYSTEM_HEALTH_RETENTION_V1 # MINEROPS_REPAIR_READY_TO_SHELF_STATUS_V1 # MINEROPS_REPAIR_STATUS_LABELS_V1 # MINEROPS_REPAIR_PARTS_USED_BASIC_V1 # MINEROPS_REPAIR_PARTS_QTY_V1 # MINEROPS_REPAIR_PARTS_SUMMARY_API_V1 @app.get("/api/repairs/parts-summary") def minerops_repair_parts_summary_api( current_user: dict = Depends(get_current_user), ): import re from collections import defaultdict ensure_maintenance_tables() conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT e.miner_id, e.description, e.event_at, e.created_at, m.current_mac, m.model FROM miner_maintenance_events e LEFT JOIN miners m ON m.id = e.miner_id WHERE e.description ILIKE '%%Parts used:%%' ORDER BY COALESCE(e.event_at, e.created_at) DESC; """ ) rows = [dict(row) for row in cur.fetchall()] part_totals = defaultdict(int) recent = [] pattern = re.compile( r"Parts used:\s*([A-Za-z0-9 _/\-]+?)(?:\s*\(Qty:\s*(\d+)\s*\))?(?:\n|$)", re.IGNORECASE, ) for row in rows: description = row.get("description") or "" for match in pattern.finditer(description): part = (match.group(1) or "Other").strip() qty = int(match.group(2) or "1") if not part: part = "Other" part_totals[part] += qty if len(recent) < 15: recent.append( { "part": part, "qty": qty, "current_mac": row.get("current_mac"), "model": row.get("model"), "miner_id": str(row.get("miner_id") or ""), "event_at": row.get("event_at") or row.get("created_at"), } ) totals = [ { "part": part, "qty": qty, } for part, qty in sorted( part_totals.items(), key=lambda item: (-item[1], item[0].lower()), ) ] return api_response( data={ "totals": totals, "recent": recent, "source_event_count": len(rows), } ) finally: if conn is not None: conn.close() # MINEROPS_REPAIR_PARTS_SUMMARY_UI_V2 # MINEROPS_REPAIR_HISTORY_EDIT_DELETE_V1 # MINEROPS_REPAIR_HISTORY_API_HELPERS_SCOPE_FIX_V1 # MINEROPS_REPAIR_HISTORY_DELEGATED_ACTIONS_V1 # Register UUID-based Repair routes after legacy static Repair paths. app.include_router(repairs_router) app.include_router(control_board_router)