from datetime import datetime, timedelta, timezone from typing import Any import jwt import psycopg import redis from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError from fastapi import Depends, FastAPI, Header, HTTPException, status from pydantic import BaseModel, EmailStr, Field from app.config import settings app = FastAPI(title=settings.app_name) password_hasher = PasswordHasher() 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}" class SetupAdminRequest(BaseModel): username: str = Field(min_length=3, max_length=64) display_name: str = Field(min_length=1, max_length=120) email: EmailStr | None = None password: str = Field(min_length=10, max_length=256) class LoginRequest(BaseModel): username: str password: str def create_access_token(user_id: str, username: str, role: str) -> str: now = datetime.now(timezone.utc) payload = { "sub": user_id, "username": username, "role": role, "iat": int(now.timestamp()), "exp": int((now + timedelta(minutes=settings.jwt_access_token_minutes)).timestamp()), "type": "access", } return jwt.encode(payload, settings.jwt_secret_key, algorithm="HS256") def decode_access_token(token: str) -> dict: try: payload = jwt.decode(token, settings.jwt_secret_key, algorithms=["HS256"]) if payload.get("type") != "access": raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type") return payload except jwt.ExpiredSignatureError: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") except jwt.InvalidTokenError: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") def get_bearer_token(authorization: str | None = Header(default=None)) -> str: if not authorization or not authorization.lower().startswith("bearer "): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token") return authorization.split(" ", 1)[1].strip() def get_current_user(token: str = Depends(get_bearer_token)) -> dict: payload = decode_access_token(token) user_id = payload["sub"] conn = None try: conn = get_db_connection() with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT users.id::text AS id, users.username, users.display_name, users.email, users.is_active, roles.name AS role FROM users JOIN roles ON roles.id = users.role_id WHERE users.id = %s; """, (user_id,), ) user = cur.fetchone() if not user or not user["is_active"]: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive") return dict(user) finally: if conn is not None: conn.close() @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, } ) @app.get("/api/auth/setup-status") def setup_status(): conn = None try: conn = get_db_connection() with conn.cursor() as cur: cur.execute("SELECT COUNT(*) FROM users;") user_count = cur.fetchone()[0] return api_response( data={ "setup_required": user_count == 0, "user_count": user_count, } ) finally: if conn is not None: conn.close() @app.post("/api/auth/setup-admin") def setup_admin(request: SetupAdminRequest): conn = None try: conn = get_db_connection() conn.autocommit = False with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute("SELECT COUNT(*) AS user_count FROM users;") user_count = cur.fetchone()["user_count"] if user_count > 0: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Initial admin setup has already been completed", ) cur.execute("SELECT id FROM roles WHERE name = 'Admin';") admin_role = cur.fetchone() if not admin_role: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Admin role is missing", ) password_hash = password_hasher.hash(request.password) cur.execute( """ INSERT INTO users (username, display_name, email, password_hash, role_id) VALUES (%s, %s, %s, %s, %s) RETURNING id::text, username, display_name, email; """, ( request.username.strip(), request.display_name.strip(), request.email, password_hash, admin_role["id"], ), ) user = cur.fetchone() cur.execute( """ INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json) VALUES (%s, 'user.created', 'user', %s, %s); """, ( user["id"], user["id"], psycopg.types.json.Jsonb( { "username": user["username"], "display_name": user["display_name"], "role": "Admin", "source": "setup-admin", } ), ), ) conn.commit() return api_response( data={ "user_id": user["id"], "username": user["username"], "display_name": user["display_name"], } ) 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/auth/login") def login(request: LoginRequest): conn = None try: conn = get_db_connection() conn.autocommit = False with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: cur.execute( """ SELECT users.id::text AS id, users.username, users.display_name, users.email, users.password_hash, users.is_active, roles.name AS role FROM users JOIN roles ON roles.id = users.role_id WHERE lower(users.username) = lower(%s); """, (request.username.strip(),), ) user = cur.fetchone() if not user or not user["is_active"]: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password") try: password_hasher.verify(user["password_hash"], request.password) except VerifyMismatchError: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password") access_token = create_access_token(user["id"], user["username"], user["role"]) cur.execute( "UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = %s;", (user["id"],), ) cur.execute( """ INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json) VALUES (%s, 'user.login', 'user', %s, %s); """, ( user["id"], user["id"], psycopg.types.json.Jsonb({"username": user["username"]}), ), ) conn.commit() return api_response( data={ "access_token": access_token, "token_type": "bearer", "expires_in": settings.jwt_access_token_minutes * 60, "user": { "id": user["id"], "username": user["username"], "display_name": user["display_name"], "email": user["email"], "role": user["role"], }, } ) 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/auth/me") def me(current_user: dict = Depends(get_current_user)): return api_response(data=current_user) 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(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, 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(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, 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, 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() 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 @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.
""" @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 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, 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"; 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() { const token = getToken(); if (!token) { return null; } try { const response = await fetch("/api/auth/me", { headers: authHeaders() }); if (!response.ok) { return null; } return await response.json(); } 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."; } } function logoutFromHeader() { clearToken(); window.location.reload(); } 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 `; 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""" (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 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 minerId = minerIdFromPage(); if (!minerId) { setMinerControlStatus("Could not detect miner ID from page.", "bad"); return; } const token = getToken(); if (!token) { setMinerControlStatus("Log in first.", "bad"); return; } const label = dryRun ? "dry-run reboot" : "Reboot"; setMinerControlStatus("Sending " + label + " request...", "warn"); try { const data = await fetchJson("/api/miners/" + encodeURIComponent(minerId) + "/commands/reboot", { method: "POST", headers: authHeaders(), body: JSON.stringify({ dry_run: dryRun, confirm_text: confirmText || "" }) }); if (dryRun) { setMinerControlStatus( "Dry run logged. No reboot was sent. Target: " + (data.target_ip || "unknown IP") + " / " + (data.target_mac || "unknown MAC"), "ok" ); } else { setMinerControlStatus( "Reboot command sent to " + (data.target_ip || "unknown IP") + ". Watch miner status for reconnect.", "ok" ); } await loadMinerCommandHistory(); } catch (error) { setMinerControlStatus(label + " error: " + error.message, "bad"); await loadMinerCommandHistory(); } } 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(endpointName, label) { const minerId = minerIdFromPage(); if (!minerId) { setMinerControlStatus("Could not detect miner ID from page.", "bad"); return; } const token = getToken(); if (!token) { setMinerControlStatus("Log in first.", "bad"); return; } setMinerControlStatus("Sending " + label + " command...", "warn"); try { const data = await fetchJson("/api/miners/" + encodeURIComponent(minerId) + "/commands/" + endpointName, { method: "POST", headers: authHeaders(), body: JSON.stringify({ dry_run: false, confirm_text: "CONFIRM" }) }); const commandName = data.whatsminer_command || endpointName; setMinerControlStatus( label + " command sent to " + (data.target_ip || "unknown IP") + " using: " + commandName + ".", "ok" ); await loadMinerCommandHistory(); } catch (error) { setMinerControlStatus(label + " error: " + error.message, "bad"); await loadMinerCommandHistory(); } } 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(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", setupMinerControlCard); } else { setupMinerControlCard(); } setTimeout(setupMinerControlCard, 600); window.dryRunMinerReboot = dryRunMinerReboot; window.realRebootMiner = realRebootMiner; 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.
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.
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

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 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 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)): return api_response(data=SCHEDULED_SCANNER_STATE) @app.post("/api/scanner/run-known-now") def scanner_run_known_now(current_user: dict = Depends(get_current_user)): 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): new_mac: str old_mac: str | None = None new_ip: str | None = None event_title: str | None = None description: str | None = None component_serial: str | None = None downtime_minutes: int | None = None def normalize_mac_display(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 str(mac).strip().upper() return ":".join(cleaned[i:i + 2] for i in range(0, 12, 2)).upper() def normalize_mac_key_for_change(mac: str | None): if mac is None: return None return "".join(ch for ch in str(mac).strip().lower() if ch in "0123456789abcdef") @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), ): ensure_maintenance_tables() new_mac = normalize_mac_display(request.new_mac) new_mac_key = normalize_mac_key_for_change(new_mac) if not new_mac_key or len(new_mac_key) != 12: raise HTTPException(status_code=400, detail="New MAC address is invalid") conn = None try: conn = get_db_connection() 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, current_mac, host(current_ip) AS current_ip, hostname FROM miners WHERE id = %s::uuid; """, (miner_id,), ) miner = cur.fetchone() if not miner: raise HTTPException(status_code=404, detail="Miner not found") miner = dict(miner) old_mac = normalize_mac_display(request.old_mac or miner.get("current_mac")) old_ip = miner.get("current_ip") new_ip = request.new_ip or old_ip if normalize_mac_key_for_change(old_mac) == new_mac_key: raise HTTPException(status_code=400, detail="New MAC is the same as the current/old MAC") cur.execute( """ UPDATE miners SET current_mac = %s, current_ip = COALESCE(NULLIF(%s, '')::inet, current_ip) WHERE id = %s::uuid RETURNING id::text, current_mac, host(current_ip) AS current_ip; """, ( new_mac, new_ip, miner_id, ), ) updated_miner = dict(cur.fetchone()) # Mark all known identities for this miner as not current. cur.execute( """ UPDATE miner_network_identities SET is_current = false WHERE miner_id = %s::uuid; """, (miner_id,), ) # If this MAC already exists in the identity history, mark it current. cur.execute( """ UPDATE miner_network_identities SET is_current = true, ip_address = COALESCE(NULLIF(%s, '')::inet, ip_address), hostname = COALESCE(NULLIF(%s, ''), hostname), last_seen_at = now() WHERE miner_id = %s::uuid AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') = %s RETURNING id::text; """, ( new_ip, miner.get("hostname"), miner_id, new_mac_key, ), ) existing_identity = cur.fetchone() if not existing_identity: cur.execute( """ INSERT INTO miner_network_identities ( id, miner_id, mac_address, ip_address, hostname, first_seen_at, last_seen_at, is_current ) VALUES ( %s::uuid, %s::uuid, %s, NULLIF(%s, '')::inet, %s, now(), now(), true ); """, ( str(uuid4()), miner_id, new_mac, new_ip, miner.get("hostname"), ), ) event_id = str(uuid4()) event_title = request.event_title or "Control board / MAC address changed" description_parts = [] if request.description: description_parts.append(request.description) description_parts.append(f"MAC changed from {old_mac or 'unknown'} to {new_mac}.") if old_ip or new_ip: description_parts.append(f"IP changed/tracked from {old_ip or 'unknown'} to {new_ip or 'unknown'}.") event_description = " ".join(description_parts) 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, downtime_minutes, created_by_user_id ) VALUES ( %s::uuid, %s::uuid, 'control_board_changed', 'completed', %s, %s, now(), %s, %s, NULLIF(%s, '')::inet, NULLIF(%s, '')::inet, 'control_board', 'control_board', %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, downtime_minutes, created_by_user_id::text, created_at, updated_at; """, ( event_id, miner_id, event_title, event_description, old_mac, new_mac, old_ip, new_ip, request.component_serial, request.downtime_minutes, user_id, ), ) maintenance_event = dict(cur.fetchone()) conn.commit() return api_response( data={ "miner": updated_miner, "maintenance_event": maintenance_event, } ) except Exception: if conn is not None: conn.rollback() raise finally: if conn is not None: conn.close() @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() @app.get("/api/location/map") def get_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 "" if not auth_header.lower().startswith("bearer "): raise HTTPException(status_code=401, detail="Login token 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", )