Files
2026-08-19 20:00:52 +00:00

35518 lines
1.1 MiB
Plaintext

from datetime import datetime, timedelta, timezone
from typing import Any
from uuid import UUID
import jwt
import psycopg
import redis
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import Request, Depends, FastAPI, Header, HTTPException, Query, status
from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError
from app.config import settings
from app.dashboard.router import router as dashboard_router
from app.identity import (
COOKIE_TOKEN_HINT,
SESSION_COOKIE,
get_current_user,
install_identity_middleware,
require_permission,
user_has_permission,
router as identity_router,
)
from app.frontend.router import (
dashboard_page as internal_alpha_dashboard_page,
miner_detail_page as internal_alpha_miner_detail_page,
router as frontend_router,
)
from app.inventory.router import router as inventory_router
from app.locations.router import router as locations_router
from app.components.router import router as components_router
from app.miner_detail.router import router as miner_detail_router
from app.monitoring.router import router as monitoring_router
from app.monitoring.frontend import router as monitoring_frontend_router
from app.repairs.exceptions import (
ControlBoardReplacementActorRequired,
InvalidMacAddress,
RepairConflict,
RepairMinerNotFound,
)
from app.repairs.models import (
RepairCenterAccess,
RepairCenterFilters,
RepairCenterScope,
RepairMinerLifecycle,
ControlBoardReplacementReason,
ControlBoardReplacementRequest,
RemovedBoardDisposition,
)
from app.repairs.repository import RepairRepository
from app.repairs.center_repository import RepairCenterRepository
from app.repairs.center_service import RepairCenterService
from app.repairs.service import RepairService
from app.repairs.router import (
control_board_router,
router as repairs_router,
)
app = FastAPI(title=settings.app_name)
app.include_router(identity_router)
app.include_router(frontend_router)
app.include_router(inventory_router)
app.include_router(components_router)
app.include_router(locations_router)
app.include_router(dashboard_router)
app.include_router(miner_detail_router)
app.include_router(monitoring_frontend_router)
app.include_router(monitoring_router)
install_identity_middleware(app)
def api_response(data: Any = None, meta: dict | None = None, error: Any = None) -> dict:
return {
"data": data,
"meta": meta or {},
"error": error,
}
def json_safe(value: Any) -> Any:
if isinstance(value, dict):
return {key: json_safe(item) for key, item in value.items()}
if isinstance(value, list):
return [json_safe(item) for item in value]
if isinstance(value, datetime):
return value.isoformat()
return value
def get_db_connection():
return psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
connect_timeout=5,
)
def check_database() -> str:
conn = None
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute("SELECT 1;")
result = cur.fetchone()
if result and result[0] == 1:
return "ok"
return "error"
except Exception as exc:
return f"error: {exc}"
finally:
if conn is not None:
conn.close()
def check_redis() -> str:
try:
client = redis.Redis.from_url(settings.redis_url, socket_connect_timeout=5)
if client.ping():
return "ok"
return "error"
except Exception as exc:
return f"error: {exc}"
@app.get("/api/health")
def health_check():
database_status = check_database()
redis_status = check_redis()
overall_status = "ok"
if database_status != "ok" or redis_status != "ok":
overall_status = "degraded"
return api_response(
data={
"status": overall_status,
"app": settings.app_name,
"environment": settings.app_env,
"database": database_status,
"redis": redis_status,
}
)
class CreateRoomRequest(BaseModel):
site_id: str | None = None
name: str = Field(min_length=1, max_length=120)
description: str | None = None
sort_order: int = 0
class UpdateRoomRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
description: str | None = None
sort_order: int | None = None
@app.get("/api/sites")
def list_sites(current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT id::text, name, description, created_at, updated_at
FROM sites
ORDER BY name;
"""
)
sites = [dict(row) for row in cur.fetchall()]
return api_response(data=sites)
finally:
if conn is not None:
conn.close()
@app.get("/api/rooms")
def list_rooms(current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
rooms.id::text,
rooms.site_id::text,
sites.name AS site_name,
rooms.name,
rooms.description,
rooms.sort_order,
rooms.created_at,
rooms.updated_at
FROM rooms
JOIN sites ON sites.id = rooms.site_id
ORDER BY rooms.sort_order, rooms.name;
"""
)
rooms = [dict(row) for row in cur.fetchall()]
return api_response(data=rooms)
finally:
if conn is not None:
conn.close()
@app.post("/api/rooms")
def create_room(request: CreateRoomRequest, current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
site_id = request.site_id
if site_id is None:
cur.execute("SELECT id::text FROM sites ORDER BY name LIMIT 1;")
site = cur.fetchone()
if not site:
raise HTTPException(status_code=500, detail="No site exists")
site_id = site["id"]
cur.execute(
"""
INSERT INTO rooms (site_id, name, description, sort_order)
VALUES (%s, %s, %s, %s)
RETURNING id::text, site_id::text, name, description, sort_order, created_at, updated_at;
""",
(
site_id,
request.name.strip(),
request.description,
request.sort_order,
),
)
room = cur.fetchone()
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
VALUES (%s, 'room.created', 'room', %s, %s);
""",
(
current_user["id"],
room["id"],
psycopg.types.json.Jsonb(
{
"name": room["name"],
"site_id": room["site_id"],
"sort_order": room["sort_order"],
}
),
),
)
conn.commit()
return api_response(data=dict(room))
except HTTPException:
if conn is not None:
conn.rollback()
raise
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
@app.patch("/api/rooms/{room_id}")
def update_room(room_id: str, request: UpdateRoomRequest, current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT id::text, name, description, sort_order
FROM rooms
WHERE id = %s;
""",
(room_id,),
)
old_room = cur.fetchone()
if not old_room:
raise HTTPException(status_code=404, detail="Room not found")
new_name = request.name.strip() if request.name is not None else old_room["name"]
new_description = request.description if request.description is not None else old_room["description"]
new_sort_order = request.sort_order if request.sort_order is not None else old_room["sort_order"]
cur.execute(
"""
UPDATE rooms
SET name = %s,
description = %s,
sort_order = %s,
updated_at = now()
WHERE id = %s
RETURNING id::text, site_id::text, name, description, sort_order, created_at, updated_at;
""",
(new_name, new_description, new_sort_order, room_id),
)
room = cur.fetchone()
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json, new_value_json)
VALUES (%s, 'room.updated', 'room', %s, %s, %s);
""",
(
current_user["id"],
room["id"],
psycopg.types.json.Jsonb(json_safe(dict(old_room))),
psycopg.types.json.Jsonb(json_safe(dict(room))),
),
)
conn.commit()
return api_response(data=dict(room))
except HTTPException:
if conn is not None:
conn.rollback()
raise
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
@app.delete("/api/rooms/{room_id}")
def delete_room(room_id: str, current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT id::text, name, description, sort_order
FROM rooms
WHERE id = %s;
""",
(room_id,),
)
room = cur.fetchone()
if not room:
raise HTTPException(status_code=404, detail="Room not found")
cur.execute("DELETE FROM rooms WHERE id = %s;", (room_id,))
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json)
VALUES (%s, 'room.deleted', 'room', %s, %s);
""",
(
current_user["id"],
room["id"],
psycopg.types.json.Jsonb(json_safe(dict(room))),
),
)
conn.commit()
return api_response(data={"deleted": True, "room_id": room_id})
except HTTPException:
if conn is not None:
conn.rollback()
raise
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
class CreateRoomRequest(BaseModel):
site_id: str | None = None
name: str = Field(min_length=1, max_length=120)
description: str | None = None
sort_order: int = 0
class UpdateRoomRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
description: str | None = None
sort_order: int | None = None
@app.get("/api/sites")
def list_sites(current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT id::text, name, description, created_at, updated_at
FROM sites
ORDER BY name;
"""
)
sites = [dict(row) for row in cur.fetchall()]
return api_response(data=sites)
finally:
if conn is not None:
conn.close()
@app.get("/api/rooms")
def list_rooms(current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
rooms.id::text,
rooms.site_id::text,
sites.name AS site_name,
rooms.name,
rooms.description,
rooms.sort_order,
rooms.created_at,
rooms.updated_at
FROM rooms
JOIN sites ON sites.id = rooms.site_id
ORDER BY rooms.sort_order, rooms.name;
"""
)
rooms = [dict(row) for row in cur.fetchall()]
return api_response(data=rooms)
finally:
if conn is not None:
conn.close()
@app.post("/api/rooms")
def create_room(request: CreateRoomRequest, current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
site_id = request.site_id
if site_id is None:
cur.execute("SELECT id::text FROM sites ORDER BY name LIMIT 1;")
site = cur.fetchone()
if not site:
raise HTTPException(status_code=500, detail="No site exists")
site_id = site["id"]
cur.execute(
"""
INSERT INTO rooms (site_id, name, description, sort_order)
VALUES (%s, %s, %s, %s)
RETURNING id::text, site_id::text, name, description, sort_order, created_at, updated_at;
""",
(
site_id,
request.name.strip(),
request.description,
request.sort_order,
),
)
room = cur.fetchone()
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
VALUES (%s, 'room.created', 'room', %s, %s);
""",
(
current_user["id"],
room["id"],
psycopg.types.json.Jsonb(
{
"name": room["name"],
"site_id": room["site_id"],
"sort_order": room["sort_order"],
}
),
),
)
conn.commit()
return api_response(data=dict(room))
except HTTPException:
if conn is not None:
conn.rollback()
raise
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
@app.patch("/api/rooms/{room_id}")
def update_room(room_id: str, request: UpdateRoomRequest, current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT id::text, name, description, sort_order
FROM rooms
WHERE id = %s;
""",
(room_id,),
)
old_room = cur.fetchone()
if not old_room:
raise HTTPException(status_code=404, detail="Room not found")
new_name = request.name.strip() if request.name is not None else old_room["name"]
new_description = request.description if request.description is not None else old_room["description"]
new_sort_order = request.sort_order if request.sort_order is not None else old_room["sort_order"]
cur.execute(
"""
UPDATE rooms
SET name = %s,
description = %s,
sort_order = %s,
updated_at = now()
WHERE id = %s
RETURNING id::text, site_id::text, name, description, sort_order, created_at, updated_at;
""",
(new_name, new_description, new_sort_order, room_id),
)
room = cur.fetchone()
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json, new_value_json)
VALUES (%s, 'room.updated', 'room', %s, %s, %s);
""",
(
current_user["id"],
room["id"],
psycopg.types.json.Jsonb(json_safe(dict(old_room))),
psycopg.types.json.Jsonb(json_safe(dict(room))),
),
)
conn.commit()
return api_response(data=dict(room))
except HTTPException:
if conn is not None:
conn.rollback()
raise
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
@app.delete("/api/rooms/{room_id}")
def delete_room(room_id: str, current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT id::text, name, description, sort_order
FROM rooms
WHERE id = %s;
""",
(room_id,),
)
room = cur.fetchone()
if not room:
raise HTTPException(status_code=404, detail="Room not found")
cur.execute("DELETE FROM rooms WHERE id = %s;", (room_id,))
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json)
VALUES (%s, 'room.deleted', 'room', %s, %s);
""",
(
current_user["id"],
room["id"],
psycopg.types.json.Jsonb(json_safe(dict(room))),
),
)
conn.commit()
return api_response(data={"deleted": True, "room_id": room_id})
except HTTPException:
if conn is not None:
conn.rollback()
raise
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
import ipaddress
class CreateScanRangeRequest(BaseModel):
room_id: str | None = None
name: str = Field(min_length=1, max_length=120)
range_type: str = "cidr"
cidr: str | None = None
start_ip: str | None = None
end_ip: str | None = None
enabled: bool = True
scan_interval_seconds: int = Field(default=900, ge=60, le=86400)
timeout_ms: int = Field(default=3000, ge=500, le=30000)
retry_limit: int = Field(default=1, ge=0, le=5)
parallelism_limit: int = Field(default=10, ge=1, le=100)
stagger_ms: int = Field(default=250, ge=0, le=10000)
api_port: int = Field(default=4028, ge=1, le=65535)
class UpdateScanRangeRequest(BaseModel):
room_id: str | None = None
name: str | None = Field(default=None, min_length=1, max_length=120)
range_type: str | None = None
cidr: str | None = None
start_ip: str | None = None
end_ip: str | None = None
enabled: bool | None = None
scan_interval_seconds: int | None = Field(default=None, ge=60, le=86400)
timeout_ms: int | None = Field(default=None, ge=500, le=30000)
retry_limit: int | None = Field(default=None, ge=0, le=5)
parallelism_limit: int | None = Field(default=None, ge=1, le=100)
stagger_ms: int | None = Field(default=None, ge=0, le=10000)
api_port: int | None = Field(default=None, ge=1, le=65535)
def validate_scan_range_input(range_type: str, cidr: str | None, start_ip: str | None, end_ip: str | None) -> None:
if range_type not in ("cidr", "start_end"):
raise HTTPException(status_code=422, detail="range_type must be 'cidr' or 'start_end'")
if range_type == "cidr":
if not cidr:
raise HTTPException(status_code=422, detail="cidr is required when range_type is cidr")
try:
ipaddress.ip_network(cidr, strict=False)
except ValueError as exc:
raise HTTPException(status_code=422, detail=f"Invalid CIDR range: {exc}")
if range_type == "start_end":
if not start_ip or not end_ip:
raise HTTPException(status_code=422, detail="start_ip and end_ip are required when range_type is start_end")
try:
start = ipaddress.ip_address(start_ip)
end = ipaddress.ip_address(end_ip)
except ValueError as exc:
raise HTTPException(status_code=422, detail=f"Invalid IP address: {exc}")
if start.version != end.version:
raise HTTPException(status_code=422, detail="start_ip and end_ip must use the same IP version")
if int(start) > int(end):
raise HTTPException(status_code=422, detail="start_ip must be lower than or equal to end_ip")
@app.get("/api/scan-ranges")
def list_scan_ranges(current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
scan_ranges.id::text,
scan_ranges.room_id::text,
rooms.name AS room_name,
scan_ranges.name,
scan_ranges.range_type,
scan_ranges.cidr,
host(scan_ranges.start_ip) AS start_ip,
host(scan_ranges.end_ip) AS end_ip,
scan_ranges.enabled,
scan_ranges.scan_interval_seconds,
scan_ranges.timeout_ms,
scan_ranges.retry_limit,
scan_ranges.parallelism_limit,
scan_ranges.stagger_ms,
scan_ranges.api_port,
scan_ranges.last_scan_at,
scan_ranges.next_scan_at,
scan_ranges.created_at,
scan_ranges.updated_at
FROM scan_ranges
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
ORDER BY rooms.sort_order, scan_ranges.name;
"""
)
ranges = [dict(row) for row in cur.fetchall()]
return api_response(data=ranges)
finally:
if conn is not None:
conn.close()
@app.post("/api/scan-ranges")
def create_scan_range(request: CreateScanRangeRequest, current_user: dict = Depends(get_current_user)):
validate_scan_range_input(request.range_type, request.cidr, request.start_ip, request.end_ip)
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
INSERT INTO scan_ranges (
room_id,
name,
range_type,
cidr,
start_ip,
end_ip,
enabled,
scan_interval_seconds,
timeout_ms,
retry_limit,
parallelism_limit,
stagger_ms,
api_port,
created_by_user_id,
next_scan_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, now())
RETURNING
id::text,
room_id::text,
name,
range_type,
cidr,
host(start_ip) AS start_ip,
host(end_ip) AS end_ip,
enabled,
scan_interval_seconds,
timeout_ms,
retry_limit,
parallelism_limit,
stagger_ms,
api_port,
last_scan_at,
next_scan_at,
created_at,
updated_at;
""",
(
request.room_id,
request.name.strip(),
request.range_type,
request.cidr,
request.start_ip,
request.end_ip,
request.enabled,
request.scan_interval_seconds,
request.timeout_ms,
request.retry_limit,
request.parallelism_limit,
request.stagger_ms,
request.api_port,
current_user["id"],
),
)
scan_range = cur.fetchone()
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
VALUES (%s, 'scan_range.created', 'scan_range', %s, %s);
""",
(
current_user["id"],
scan_range["id"],
psycopg.types.json.Jsonb(json_safe(dict(scan_range))),
),
)
conn.commit()
return api_response(data=dict(scan_range))
except HTTPException:
if conn is not None:
conn.rollback()
raise
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
@app.patch("/api/scan-ranges/{scan_range_id}")
def update_scan_range(scan_range_id: str, request: UpdateScanRangeRequest, current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
id::text,
room_id::text,
name,
range_type,
cidr,
host(start_ip) AS start_ip,
host(end_ip) AS end_ip,
enabled,
scan_interval_seconds,
timeout_ms,
retry_limit,
parallelism_limit,
stagger_ms,
api_port
FROM scan_ranges
WHERE id = %s;
""",
(scan_range_id,),
)
old_range = cur.fetchone()
if not old_range:
raise HTTPException(status_code=404, detail="Scan range not found")
new_room_id = request.room_id if request.room_id is not None else old_range["room_id"]
new_name = request.name.strip() if request.name is not None else old_range["name"]
new_range_type = request.range_type if request.range_type is not None else old_range["range_type"]
new_cidr = request.cidr if request.cidr is not None else old_range["cidr"]
new_start_ip = request.start_ip if request.start_ip is not None else old_range["start_ip"]
new_end_ip = request.end_ip if request.end_ip is not None else old_range["end_ip"]
new_enabled = request.enabled if request.enabled is not None else old_range["enabled"]
new_scan_interval_seconds = request.scan_interval_seconds if request.scan_interval_seconds is not None else old_range["scan_interval_seconds"]
new_timeout_ms = request.timeout_ms if request.timeout_ms is not None else old_range["timeout_ms"]
new_retry_limit = request.retry_limit if request.retry_limit is not None else old_range["retry_limit"]
new_parallelism_limit = request.parallelism_limit if request.parallelism_limit is not None else old_range["parallelism_limit"]
new_stagger_ms = request.stagger_ms if request.stagger_ms is not None else old_range["stagger_ms"]
new_api_port = request.api_port if request.api_port is not None else old_range["api_port"]
validate_scan_range_input(new_range_type, new_cidr, new_start_ip, new_end_ip)
cur.execute(
"""
UPDATE scan_ranges
SET room_id = %s,
name = %s,
range_type = %s,
cidr = %s,
start_ip = %s,
end_ip = %s,
enabled = %s,
scan_interval_seconds = %s,
timeout_ms = %s,
retry_limit = %s,
parallelism_limit = %s,
stagger_ms = %s,
api_port = %s,
updated_at = now()
WHERE id = %s
RETURNING
id::text,
room_id::text,
name,
range_type,
cidr,
host(start_ip) AS start_ip,
host(end_ip) AS end_ip,
enabled,
scan_interval_seconds,
timeout_ms,
retry_limit,
parallelism_limit,
stagger_ms,
api_port,
last_scan_at,
next_scan_at,
created_at,
updated_at;
""",
(
new_room_id,
new_name,
new_range_type,
new_cidr,
new_start_ip,
new_end_ip,
new_enabled,
new_scan_interval_seconds,
new_timeout_ms,
new_retry_limit,
new_parallelism_limit,
new_stagger_ms,
new_api_port,
scan_range_id,
),
)
scan_range = cur.fetchone()
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json, new_value_json)
VALUES (%s, 'scan_range.updated', 'scan_range', %s, %s, %s);
""",
(
current_user["id"],
scan_range["id"],
psycopg.types.json.Jsonb(json_safe(dict(old_range))),
psycopg.types.json.Jsonb(json_safe(dict(scan_range))),
),
)
conn.commit()
return api_response(data=dict(scan_range))
except HTTPException:
if conn is not None:
conn.rollback()
raise
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
@app.delete("/api/scan-ranges/{scan_range_id}")
def delete_scan_range(scan_range_id: str, current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT id::text, name, range_type, cidr, host(start_ip) AS start_ip, end_ip::text
FROM scan_ranges
WHERE id = %s;
""",
(scan_range_id,),
)
old_range = cur.fetchone()
if not old_range:
raise HTTPException(status_code=404, detail="Scan range not found")
cur.execute("DELETE FROM scan_ranges WHERE id = %s;", (scan_range_id,))
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json)
VALUES (%s, 'scan_range.deleted', 'scan_range', %s, %s);
""",
(
current_user["id"],
old_range["id"],
psycopg.types.json.Jsonb(json_safe(dict(old_range))),
),
)
conn.commit()
return api_response(data={"deleted": True, "scan_range_id": scan_range_id})
except HTTPException:
if conn is not None:
conn.rollback()
raise
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
@app.get("/api/scan-runs")
def list_scan_runs(current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
scan_runs.id::text,
scan_runs.scan_range_id::text,
scan_ranges.name AS scan_range_name,
rooms.name AS room_name,
scan_runs.triggered_by,
scan_runs.triggered_by_user_id::text,
scan_runs.started_at,
scan_runs.finished_at,
scan_runs.status,
scan_runs.total_ips,
scan_runs.scanned_ips,
scan_runs.miners_found,
scan_runs.errors_count,
scan_runs.notes
FROM scan_runs
LEFT JOIN scan_ranges ON scan_ranges.id = scan_runs.scan_range_id
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
ORDER BY scan_runs.started_at DESC
LIMIT 100;
"""
)
scan_runs = [dict(row) for row in cur.fetchall()]
return api_response(data=scan_runs)
finally:
if conn is not None:
conn.close()
@app.get("/api/scan-runs/{scan_run_id}")
def get_scan_run(scan_run_id: str, current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
scan_runs.id::text,
scan_runs.scan_range_id::text,
scan_ranges.name AS scan_range_name,
rooms.name AS room_name,
scan_runs.triggered_by,
scan_runs.triggered_by_user_id::text,
scan_runs.started_at,
scan_runs.finished_at,
scan_runs.status,
scan_runs.total_ips,
scan_runs.scanned_ips,
scan_runs.miners_found,
scan_runs.errors_count,
scan_runs.notes
FROM scan_runs
LEFT JOIN scan_ranges ON scan_ranges.id = scan_runs.scan_range_id
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
WHERE scan_runs.id = %s;
""",
(scan_run_id,),
)
scan_run = cur.fetchone()
if not scan_run:
raise HTTPException(status_code=404, detail="Scan run not found")
return api_response(data=dict(scan_run))
finally:
if conn is not None:
conn.close()
@app.get("/api/scan-runs/{scan_run_id}/results")
def list_scan_run_results(scan_run_id: str, current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
id::text,
scan_run_id::text,
host(ip_address) AS ip_address,
port,
detected,
mac_address,
response_ms,
status,
error_message,
raw_summary_json,
raw_version_json,
raw_pools_json,
raw_error_json,
created_at
FROM scan_results
WHERE scan_run_id = %s
ORDER BY ip_address
LIMIT 2000;
""",
(scan_run_id,),
)
results = [dict(row) for row in cur.fetchall()]
return api_response(data=results)
finally:
if conn is not None:
conn.close()
@app.get("/api/scan-runs")
def list_scan_runs(current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
scan_runs.id::text,
scan_runs.scan_range_id::text,
scan_ranges.name AS scan_range_name,
rooms.name AS room_name,
scan_runs.triggered_by,
scan_runs.triggered_by_user_id::text,
scan_runs.started_at,
scan_runs.finished_at,
scan_runs.status,
scan_runs.total_ips,
scan_runs.scanned_ips,
scan_runs.miners_found,
scan_runs.errors_count,
scan_runs.notes
FROM scan_runs
LEFT JOIN scan_ranges ON scan_ranges.id = scan_runs.scan_range_id
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
ORDER BY scan_runs.started_at DESC
LIMIT 100;
"""
)
scan_runs = [dict(row) for row in cur.fetchall()]
return api_response(data=scan_runs)
finally:
if conn is not None:
conn.close()
@app.get("/api/scan-runs/{scan_run_id}")
def get_scan_run(scan_run_id: str, current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
scan_runs.id::text,
scan_runs.scan_range_id::text,
scan_ranges.name AS scan_range_name,
rooms.name AS room_name,
scan_runs.triggered_by,
scan_runs.triggered_by_user_id::text,
scan_runs.started_at,
scan_runs.finished_at,
scan_runs.status,
scan_runs.total_ips,
scan_runs.scanned_ips,
scan_runs.miners_found,
scan_runs.errors_count,
scan_runs.notes
FROM scan_runs
LEFT JOIN scan_ranges ON scan_ranges.id = scan_runs.scan_range_id
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
WHERE scan_runs.id = %s;
""",
(scan_run_id,),
)
scan_run = cur.fetchone()
if not scan_run:
raise HTTPException(status_code=404, detail="Scan run not found")
return api_response(data=dict(scan_run))
finally:
if conn is not None:
conn.close()
@app.get("/api/scan-runs/{scan_run_id}/results")
def list_scan_run_results(scan_run_id: str, current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
id::text,
scan_run_id::text,
host(ip_address) AS ip_address,
port,
detected,
mac_address,
response_ms,
status,
error_message,
raw_summary_json,
raw_version_json,
raw_pools_json,
raw_error_json,
created_at
FROM scan_results
WHERE scan_run_id = %s
ORDER BY ip_address
LIMIT 2000;
""",
(scan_run_id,),
)
results = [dict(row) for row in cur.fetchall()]
return api_response(data=results)
finally:
if conn is not None:
conn.close()
import socket
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
class ScanNowRequest(BaseModel):
max_ips: int = Field(default=25, ge=1, le=2000)
specific_ips: list[str] | None = None
def expand_scan_range_for_scan(scan_range: dict, max_ips: int, specific_ips: list[str] | None = None) -> list[str]:
if specific_ips:
validated_ips = []
for ip in specific_ips:
try:
validated_ips.append(str(ipaddress.ip_address(ip)))
except ValueError as exc:
raise HTTPException(status_code=422, detail=f"Invalid IP address {ip}: {exc}")
return validated_ips[:max_ips]
range_type = scan_range["range_type"]
if range_type == "cidr":
network = ipaddress.ip_network(scan_range["cidr"], strict=False)
return [str(ip) for ip in list(network.hosts())[:max_ips]]
if range_type == "start_end":
start = ipaddress.ip_address(scan_range["start_ip"])
end = ipaddress.ip_address(scan_range["end_ip"])
ips = []
current = int(start)
end_int = int(end)
while current <= end_int and len(ips) < max_ips:
ips.append(str(ipaddress.ip_address(current)))
current += 1
return ips
raise HTTPException(status_code=422, detail="Unsupported scan range type")
def tcp_check_ip(ip_address: str, port: int, timeout_ms: int) -> dict:
started = time.perf_counter()
try:
with socket.create_connection((ip_address, port), timeout=timeout_ms / 1000):
elapsed_ms = int((time.perf_counter() - started) * 1000)
return {
"ip_address": ip_address,
"port": port,
"detected": True,
"response_ms": elapsed_ms,
"status": "port_open",
"error_message": None,
}
except socket.timeout:
elapsed_ms = int((time.perf_counter() - started) * 1000)
return {
"ip_address": ip_address,
"port": port,
"detected": False,
"response_ms": elapsed_ms,
"status": "timeout",
"error_message": "Connection timed out",
}
except OSError as exc:
elapsed_ms = int((time.perf_counter() - started) * 1000)
return {
"ip_address": ip_address,
"port": port,
"detected": False,
"response_ms": elapsed_ms,
"status": "no_response",
"error_message": str(exc),
}
@app.post("/api/scan-ranges/{scan_range_id}/scan-now")
def scan_range_now(scan_range_id: str, request: ScanNowRequest, current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
id::text,
room_id::text,
name,
range_type,
cidr,
host(start_ip) AS start_ip,
host(end_ip) AS end_ip,
enabled,
timeout_ms,
retry_limit,
parallelism_limit,
stagger_ms,
api_port,
scan_interval_seconds
FROM scan_ranges
WHERE id = %s;
""",
(scan_range_id,),
)
scan_range = cur.fetchone()
if not scan_range:
raise HTTPException(status_code=404, detail="Scan range not found")
if not scan_range["enabled"]:
raise HTTPException(status_code=409, detail="Scan range is disabled")
ip_list = expand_scan_range_for_scan(dict(scan_range), request.max_ips, request.specific_ips)
cur.execute(
"""
INSERT INTO scan_runs (
scan_range_id,
triggered_by,
triggered_by_user_id,
status,
total_ips,
scanned_ips,
miners_found,
errors_count,
notes
)
VALUES (%s, 'manual', %s, 'running', %s, 0, 0, 0, %s)
RETURNING id::text;
""",
(
scan_range_id,
current_user["id"],
len(ip_list),
"TCP port 4028 reachability scan only",
),
)
scan_run_id = cur.fetchone()["id"]
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
VALUES (%s, 'scan.started', 'scan_run', %s, %s);
""",
(
current_user["id"],
scan_run_id,
psycopg.types.json.Jsonb(
{
"scan_range_id": scan_range_id,
"scan_range_name": scan_range["name"],
"total_ips": len(ip_list),
"mode": "tcp_4028_only",
}
),
),
)
conn.commit()
results = []
detected_count = 0
errors_count = 0
max_workers = min(scan_range["parallelism_limit"], len(ip_list))
if max_workers < 1:
max_workers = 1
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_map = {
executor.submit(
tcp_check_ip,
ip,
scan_range["api_port"],
scan_range["timeout_ms"],
): ip
for ip in ip_list
}
for future in as_completed(future_map):
result = future.result()
results.append(result)
if result["detected"]:
detected_count += 1
if result["status"] not in ("port_open", "timeout", "no_response"):
errors_count += 1
results = sorted(results, key=lambda item: ipaddress.ip_address(item["ip_address"]))
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
for result in results:
cur.execute(
"""
INSERT INTO scan_results (
scan_run_id,
ip_address,
port,
detected,
response_ms,
status,
error_message
)
VALUES (%s, %s, %s, %s, %s, %s, %s);
""",
(
scan_run_id,
result["ip_address"],
result["port"],
result["detected"],
result["response_ms"],
result["status"],
result["error_message"],
),
)
final_status = "completed"
if errors_count > 0:
final_status = "completed_with_errors"
cur.execute(
"""
UPDATE scan_runs
SET finished_at = now(),
status = %s,
scanned_ips = %s,
miners_found = %s,
errors_count = %s
WHERE id = %s
RETURNING
id::text,
scan_range_id::text,
triggered_by,
started_at,
finished_at,
status,
total_ips,
scanned_ips,
miners_found,
errors_count,
notes;
""",
(
final_status,
len(results),
detected_count,
errors_count,
scan_run_id,
),
)
scan_run = cur.fetchone()
cur.execute(
"""
UPDATE scan_ranges
SET last_scan_at = now(),
next_scan_at = now() + make_interval(secs => scan_interval_seconds),
updated_at = now()
WHERE id = %s;
""",
(scan_range_id,),
)
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
VALUES (%s, 'scan.completed', 'scan_run', %s, %s);
""",
(
current_user["id"],
scan_run_id,
psycopg.types.json.Jsonb(
{
"scan_range_id": scan_range_id,
"total_ips": len(ip_list),
"scanned_ips": len(results),
"port_4028_open": detected_count,
"errors_count": errors_count,
}
),
),
)
conn.commit()
return api_response(
data={
"scan_run": dict(scan_run),
"summary": {
"scan_range_id": scan_range_id,
"scan_range_name": scan_range["name"],
"total_ips": len(ip_list),
"scanned_ips": len(results),
"port_4028_open": detected_count,
"errors_count": errors_count,
},
}
)
except HTTPException:
if conn is not None:
conn.rollback()
raise
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
class WhatsminerReadTestRequest(BaseModel):
ip_address: str
port: int = Field(default=4028, ge=1, le=65535)
command: str = Field(default="summary", min_length=1, max_length=80)
params: dict[str, Any] | None = None
timeout_ms: int = Field(default=5000, ge=500, le=30000)
def read_from_socket_until_closed(sock: socket.socket, max_bytes: int = 1048576) -> bytes:
chunks = []
total = 0
while True:
chunk = sock.recv(65536)
if not chunk:
break
chunks.append(chunk)
total += len(chunk)
if total >= max_bytes:
break
return b"".join(chunks)
def send_whatsminer_read_command(ip_address: str, port: int, command: str, timeout_ms: int, params: dict[str, Any] | None = None) -> dict:
try:
ipaddress.ip_address(ip_address)
except ValueError as exc:
raise HTTPException(status_code=422, detail=f"Invalid IP address: {exc}")
import json
started = time.perf_counter()
params = params or {}
command_payload = {"command": command}
command_payload.update(params)
cmd_payload = {"cmd": command}
cmd_payload.update(params)
command_payloads = [
{
"name": "json_command_plain",
"payload": json.dumps(command_payload, separators=(",", ":")).encode("utf-8"),
},
{
"name": "json_command_newline",
"payload": (json.dumps(command_payload, separators=(",", ":")) + "\n").encode("utf-8"),
},
{
"name": "json_cmd_plain",
"payload": json.dumps(cmd_payload, separators=(",", ":")).encode("utf-8"),
},
{
"name": "json_cmd_newline",
"payload": (json.dumps(cmd_payload, separators=(",", ":")) + "\n").encode("utf-8"),
},
{
"name": "plain_command",
"payload": command.encode("utf-8"),
},
{
"name": "plain_command_newline",
"payload": (command + "\n").encode("utf-8"),
},
]
attempts = []
for command_payload in command_payloads:
payload_name = command_payload["name"]
payload = command_payload["payload"]
try:
with socket.create_connection((ip_address, port), timeout=timeout_ms / 1000) as sock:
sock.settimeout(timeout_ms / 1000)
sock.sendall(payload)
raw_response = read_from_socket_until_closed(sock)
elapsed_ms = int((time.perf_counter() - started) * 1000)
response_text = raw_response.decode("utf-8", errors="replace").strip()
attempts.append(
{
"payload_type": payload_name,
"bytes_received": len(raw_response),
"response_preview": response_text[:1000],
}
)
if response_text:
parsed_json = None
parse_error = None
try:
parsed_json = json.loads(response_text)
except Exception as exc:
parse_error = str(exc)
return {
"success": True,
"ip_address": ip_address,
"port": port,
"command": command,
"payload_type": payload_name,
"response_ms": elapsed_ms,
"bytes_received": len(raw_response),
"response_text": response_text,
"parsed_json": parsed_json,
"parse_error": parse_error,
"attempts": attempts,
}
except Exception as exc:
attempts.append(
{
"payload_type": payload_name,
"bytes_received": 0,
"error": str(exc),
}
)
elapsed_ms = int((time.perf_counter() - started) * 1000)
return {
"success": False,
"ip_address": ip_address,
"port": port,
"command": command,
"response_ms": elapsed_ms,
"attempts": attempts,
}
@app.post("/api/tools/whatsminer-read-test")
def whatsminer_read_test(request: WhatsminerReadTestRequest, current_user: dict = Depends(get_current_user)):
result = send_whatsminer_read_command(
ip_address=request.ip_address,
port=request.port,
command=request.command,
timeout_ms=request.timeout_ms,
params=request.params,
)
return api_response(data=result)
@app.get("/api/miners")
def list_miners(current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
miners.id::text,
miners.site_id::text,
sites.name AS site_name,
miners.room_id::text,
rooms.name AS room_name,
miners.asset_tag,
miners.current_mac,
host(miners.current_ip) AS current_ip,
miners.hostname,
miners.miner_sn,
miners.power_sn,
miners.model,
miners.miner_type,
miners.api_version,
miners.firmware_version,
miners.platform,
miners.chip,
miners.status,
miners.first_seen_at,
miners.last_seen_at,
miners.updated_at
FROM miners
LEFT JOIN sites ON sites.id = miners.site_id
LEFT JOIN rooms ON rooms.id = miners.room_id
ORDER BY rooms.sort_order, miners.current_ip, miners.current_mac;
"""
)
miners = [dict(row) for row in cur.fetchall()]
return api_response(data=miners)
finally:
if conn is not None:
conn.close()
@app.get("/api/miners/{miner_id}")
def get_miner(miner_id: str, current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
miners.id::text,
miners.site_id::text,
sites.name AS site_name,
miners.room_id::text,
rooms.name AS room_name,
miners.asset_tag,
miners.current_mac,
host(miners.current_ip) AS current_ip,
miners.hostname,
miners.miner_sn,
miners.power_sn,
miners.model,
miners.miner_type,
miners.api_version,
miners.firmware_version,
miners.platform,
miners.chip,
miners.status,
miners.notes,
miners.first_seen_at,
miners.last_seen_at,
miners.created_at,
miners.updated_at
FROM miners
LEFT JOIN sites ON sites.id = miners.site_id
LEFT JOIN rooms ON rooms.id = miners.room_id
WHERE miners.id = %s;
""",
(miner_id,),
)
miner = cur.fetchone()
if not miner:
raise HTTPException(status_code=404, detail="Miner not found")
return api_response(data=dict(miner))
finally:
if conn is not None:
conn.close()
@app.get("/api/miners/{miner_id}/network-identities")
def list_miner_network_identities(miner_id: str, current_user: dict = Depends(require_permission("miners.view"))):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
id::text,
miner_id::text,
mac_address,
host(ip_address) AS ip_address,
hostname,
source,
is_current,
first_seen_at,
last_seen_at,
created_at,
updated_at
FROM miner_network_identities
WHERE miner_id = %s
ORDER BY is_current DESC, last_seen_at DESC;
""",
(miner_id,),
)
identities = [dict(row) for row in cur.fetchall()]
return api_response(data=identities)
finally:
if conn is not None:
conn.close()
@app.get("/api/miners/{miner_id}/hashboards")
def list_miner_hashboards(miner_id: str, current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
id::text,
miner_id::text,
slot,
pcb_sn,
chip_data,
temperature_c,
chip_frequency,
mhs_av,
mhs_1m,
mhs_15m,
hs_rt,
factory_ghs,
effective_chips,
upfreq_complete,
first_seen_at,
last_seen_at,
updated_at
FROM miner_hashboards
WHERE miner_id = %s
ORDER BY slot;
""",
(miner_id,),
)
hashboards = [dict(row) for row in cur.fetchall()]
return api_response(data=hashboards)
finally:
if conn is not None:
conn.close()
@app.get("/api/miners/{miner_id}/status-snapshots")
def list_miner_status_snapshots(
miner_id: str,
current_user: dict = Depends(require_permission("miners.view")),
):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
id::text,
miner_id::text,
host(observed_ip) AS observed_ip,
observed_at,
mhs_av,
mhs_1m,
mhs_15m,
hs_rt,
power_w,
power_rate_w_per_th,
env_temp_c,
chip_temp_min_c,
chip_temp_max_c,
chip_temp_avg_c,
fan_speed_in_rpm,
fan_speed_out_rpm,
uptime_seconds,
hash_stable,
pool_rejected_percent,
COALESCE(
raw_summary_json #>> '{Msg,Power Mode}',
raw_summary_json #>> '{MSG,Power Mode}',
raw_summary_json #>> '{msg,Power Mode}',
raw_summary_json ->> 'Power Mode',
raw_summary_json ->> 'PowerMode',
raw_summary_json ->> 'power_mode'
) AS performance_mode,
raw_summary_json,
created_at
FROM miner_status_snapshots
WHERE miner_id = %s
ORDER BY observed_at DESC
LIMIT 100;
""",
(miner_id,),
)
snapshots = [dict(row) for row in cur.fetchall()]
return api_response(data=snapshots)
finally:
if conn is not None:
conn.close()
@app.get("/api/miners/{miner_id}/configured-performance-mode")
def get_miner_configured_performance_mode(miner_id: str, current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
WITH latest AS (
SELECT
COALESCE(
raw_summary_json #>> '{Msg,Power Mode}',
raw_summary_json #>> '{MSG,Power Mode}',
raw_summary_json #>> '{msg,Power Mode}',
raw_summary_json ->> 'Power Mode',
raw_summary_json ->> 'PowerMode',
raw_summary_json ->> 'power_mode'
) AS runtime_power_state,
created_at AS runtime_power_state_at
FROM miner_status_snapshots
WHERE miner_id = %s
ORDER BY observed_at DESC
LIMIT 1
)
SELECT
miners.id::text AS miner_id,
INITCAP(miners.configured_performance_mode) AS configured_performance_mode,
miners.configured_performance_mode_source,
miners.configured_performance_mode_updated_at,
latest.runtime_power_state,
latest.runtime_power_state_at
FROM miners
LEFT JOIN latest ON true
WHERE miners.id = %s;
""",
(miner_id, miner_id),
)
row = cur.fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Miner not found.")
return api_response(data=dict(row))
finally:
if conn is not None:
conn.close()
class DiscoverOneMinerRequest(BaseModel):
ip_address: str
port: int = Field(default=4028, ge=1, le=65535)
scan_range_id: str | None = None
timeout_ms: int = Field(default=5000, ge=500, le=30000)
def clean_ip_text(value: Any) -> str | None:
if value is None:
return None
return str(value).split("/")[0]
def to_bool_or_none(value: Any) -> bool | None:
if value is None:
return None
if isinstance(value, bool):
return value
text = str(value).strip().lower()
if text in ("true", "1", "yes", "y", "on"):
return True
if text in ("false", "0", "no", "n", "off"):
return False
return None
def get_msg_dict(result: dict, command_name: str) -> dict:
if not result.get("success"):
raise HTTPException(status_code=502, detail=f"{command_name} failed: {result}")
parsed_json = result.get("parsed_json")
if not isinstance(parsed_json, dict):
raise HTTPException(status_code=502, detail=f"{command_name} did not return JSON")
if parsed_json.get("STATUS") == "E":
raise HTTPException(status_code=502, detail=f"{command_name} returned error: {parsed_json}")
msg = parsed_json.get("Msg", {})
if isinstance(msg, dict):
return msg
return {}
def find_scan_range_context(cur, ip_address: str, scan_range_id: str | None) -> dict:
if scan_range_id:
cur.execute(
"""
SELECT
scan_ranges.id::text AS scan_range_id,
scan_ranges.room_id::text,
rooms.site_id::text,
rooms.name AS room_name,
scan_ranges.name AS scan_range_name
FROM scan_ranges
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
WHERE scan_ranges.id = %s;
""",
(scan_range_id,),
)
row = cur.fetchone()
if row:
return dict(row)
cur.execute(
"""
SELECT
scan_ranges.id::text AS scan_range_id,
scan_ranges.room_id::text,
rooms.site_id::text,
rooms.name AS room_name,
scan_ranges.name AS scan_range_name,
scan_ranges.range_type,
scan_ranges.cidr,
host(scan_ranges.start_ip) AS start_ip,
scan_ranges.end_ip::text
FROM scan_ranges
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
WHERE scan_ranges.enabled = true;
"""
)
ip_obj = ipaddress.ip_address(ip_address)
for row in cur.fetchall():
row_dict = dict(row)
if row_dict["range_type"] == "cidr" and row_dict["cidr"]:
network = ipaddress.ip_network(row_dict["cidr"], strict=False)
if ip_obj in network:
return row_dict
if row_dict["range_type"] == "start_end" and row_dict["start_ip"] and row_dict["end_ip"]:
start_ip = ipaddress.ip_address(clean_ip_text(row_dict["start_ip"]))
end_ip = ipaddress.ip_address(clean_ip_text(row_dict["end_ip"]))
if int(start_ip) <= int(ip_obj) <= int(end_ip):
return row_dict
return {
"scan_range_id": scan_range_id,
"room_id": None,
"site_id": None,
"room_name": None,
"scan_range_name": None,
}
def find_existing_miner(cur, miner_sn: str | None, mac_address: str | None, ip_address: str) -> dict | None:
if miner_sn:
cur.execute(
"""
SELECT id::text
FROM miners
WHERE miner_sn = %s
LIMIT 1;
""",
(miner_sn,),
)
row = cur.fetchone()
if row:
return dict(row)
if mac_address:
cur.execute(
"""
SELECT id::text
FROM miners
WHERE lower(current_mac) = lower(%s)
LIMIT 1;
""",
(mac_address,),
)
row = cur.fetchone()
if row:
return dict(row)
cur.execute(
"""
SELECT id::text
FROM miners
WHERE current_ip = %s
LIMIT 1;
""",
(ip_address,),
)
row = cur.fetchone()
if row:
return dict(row)
return None
@app.post("/api/miners/discover-one")
def discover_one_miner(request: DiscoverOneMinerRequest, current_user: dict = Depends(get_current_user)):
try:
ip_address = str(ipaddress.ip_address(request.ip_address))
except ValueError as exc:
raise HTTPException(status_code=422, detail=f"Invalid IP address: {exc}")
miner_info_result = send_whatsminer_read_command(
ip_address=ip_address,
port=request.port,
command="get_miner_info",
timeout_ms=request.timeout_ms,
params={
"info": "ip,proto,netmask,gateway,dns,hostname,mac,ledstat,minersn,powersn"
},
)
version_result = send_whatsminer_read_command(
ip_address=ip_address,
port=request.port,
command="get_version",
timeout_ms=request.timeout_ms,
)
summary_result = send_whatsminer_read_command(
ip_address=ip_address,
port=request.port,
command="summary",
timeout_ms=request.timeout_ms,
)
edevs_result = send_whatsminer_read_command(
ip_address=ip_address,
port=request.port,
command="edevs",
timeout_ms=request.timeout_ms,
)
pools_result = send_whatsminer_read_command(
ip_address=ip_address,
port=request.port,
command="pools",
timeout_ms=request.timeout_ms,
)
miner_info_json = miner_info_result.get("parsed_json") or {}
version_json = version_result.get("parsed_json") or {}
summary_json = summary_result.get("parsed_json") or {}
edevs_json = edevs_result.get("parsed_json") or {}
pools_json = pools_result.get("parsed_json") or {}
miner_info_msg = get_msg_dict(miner_info_result, "get_miner_info")
version_msg = get_msg_dict(version_result, "get_version")
try:
summary_msg = get_msg_dict(summary_result, "summary")
summary_warning = None
except HTTPException as exc:
summary_msg = {}
summary_warning = exc.detail
mac_address = miner_info_msg.get("mac")
if mac_address:
mac_address = str(mac_address).upper()
hostname = miner_info_msg.get("hostname")
miner_sn = miner_info_msg.get("minersn")
power_sn = miner_info_msg.get("powersn")
miner_type = version_msg.get("miner_type")
model = miner_type.split("_")[0] if miner_type else None
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
context = find_scan_range_context(cur, ip_address, request.scan_range_id)
existing_miner = find_existing_miner(
cur=cur,
miner_sn=miner_sn,
mac_address=mac_address,
ip_address=ip_address,
)
if existing_miner:
miner_id = existing_miner["id"]
cur.execute(
"""
UPDATE miners
SET site_id = COALESCE(%s, site_id),
room_id = COALESCE(%s, room_id),
current_mac = %s,
current_ip = %s,
hostname = %s,
miner_sn = %s,
power_sn = %s,
model = %s,
miner_type = %s,
api_version = %s,
firmware_version = %s,
platform = %s,
chip = %s,
status = 'online',
last_seen_at = now(),
updated_at = now()
WHERE id = %s
RETURNING id::text;
""",
(
context.get("site_id"),
context.get("room_id"),
mac_address,
ip_address,
hostname,
miner_sn,
power_sn,
model,
miner_type,
version_msg.get("api_ver"),
version_msg.get("fw_ver"),
version_msg.get("platform"),
version_msg.get("chip"),
miner_id,
),
)
miner_id = cur.fetchone()["id"]
action = "miner.updated"
else:
cur.execute(
"""
INSERT INTO miners (
site_id,
room_id,
current_mac,
current_ip,
hostname,
miner_sn,
power_sn,
model,
miner_type,
api_version,
firmware_version,
platform,
chip,
status
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'online')
RETURNING id::text;
""",
(
context.get("site_id"),
context.get("room_id"),
mac_address,
ip_address,
hostname,
miner_sn,
power_sn,
model,
miner_type,
version_msg.get("api_ver"),
version_msg.get("fw_ver"),
version_msg.get("platform"),
version_msg.get("chip"),
),
)
miner_id = cur.fetchone()["id"]
action = "miner.created"
if mac_address:
cur.execute(
"""
UPDATE miner_network_identities
SET is_current = false,
updated_at = now()
WHERE miner_id = %s;
""",
(miner_id,),
)
cur.execute(
"""
SELECT id::text
FROM miner_network_identities
WHERE miner_id = %s
AND lower(mac_address) = lower(%s)
LIMIT 1;
""",
(miner_id, mac_address),
)
identity = cur.fetchone()
if identity:
cur.execute(
"""
UPDATE miner_network_identities
SET ip_address = %s,
hostname = %s,
source = 'scanner',
is_current = true,
last_seen_at = now(),
updated_at = now()
WHERE id = %s;
""",
(ip_address, hostname, identity["id"]),
)
else:
cur.execute(
"""
INSERT INTO miner_network_identities (
miner_id,
mac_address,
ip_address,
hostname,
source,
is_current
)
VALUES (%s, %s, %s, %s, 'scanner', true);
""",
(miner_id, mac_address, ip_address, hostname),
)
for board in edevs_json.get("DEVS", []):
slot = board.get("Slot")
if slot is None:
continue
cur.execute(
"""
INSERT INTO miner_hashboards (
miner_id,
slot,
pcb_sn,
chip_data,
temperature_c,
chip_frequency,
mhs_av,
mhs_1m,
mhs_15m,
hs_rt,
factory_ghs,
effective_chips,
upfreq_complete,
raw_json
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT ON CONSTRAINT uq_miner_hashboards_miner_slot
DO UPDATE SET
pcb_sn = EXCLUDED.pcb_sn,
chip_data = EXCLUDED.chip_data,
temperature_c = EXCLUDED.temperature_c,
chip_frequency = EXCLUDED.chip_frequency,
mhs_av = EXCLUDED.mhs_av,
mhs_1m = EXCLUDED.mhs_1m,
mhs_15m = EXCLUDED.mhs_15m,
hs_rt = EXCLUDED.hs_rt,
factory_ghs = EXCLUDED.factory_ghs,
effective_chips = EXCLUDED.effective_chips,
upfreq_complete = EXCLUDED.upfreq_complete,
raw_json = EXCLUDED.raw_json,
last_seen_at = now(),
updated_at = now();
""",
(
miner_id,
int(slot),
board.get("PCB SN"),
board.get("Chip Data"),
board.get("Temperature"),
board.get("Chip Frequency"),
board.get("MHS av"),
board.get("MHS 1m"),
board.get("MHS 15m"),
board.get("HS RT"),
board.get("Factory GHS"),
board.get("Effective Chips"),
board.get("Upfreq Complete"),
psycopg.types.json.Jsonb(json_safe(board)),
),
)
cur.execute(
"""
INSERT INTO miner_status_snapshots (
miner_id,
observed_ip,
mhs_av,
mhs_1m,
mhs_15m,
hs_rt,
power_w,
power_rate_w_per_th,
env_temp_c,
chip_temp_min_c,
chip_temp_max_c,
chip_temp_avg_c,
fan_speed_in_rpm,
fan_speed_out_rpm,
uptime_seconds,
hash_stable,
pool_rejected_percent,
raw_summary_json,
raw_version_json,
raw_miner_info_json
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);
""",
(
miner_id,
ip_address,
summary_msg.get("MHS av"),
summary_msg.get("MHS 1m"),
summary_msg.get("MHS 15m"),
summary_msg.get("HS RT"),
summary_msg.get("Power"),
summary_msg.get("Power Rate"),
summary_msg.get("Env Temp"),
summary_msg.get("Chip Temp Min"),
summary_msg.get("Chip Temp Max"),
summary_msg.get("Chip Temp Avg"),
summary_msg.get("Fan Speed In"),
summary_msg.get("Fan Speed Out"),
summary_msg.get("Uptime"),
to_bool_or_none(summary_msg.get("Hash Stable")),
summary_msg.get("Pool Rejected%"),
psycopg.types.json.Jsonb(json_safe(summary_json)),
psycopg.types.json.Jsonb(json_safe(version_json)),
psycopg.types.json.Jsonb(json_safe(miner_info_json)),
),
)
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
VALUES (%s, %s, 'miner', %s, %s);
""",
(
current_user["id"],
action,
miner_id,
psycopg.types.json.Jsonb(
{
"ip_address": ip_address,
"mac_address": mac_address,
"miner_sn": miner_sn,
"power_sn": power_sn,
"miner_type": miner_type,
"firmware_version": version_msg.get("fw_ver"),
"room_id": context.get("room_id"),
"scan_range_id": context.get("scan_range_id"),
}
),
),
)
cur.execute(
"""
SELECT
miners.id::text,
miners.site_id::text,
sites.name AS site_name,
miners.room_id::text,
rooms.name AS room_name,
miners.current_mac,
host(miners.current_ip) AS current_ip,
miners.hostname,
miners.miner_sn,
miners.power_sn,
miners.model,
miners.miner_type,
miners.api_version,
miners.firmware_version,
miners.platform,
miners.chip,
miners.status,
miners.first_seen_at,
miners.last_seen_at,
miners.updated_at
FROM miners
LEFT JOIN sites ON sites.id = miners.site_id
LEFT JOIN rooms ON rooms.id = miners.room_id
WHERE miners.id = %s;
""",
(miner_id,),
)
miner = dict(cur.fetchone())
conn.commit()
return api_response(
data={
"action": action,
"miner": miner,
"summary": {
"ip_address": ip_address,
"mac_address": mac_address,
"miner_sn": miner_sn,
"power_sn": power_sn,
"miner_type": miner_type,
"firmware_version": version_msg.get("fw_ver"),
"hashboards_seen": len(edevs_json.get("DEVS", [])),
"pools_seen": len(pools_json.get("POOLS", [])),
"hashrate_mhs_av": summary_msg.get("MHS av"),
"power_w": summary_msg.get("Power"),
"chip_temp_max_c": summary_msg.get("Chip Temp Max"),
"summary_warning": summary_warning,
},
}
)
except HTTPException:
if conn is not None:
conn.rollback()
raise
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
@app.post("/api/scan-ranges/{scan_range_id}/discover-now")
def discover_scan_range_now(scan_range_id: str, request: ScanNowRequest, current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
id::text,
room_id::text,
name,
range_type,
cidr,
host(start_ip) AS start_ip,
host(end_ip) AS end_ip,
enabled,
timeout_ms,
retry_limit,
parallelism_limit,
stagger_ms,
api_port,
scan_interval_seconds
FROM scan_ranges
WHERE id = %s;
""",
(scan_range_id,),
)
scan_range = cur.fetchone()
if not scan_range:
raise HTTPException(status_code=404, detail="Scan range not found")
if not scan_range["enabled"]:
raise HTTPException(status_code=409, detail="Scan range is disabled")
ip_list = expand_scan_range_for_scan(dict(scan_range), request.max_ips, request.specific_ips)
cur.execute(
"""
INSERT INTO scan_runs (
scan_range_id,
triggered_by,
triggered_by_user_id,
status,
total_ips,
scanned_ips,
miners_found,
errors_count,
notes
)
VALUES (%s, 'manual', %s, 'running', %s, 0, 0, 0, %s)
RETURNING id::text;
""",
(
scan_range_id,
current_user["id"],
len(ip_list),
"TCP port 4028 scan with read-only miner discovery",
),
)
scan_run_id = cur.fetchone()["id"]
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
VALUES (%s, 'scan.discovery.started', 'scan_run', %s, %s);
""",
(
current_user["id"],
scan_run_id,
psycopg.types.json.Jsonb(
{
"scan_range_id": scan_range_id,
"scan_range_name": scan_range["name"],
"total_ips": len(ip_list),
"mode": "tcp_4028_then_whatsminer_read_only_discovery",
}
),
),
)
conn.commit()
except HTTPException:
if conn is not None:
conn.rollback()
raise
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
results = []
detected_count = 0
max_workers = min(scan_range["parallelism_limit"], len(ip_list))
if max_workers < 1:
max_workers = 1
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_map = {
executor.submit(
tcp_check_ip,
ip,
scan_range["api_port"],
scan_range["timeout_ms"],
): ip
for ip in ip_list
}
for future in as_completed(future_map):
result = future.result()
results.append(result)
if result["detected"]:
detected_count += 1
results = sorted(results, key=lambda item: ipaddress.ip_address(item["ip_address"]))
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
for result in results:
cur.execute(
"""
INSERT INTO scan_results (
scan_run_id,
ip_address,
port,
detected,
response_ms,
status,
error_message
)
VALUES (%s, %s, %s, %s, %s, %s, %s);
""",
(
scan_run_id,
result["ip_address"],
result["port"],
result["detected"],
result["response_ms"],
result["status"],
result["error_message"],
),
)
cur.execute(
"""
UPDATE scan_ranges
SET last_scan_at = now(),
next_scan_at = now() + make_interval(secs => scan_interval_seconds),
updated_at = now()
WHERE id = %s;
""",
(scan_range_id,),
)
cur.execute(
"""
UPDATE scan_runs
SET scanned_ips = %s,
miners_found = %s,
errors_count = 0
WHERE id = %s;
""",
(
len(results),
detected_count,
scan_run_id,
),
)
conn.commit()
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
discoveries = []
discovery_errors = []
for result in results:
if not result["detected"]:
continue
try:
discovery_response = discover_one_miner(
DiscoverOneMinerRequest(
ip_address=result["ip_address"],
port=result["port"],
scan_range_id=scan_range_id,
timeout_ms=scan_range["timeout_ms"],
),
current_user=current_user,
)
discoveries.append(discovery_response["data"])
except HTTPException as exc:
discovery_errors.append(
{
"ip_address": result["ip_address"],
"status_code": exc.status_code,
"detail": exc.detail,
}
)
except Exception as exc:
discovery_errors.append(
{
"ip_address": result["ip_address"],
"status_code": 500,
"detail": str(exc),
}
)
final_status = "completed"
if discovery_errors:
final_status = "completed_with_errors"
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
UPDATE scan_runs
SET finished_at = now(),
status = %s,
miners_found = %s,
errors_count = %s,
notes = %s
WHERE id = %s
RETURNING
id::text,
scan_range_id::text,
triggered_by,
started_at,
finished_at,
status,
total_ips,
scanned_ips,
miners_found,
errors_count,
notes;
""",
(
final_status,
len(discoveries),
len(discovery_errors),
"TCP port 4028 scan with read-only miner discovery",
scan_run_id,
),
)
scan_run = cur.fetchone()
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
VALUES (%s, 'scan.discovery.completed', 'scan_run', %s, %s);
""",
(
current_user["id"],
scan_run_id,
psycopg.types.json.Jsonb(
{
"scan_range_id": scan_range_id,
"total_ips": len(ip_list),
"scanned_ips": len(results),
"port_4028_open": detected_count,
"miners_discovered": len(discoveries),
"discovery_errors": len(discovery_errors),
}
),
),
)
conn.commit()
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
return api_response(
data={
"scan_run": dict(scan_run),
"summary": {
"scan_range_id": scan_range_id,
"scan_range_name": scan_range["name"],
"total_ips": len(ip_list),
"scanned_ips": len(results),
"port_4028_open": detected_count,
"miners_discovered": len(discoveries),
"discovery_errors": len(discovery_errors),
},
"discoveries": discoveries,
"discovery_errors": discovery_errors,
}
)
@app.get("/api/dashboard/summary")
def dashboard_summary(current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
COUNT(*) AS total_miners,
COUNT(*) FILTER (WHERE status = 'online') AS online_miners,
COUNT(*) FILTER (WHERE current_mac IS NULL OR current_mac = '') AS missing_mac_count,
COUNT(*) FILTER (WHERE miner_type IS NULL OR miner_type = '') AS missing_miner_type_count,
COUNT(*) FILTER (WHERE firmware_version IS NULL OR firmware_version = '') AS missing_firmware_count,
COUNT(*) FILTER (WHERE last_seen_at >= now() - interval '30 minutes') AS seen_last_30_minutes,
COUNT(*) FILTER (WHERE last_seen_at < now() - interval '30 minutes') AS not_seen_last_30_minutes
FROM miners;
"""
)
totals = dict(cur.fetchone())
cur.execute(
"""
SELECT
COALESCE(rooms.name, 'Unassigned') AS room_name,
COUNT(miners.id) AS miner_count,
COUNT(miners.id) FILTER (WHERE miners.status = 'online') AS online_count,
COUNT(miners.id) FILTER (WHERE miners.last_seen_at >= now() - interval '30 minutes') AS seen_last_30_minutes
FROM miners
LEFT JOIN rooms ON rooms.id = miners.room_id
GROUP BY rooms.name, rooms.sort_order
ORDER BY rooms.sort_order, rooms.name;
"""
)
by_room = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT
COALESCE(NULLIF(miner_type, ''), NULLIF(model, ''), 'Unknown') AS model,
COUNT(*) AS miner_count
FROM miners
GROUP BY COALESCE(NULLIF(miner_type, ''), NULLIF(model, ''), 'Unknown')
ORDER BY miner_count DESC, model;
"""
)
by_model = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT
COALESCE(NULLIF(firmware_version, ''), 'Unknown') AS firmware_version,
COUNT(*) AS miner_count
FROM miners
GROUP BY COALESCE(NULLIF(firmware_version, ''), 'Unknown')
ORDER BY miner_count DESC, firmware_version;
"""
)
by_firmware = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT
COALESCE(NULLIF(status, ''), 'Unknown') AS status,
COUNT(*) AS miner_count
FROM miners
GROUP BY COALESCE(NULLIF(status, ''), 'Unknown')
ORDER BY miner_count DESC, status;
"""
)
by_status = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
WITH latest AS (
SELECT DISTINCT ON (miner_id)
miner_id,
observed_at,
power_w,
power_rate_w_per_th,
chip_temp_max_c,
chip_temp_avg_c,
mhs_av,
hash_stable
FROM miner_status_snapshots
ORDER BY miner_id, observed_at DESC
)
SELECT
COUNT(*) AS miners_with_snapshots,
ROUND(AVG(power_w)::numeric, 2)::float AS avg_power_w,
ROUND(SUM(power_w)::numeric, 2)::float AS total_power_w,
ROUND(AVG(power_rate_w_per_th)::numeric, 2)::float AS avg_power_rate_w_per_th,
ROUND(MAX(chip_temp_max_c)::numeric, 2)::float AS hottest_chip_temp_c,
ROUND(AVG(chip_temp_avg_c)::numeric, 2)::float AS avg_chip_temp_c,
ROUND(SUM(mhs_av)::numeric / 1000000, 2)::float AS total_hashrate_ths,
COUNT(*) FILTER (WHERE chip_temp_max_c >= 90) AS miners_chip_temp_90c_or_higher,
COUNT(*) FILTER (WHERE hash_stable = false) AS miners_hash_unstable
FROM latest;
"""
)
telemetry = dict(cur.fetchone())
cur.execute(
"""
SELECT
COUNT(DISTINCT miner_id) AS miners_with_hashboards,
COUNT(*) AS total_hashboards,
COUNT(*) FILTER (WHERE temperature_c >= 90) AS hashboards_90c_or_higher,
COUNT(*) FILTER (WHERE pcb_sn IS NULL OR pcb_sn = '') AS hashboards_missing_pcb_sn
FROM miner_hashboards;
"""
)
hashboards = dict(cur.fetchone())
return api_response(
data={
"totals": totals,
"by_room": by_room,
"by_model": by_model,
"by_firmware": by_firmware,
"by_status": by_status,
"telemetry": telemetry,
"hashboards": hashboards,
}
)
finally:
if conn is not None:
conn.close()
from fastapi import File, Query, UploadFile
import csv
import io
def csv_value(row: dict, possible_names: list[str]) -> str | None:
for name in possible_names:
if name in row and row[name] is not None:
value = str(row[name]).strip()
if value:
return value
return None
def normalize_mac_for_match(mac: str | None) -> str | None:
if not mac:
return None
cleaned = "".join(ch for ch in mac.lower() if ch in "0123456789abcdef")
if len(cleaned) != 12:
return None
return cleaned
def model_from_miner_type(miner_type: str | None) -> str | None:
if not miner_type:
return None
if "_" in miner_type:
return miner_type.split("_", 1)[0]
return miner_type
@app.post("/api/imports/whatsminer-tool-scan")
async def import_whatsminer_tool_scan(
dry_run: bool = Query(default=True),
update_existing: bool = Query(default=False),
file: UploadFile = File(...),
current_user: dict = Depends(get_current_user),
):
file_bytes = await file.read()
try:
csv_text = file_bytes.decode("utf-8-sig")
except UnicodeDecodeError:
csv_text = file_bytes.decode("latin-1")
reader = csv.DictReader(io.StringIO(csv_text))
rows = list(reader)
if not rows:
raise HTTPException(status_code=422, detail="CSV file has no rows")
matched_count = 0
unmatched_count = 0
rows_with_updates = 0
updates_applied = 0
skipped_no_useful_data = 0
update_samples = []
unmatched_samples = []
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
for row_number, row in enumerate(rows, start=2):
ip_address = csv_value(row, ["IP", "IP Address", "ip", "ip_address"])
mac_address = csv_value(row, ["MAC Addr", "MAC", "Mac", "mac", "mac_address"])
miner_type = csv_value(row, ["Miner Type", "MinerType", "Model", "model", "miner_type"])
firmware_version = csv_value(row, ["Firmware", "Firmware Version", "FW Version", "fw_ver", "firmware_version"])
if ip_address:
ip_address = clean_ip_text(ip_address)
normalized_mac = normalize_mac_for_match(mac_address)
model = model_from_miner_type(miner_type)
if not ip_address and not normalized_mac:
skipped_no_useful_data += 1
continue
miner = None
if normalized_mac:
cur.execute(
"""
SELECT
id::text,
host(current_ip) AS current_ip,
current_mac,
model,
miner_type,
firmware_version
FROM miners
WHERE regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g') = %s
LIMIT 1;
""",
(normalized_mac,),
)
miner = cur.fetchone()
if not miner and ip_address:
cur.execute(
"""
SELECT
id::text,
host(current_ip) AS current_ip,
current_mac,
model,
miner_type,
firmware_version
FROM miners
WHERE host(current_ip) = %s
LIMIT 1;
""",
(ip_address,),
)
miner = cur.fetchone()
if not miner:
unmatched_count += 1
if len(unmatched_samples) < 20:
unmatched_samples.append(
{
"row_number": row_number,
"ip_address": ip_address,
"mac_address": mac_address,
"miner_type": miner_type,
"firmware_version": firmware_version,
}
)
continue
matched_count += 1
set_clauses = []
params = []
proposed_changes = {}
if miner_type and (update_existing or not miner["miner_type"]):
set_clauses.append("miner_type = %s")
params.append(miner_type)
proposed_changes["miner_type"] = {
"old": miner["miner_type"],
"new": miner_type,
}
if model and (update_existing or not miner["model"]):
set_clauses.append("model = %s")
params.append(model)
proposed_changes["model"] = {
"old": miner["model"],
"new": model,
}
if firmware_version and (update_existing or not miner["firmware_version"]):
set_clauses.append("firmware_version = %s")
params.append(firmware_version)
proposed_changes["firmware_version"] = {
"old": miner["firmware_version"],
"new": firmware_version,
}
if not set_clauses:
continue
rows_with_updates += 1
if len(update_samples) < 50:
update_samples.append(
{
"row_number": row_number,
"miner_id": miner["id"],
"ip_address": miner["current_ip"],
"current_mac": miner["current_mac"],
"changes": proposed_changes,
}
)
if not dry_run:
set_sql = ", ".join(set_clauses)
params.append(miner["id"])
cur.execute(
f"""
UPDATE miners
SET {set_sql},
updated_at = now()
WHERE id = %s;
""",
tuple(params),
)
updates_applied += 1
if not dry_run:
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
VALUES (%s, 'import.whatsminertool_csv.applied', 'import', NULL, %s);
""",
(
current_user["id"],
psycopg.types.json.Jsonb(
{
"filename": file.filename,
"rows_read": len(rows),
"matched_count": matched_count,
"unmatched_count": unmatched_count,
"rows_with_updates": rows_with_updates,
"updates_applied": updates_applied,
"update_existing": update_existing,
}
),
),
)
if dry_run:
conn.rollback()
else:
conn.commit()
return api_response(
data={
"filename": file.filename,
"dry_run": dry_run,
"update_existing": update_existing,
"rows_read": len(rows),
"matched_count": matched_count,
"unmatched_count": unmatched_count,
"skipped_no_useful_data": skipped_no_useful_data,
"rows_with_updates": rows_with_updates,
"updates_applied": updates_applied,
"update_samples": update_samples,
"unmatched_samples": unmatched_samples,
}
)
except HTTPException:
if conn is not None:
conn.rollback()
raise
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
from fastapi.responses import HTMLResponse, Response
@app.get("/tools/whatsminer-import", response_class=HTMLResponse)
def whatsminer_import_tool_page():
return """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>MinerOps - WhatsMinerTool CSV Import</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 30px;
background: #f4f6f8;
color: #1f2933;
}
.container {
max-width: 1100px;
margin: auto;
}
.card {
background: white;
border: 1px solid #d9e2ec;
border-radius: 8px;
padding: 20px;
margin-bottom: 18px;
box-shadow: 0 2px 5px rgba(0,0,0,0.04);
}
h1, h2 {
margin-top: 0;
}
label {
display: block;
margin-top: 12px;
font-weight: bold;
}
input[type="text"],
input[type="password"],
input[type="file"] {
width: 100%;
max-width: 420px;
padding: 10px;
margin-top: 5px;
border: 1px solid #bcccdc;
border-radius: 5px;
}
button {
margin-top: 14px;
padding: 10px 16px;
border: 0;
border-radius: 5px;
background: #2563eb;
color: white;
cursor: pointer;
font-weight: bold;
}
button.secondary {
background: #475569;
}
button.danger {
background: #b91c1c;
}
button:disabled {
background: #94a3b8;
cursor: not-allowed;
}
pre {
background: #111827;
color: #f9fafb;
padding: 15px;
border-radius: 6px;
overflow-x: auto;
white-space: pre-wrap;
}
.status {
padding: 10px;
border-radius: 5px;
margin-top: 10px;
}
.ok {
background: #dcfce7;
border: 1px solid #86efac;
}
.warn {
background: #fef9c3;
border: 1px solid #fde047;
}
.bad {
background: #fee2e2;
border: 1px solid #fca5a5;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.metric {
background: #f8fafc;
border: 1px solid #e2e8f0;
padding: 12px;
border-radius: 6px;
}
.metric .label {
font-size: 12px;
color: #64748b;
text-transform: uppercase;
}
.metric .value {
font-size: 24px;
font-weight: bold;
margin-top: 5px;
}
</style>
</head>
<body>
<div class="card" style="border-left:4px solid #0b63ce;margin:12px 0;padding:12px;"><strong>New miner WebAPI reminder:</strong> Before importing or adding new WhatsMiners, enable WebAPI/API3 in WhatsMinerTool. Reboot, Suspend, Resume, and password changes need WebAPI enabled.</div>
<div class="container">
<h1>MinerOps - WhatsMinerTool CSV Import</h1>
<div class="card">
<h2>1. Login</h2>
<label>Username</label>
<input id="username" type="text" value="admin">
<label>Password</label>
<input id="password" type="password">
<button onclick="login()">Login</button>
<div id="loginStatus" class="status warn">Not logged in.</div>
</div>
<div class="card">
<h2>2. Upload CSV</h2>
<label>WhatsMinerTool CSV</label>
<input id="csvFile" type="file" accept=".csv,text/csv">
<label>
<input id="updateExisting" type="checkbox">
Allow CSV to overwrite existing model/firmware fields
</label>
<button onclick="previewImport()">Preview Import</button>
<button class="danger" onclick="applyImport()" id="applyButton" disabled>Apply Updates</button>
<div id="importStatus" class="status warn">Choose a CSV and preview first.</div>
</div>
<div class="card">
<h2>3. Preview Summary</h2>
<div class="grid">
<div class="metric">
<div class="label">Rows Read</div>
<div class="value" id="rowsRead">-</div>
</div>
<div class="metric">
<div class="label">Matched</div>
<div class="value" id="matchedCount">-</div>
</div>
<div class="metric">
<div class="label">Unmatched</div>
<div class="value" id="unmatchedCount">-</div>
</div>
<div class="metric">
<div class="label">Rows With Updates</div>
<div class="value" id="rowsWithUpdates">-</div>
</div>
<div class="metric">
<div class="label">Updates Applied</div>
<div class="value" id="updatesApplied">-</div>
</div>
</div>
</div>
<div class="card">
<h2>Raw Result</h2>
<pre id="rawResult">No result yet.</pre>
</div>
</div>
<script>
let token = localStorage.getItem("minerops_token") || "";
function setLoginStatus(message, className) {
const element = document.getElementById("loginStatus");
element.textContent = message;
element.className = "status " + className;
}
function setImportStatus(message, className) {
const element = document.getElementById("importStatus");
element.textContent = message;
element.className = "status " + className;
}
function updateSummary(data) {
document.getElementById("rowsRead").textContent = data.rows_read ?? "-";
document.getElementById("matchedCount").textContent = data.matched_count ?? "-";
document.getElementById("unmatchedCount").textContent = data.unmatched_count ?? "-";
document.getElementById("rowsWithUpdates").textContent = data.rows_with_updates ?? "-";
document.getElementById("updatesApplied").textContent = data.updates_applied ?? "-";
}
function showRaw(value) {
document.getElementById("rawResult").textContent = JSON.stringify(value, null, 2);
}
async function apiPost(path) {
if (!token) {
throw new Error("Missing token. Please log in.");
}
const response = await fetch(path, {
method: "POST",
headers: {
"Authorization": "Bearer " + token
}
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || "API error");
}
return result;
}
async function refreshScannerStatusOnly() {
try {
scannerStatus = await apiGet("/api/scanner/status");
scannerPollCount += 1;
renderScannerStatus();
if (scannerStatus.running) {
setRaw("Scanner is running... Success: " + scannerStatus.last_success_count + " | Failed: " + scannerStatus.last_failure_count);
scannerPollTimer = setTimeout(refreshScannerStatusOnly, 5000);
} else {
setRaw("Scanner is idle. Last scan success: " + scannerStatus.last_success_count + " | Failed: " + scannerStatus.last_failure_count);
if (scannerPollTimer) {
clearTimeout(scannerPollTimer);
scannerPollTimer = null;
}
await refreshAll();
}
} catch (error) {
setRaw("Scanner refresh error: " + error.message);
}
}
async function runKnownMinerScanNow() {
try {
setRaw("Starting known-miner scan...");
const result = await apiPost("/api/scanner/run-known-now");
scannerPollCount = 0;
scannerStatus = result.data;
renderScannerStatus();
if (scannerPollTimer) {
clearTimeout(scannerPollTimer);
scannerPollTimer = null;
}
scannerPollTimer = setTimeout(refreshScannerStatusOnly, 5000);
setRaw(result.meta?.message || "Known-miner scan requested.");
} catch (error) {
setRaw("Scanner error: " + error.message);
}
}
async function login() {
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
username: username,
password: password
})
});
const result = await response.json();
showRaw(result);
if (!response.ok) {
setLoginStatus("Login failed: " + (result.detail || "Unknown error"), "bad");
return;
}
token = result.data.access_token;
localStorage.setItem("minerops_token", token);
setLoginStatus("Logged in. Token expires in " + result.data.expires_in + " seconds.", "ok");
}
async function runImport(dryRun) {
if (!token) {
setImportStatus("Please log in first.", "bad");
return;
}
const fileInput = document.getElementById("csvFile");
const file = fileInput.files[0];
if (!file) {
setImportStatus("Please choose a CSV file first.", "bad");
return;
}
const updateExisting = document.getElementById("updateExisting").checked;
const formData = new FormData();
formData.append("file", file);
const url = "/api/imports/whatsminer-tool-scan?dry_run=" + dryRun + "&update_existing=" + updateExisting;
const response = await fetch(url, {
method: "POST",
headers: {
"Authorization": "Bearer " + token
},
body: formData
});
const result = await response.json();
showRaw(result);
if (!response.ok) {
setImportStatus("Import failed: " + (result.detail || "Unknown error"), "bad");
return;
}
updateSummary(result.data);
if (dryRun) {
if (result.data.rows_with_updates > 0) {
setImportStatus("Preview complete. Updates are available. Review before applying.", "warn");
document.getElementById("applyButton").disabled = false;
} else {
setImportStatus("Preview complete. No updates needed.", "ok");
document.getElementById("applyButton").disabled = true;
}
} else {
setImportStatus("Import applied. Updates applied: " + result.data.updates_applied, "ok");
document.getElementById("applyButton").disabled = true;
}
}
function previewImport() {
runImport(true);
}
function applyImport() {
const ok = confirm("Apply CSV updates to MinerOps inventory?");
if (!ok) {
return;
}
runImport(false);
}
if (token) {
setLoginStatus("Saved token found in this browser.", "ok");
}
</script>
<script src="/assets/minerops-miner-detail-extra.js?v=1"></script>
</body>
</html>
"""
class ScheduledBulkActionCreateRequest(BaseModel):
name: str = ""
target_type: str = "unit"
target_value: str = ""
action: str = "performance_mode"
performance_mode: str = ""
repeat_rule: str = "once"
scheduled_for: str
def minerops_ensure_scheduled_bulk_actions_table():
conn = None
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS minerops_scheduled_bulk_actions (
id TEXT PRIMARY KEY,
name TEXT,
target_type TEXT NOT NULL,
target_value TEXT,
action TEXT NOT NULL DEFAULT 'performance_mode',
performance_mode TEXT,
repeat_rule TEXT NOT NULL DEFAULT 'once',
scheduled_for TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
agent_id TEXT NOT NULL DEFAULT 'plexus-windows-agent',
requested_by_user_id TEXT,
requested_by_username TEXT,
queued_command_count INTEGER NOT NULL DEFAULT 0,
last_queued_command_count INTEGER NOT NULL DEFAULT 0,
run_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ
);
ALTER TABLE minerops_scheduled_bulk_actions
ALTER COLUMN performance_mode DROP NOT NULL;
ALTER TABLE minerops_scheduled_bulk_actions
ADD COLUMN IF NOT EXISTS repeat_rule TEXT NOT NULL DEFAULT 'once',
ADD COLUMN IF NOT EXISTS last_queued_command_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS run_count INTEGER NOT NULL DEFAULT 0;
UPDATE minerops_scheduled_bulk_actions
SET repeat_rule = 'once'
WHERE repeat_rule IS NULL OR repeat_rule = '';
CREATE INDEX IF NOT EXISTS idx_minerops_scheduled_bulk_actions_due
ON minerops_scheduled_bulk_actions(status, scheduled_for);
CREATE INDEX IF NOT EXISTS idx_minerops_scheduled_bulk_actions_created
ON minerops_scheduled_bulk_actions(created_at DESC);
"""
)
conn.commit()
finally:
if conn is not None:
conn.close()
def minerops_next_scheduled_run(current_dt, repeat_rule):
import datetime as _datetime
repeat_rule = (repeat_rule or "once").lower()
if repeat_rule == "once":
return None
candidate = current_dt + _datetime.timedelta(days=1)
if repeat_rule == "daily":
return candidate
if repeat_rule == "weekdays":
while candidate.weekday() >= 5:
candidate = candidate + _datetime.timedelta(days=1)
return candidate
if repeat_rule == "weekends":
while candidate.weekday() < 5:
candidate = candidate + _datetime.timedelta(days=1)
return candidate
return None
def minerops_scheduled_action_label(action, performance_mode):
action = (action or "").lower()
performance_mode = (performance_mode or "").lower()
if action == "performance_mode":
return f"Set {performance_mode} mode"
if action == "suspend":
return "Suspend"
if action == "resume":
return "Resume"
return action or "unknown"
def minerops_process_due_scheduled_bulk_actions():
import json as _json
import uuid as _uuid
minerops_ensure_scheduled_bulk_actions_table()
conn = None
processed = []
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT *
FROM minerops_scheduled_bulk_actions
WHERE status = 'pending'
AND scheduled_for <= now()
ORDER BY scheduled_for ASC
LIMIT 10
FOR UPDATE SKIP LOCKED;
"""
)
due_actions = [dict(row) for row in cur.fetchall()]
for action in due_actions:
action_id = action["id"]
target_type = (action.get("target_type") or "").lower()
target_value = action.get("target_value") or ""
command_action = (action.get("action") or "performance_mode").lower()
performance_mode = (action.get("performance_mode") or "").lower()
repeat_rule = (action.get("repeat_rule") or "once").lower()
try:
if command_action not in ("performance_mode", "suspend", "resume"):
raise ValueError("Invalid scheduled action.")
if command_action == "performance_mode" and performance_mode not in ("low", "normal", "high"):
raise ValueError("Invalid performance mode.")
cur.execute(
"""
UPDATE minerops_scheduled_bulk_actions
SET status = 'running',
updated_at = now(),
last_error = NULL
WHERE id = %s;
""",
(action_id,),
)
params = []
where_extra = ""
if target_type == "all":
where_extra = ""
elif target_type == "unit":
where_extra = "AND rooms.name = %s"
params.append(target_value)
elif target_type == "model":
where_extra = "AND (miners.miner_type = %s OR miners.model = %s)"
params.extend([target_value, target_value])
elif target_type == "configured_mode":
where_extra = "AND lower(COALESCE(miners.configured_performance_mode, '')) = %s"
params.append(target_value.lower())
elif target_type == "selected_miners":
selected_ids = [
item.strip()
for item in str(target_value or "").replace("\n", ",").split(",")
if item.strip()
]
if not selected_ids:
raise ValueError("No selected miners were saved with this schedule.")
placeholders = ",".join(["%s"] * len(selected_ids))
where_extra = f"AND miners.id::text IN ({placeholders})"
params.extend(selected_ids)
else:
raise ValueError("Invalid target type.")
cur.execute(
f"""
SELECT
miners.id::text AS miner_id,
host(miners.current_ip) AS ip_address,
miners.current_mac AS mac_address,
rooms.name AS room_name
FROM miners
LEFT JOIN rooms ON rooms.id = miners.room_id
WHERE miners.current_ip IS NOT NULL
{where_extra}
ORDER BY rooms.sort_order, miners.current_ip;
""",
tuple(params),
)
miners = [dict(row) for row in cur.fetchall()]
queued = 0
for miner in miners:
payload = {
"miner_id": miner["miner_id"],
"action": command_action,
"performance_mode": performance_mode if command_action == "performance_mode" else "",
"ip_address": miner["ip_address"],
"current_ip": miner["ip_address"],
"mac_address": miner["mac_address"],
"dry_run": False,
"scheduled_action_id": action_id,
"scheduled_action_name": action.get("name") or "",
}
cur.execute(
"""
INSERT INTO minerops_agent_command_queue (
id,
agent_id,
command_type,
command_status,
requested_by_user_id,
requested_by_username,
request_payload
)
VALUES (%s, %s, 'miner_control', 'pending', %s, %s, %s::jsonb);
""",
(
str(_uuid.uuid4()),
action.get("agent_id") or "plexus-windows-agent",
action.get("requested_by_user_id"),
action.get("requested_by_username") or "scheduler",
_json.dumps(payload),
),
)
queued += 1
next_run_at = minerops_next_scheduled_run(action["scheduled_for"], repeat_rule)
if next_run_at is None:
cur.execute(
"""
UPDATE minerops_scheduled_bulk_actions
SET status = 'queued',
queued_command_count = queued_command_count + %s,
last_queued_command_count = %s,
run_count = run_count + 1,
processed_at = now(),
updated_at = now(),
last_error = NULL
WHERE id = %s;
""",
(queued, queued, action_id),
)
final_status = "queued"
else:
cur.execute(
"""
UPDATE minerops_scheduled_bulk_actions
SET status = 'pending',
scheduled_for = %s,
queued_command_count = queued_command_count + %s,
last_queued_command_count = %s,
run_count = run_count + 1,
processed_at = now(),
updated_at = now(),
last_error = NULL
WHERE id = %s;
""",
(next_run_at, queued, queued, action_id),
)
final_status = "pending-next-run"
processed.append({"id": action_id, "queued": queued, "status": final_status})
except Exception as exc:
cur.execute(
"""
UPDATE minerops_scheduled_bulk_actions
SET status = 'failed',
processed_at = now(),
updated_at = now(),
last_error = %s
WHERE id = %s;
""",
(str(exc), action_id),
)
processed.append({"id": action_id, "queued": 0, "status": "failed", "error": str(exc)})
conn.commit()
return processed
finally:
if conn is not None:
conn.close()
def minerops_reconcile_scheduled_bulk_action_statuses():
minerops_ensure_scheduled_bulk_actions_table()
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
s.id,
COUNT(q.id) AS total_commands,
COUNT(q.id) FILTER (
WHERE q.command_status NOT IN ('completed', 'failed', 'cancelled')
) AS active_commands,
COUNT(q.id) FILTER (WHERE q.command_status = 'completed') AS completed_commands,
COUNT(q.id) FILTER (WHERE q.command_status = 'failed') AS failed_commands,
COUNT(q.id) FILTER (WHERE q.command_status = 'cancelled') AS cancelled_commands
FROM minerops_scheduled_bulk_actions s
LEFT JOIN minerops_agent_command_queue q
ON q.request_payload ->> 'scheduled_action_id' = s.id
WHERE s.status IN ('queued', 'running')
GROUP BY s.id;
"""
)
rows = [dict(row) for row in cur.fetchall()]
for row in rows:
total = int(row.get("total_commands") or 0)
active = int(row.get("active_commands") or 0)
completed = int(row.get("completed_commands") or 0)
failed = int(row.get("failed_commands") or 0)
cancelled = int(row.get("cancelled_commands") or 0)
if total <= 0:
continue
if active > 0:
continue
if completed == total:
new_status = "completed"
last_error = None
else:
new_status = "completed_with_errors"
last_error = f"Completed with errors: {completed} completed, {failed} failed, {cancelled} cancelled."
cur.execute(
"""
UPDATE minerops_scheduled_bulk_actions
SET status = %s,
processed_at = COALESCE(processed_at, now()),
updated_at = now(),
last_error = %s
WHERE id = %s
AND status IN ('queued', 'running');
""",
(new_status, last_error, row["id"]),
)
conn.commit()
finally:
if conn is not None:
conn.close()
class M30CommandTestRequest(BaseModel):
miner_id: str
test_action: str
def minerops_ensure_pool_info_table():
conn = None
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS miner_pool_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
miner_id UUID,
ip_address INET,
mac_address TEXT,
model TEXT,
pool_index INTEGER,
url TEXT,
worker_user TEXT,
pool_status TEXT,
stratum_active BOOLEAN,
accepted DOUBLE PRECISION,
rejected DOUBLE PRECISION,
stale DOUBLE PRECISION,
get_failures DOUBLE PRECISION,
remote_failures DOUBLE PRECISION,
pool_rejected_percent DOUBLE PRECISION,
pool_stale_percent DOUBLE PRECISION,
last_share_time_epoch DOUBLE PRECISION,
raw_pool_json JSONB NOT NULL DEFAULT '{}'::jsonb,
agent_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DO $$
DECLARE
id_type TEXT;
BEGIN
SELECT data_type INTO id_type
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'miner_pool_snapshots'
AND column_name = 'id';
IF id_type = 'text' THEN
ALTER TABLE miner_pool_snapshots
ALTER COLUMN id SET DEFAULT (gen_random_uuid())::text;
ELSIF id_type = 'uuid' THEN
ALTER TABLE miner_pool_snapshots
ALTER COLUMN id SET DEFAULT gen_random_uuid();
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_miner_created
ON miner_pool_snapshots (miner_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_ip_created
ON miner_pool_snapshots (ip_address, created_at DESC);
"""
)
conn.commit()
finally:
if conn is not None:
conn.close()
@app.post("/api/agent/pool-snapshots")
async def agent_pool_snapshots(request: Request):
import json as _json
import os as _os
import uuid as _uuid
expected_secret = (_os.getenv("MINEROPS_AGENT_SHARED_SECRET") or "").strip()
provided_secret = (
request.headers.get("X-MinerOps-Agent-Secret")
or request.headers.get("x-minerops-agent-secret")
or ""
).strip()
if expected_secret and provided_secret != expected_secret:
raise HTTPException(status_code=401, detail="Invalid agent secret.")
body = await request.json()
agent_id = body.get("agent_id") or ""
snapshots = body.get("pool_snapshots") or body.get("snapshots") or []
minerops_ensure_pool_info_table()
inserted = 0
matched = 0
conn = None
def fnum(value):
try:
if value is None or value == "":
return None
return float(value)
except Exception:
return None
def fint(value):
try:
if value is None or value == "":
return None
return int(float(value))
except Exception:
return None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
for snap in snapshots:
ip = (snap.get("ip_address") or snap.get("ip") or "").strip()
mac = (snap.get("mac_address") or snap.get("mac") or "").strip()
model = snap.get("model") or ""
pools = snap.get("pools") or []
if isinstance(pools, dict):
pools = [pools]
if not isinstance(pools, list):
pools = []
if not ip or not pools:
continue
miner_id = None
if mac:
cur.execute(
"""
SELECT id
FROM miners
WHERE upper(current_mac) = upper(%s)
OR host(current_ip) = %s
LIMIT 1;
""",
(mac, ip),
)
else:
cur.execute(
"""
SELECT id
FROM miners
WHERE host(current_ip) = %s
LIMIT 1;
""",
(ip,),
)
row = cur.fetchone()
if row:
miner_id = row["id"]
matched += 1
for pool in pools:
if not isinstance(pool, dict):
continue
pool_index = fint(pool.get("POOL") or pool.get("pool") or pool.get("Pool"))
cur.execute(
"""
INSERT INTO miner_pool_snapshots (
miner_id,
ip_address,
mac_address,
model,
pool_index,
url,
worker_user,
pool_status,
stratum_active,
accepted,
rejected,
stale,
get_failures,
remote_failures,
pool_rejected_percent,
pool_stale_percent,
last_share_time_epoch,
raw_pool_json,
agent_id
)
VALUES (
%s,
%s::inet,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s::jsonb,
%s
);
""",
(
miner_id,
ip,
mac,
model,
pool_index,
pool.get("URL") or pool.get("url"),
pool.get("User") or pool.get("user"),
pool.get("Status") or pool.get("status"),
pool.get("Stratum Active") if "Stratum Active" in pool else pool.get("stratum_active"),
fnum(pool.get("Accepted")),
fnum(pool.get("Rejected")),
fnum(pool.get("Stale")),
fnum(pool.get("Get Failures")),
fnum(pool.get("Remote Failures")),
fnum(pool.get("Pool Rejected%")),
fnum(pool.get("Pool Stale%")),
fnum(pool.get("Last Share Time")),
_json.dumps(pool),
agent_id,
),
)
inserted += 1
conn.commit()
return api_response(
data={
"inserted": inserted,
"matched": matched,
"received_miners": len(snapshots),
},
meta={"message": "Pool snapshots saved."},
)
finally:
if conn is not None:
conn.close()
@app.get("/api/dashboard/pool-info-latest")
def dashboard_pool_info_latest(current_user: dict = Depends(get_current_user)):
minerops_ensure_pool_info_table()
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
WITH latest_pool AS (
SELECT DISTINCT ON (miner_id, ip_address, pool_index)
*
FROM miner_pool_snapshots
ORDER BY miner_id, ip_address, pool_index, created_at DESC
)
SELECT
lp.id,
lp.miner_id::text AS miner_id,
rooms.name AS room_name,
host(COALESCE(m.current_ip, lp.ip_address)) AS current_ip,
COALESCE(m.current_mac, lp.mac_address) AS current_mac,
COALESCE(m.miner_type, m.model, lp.model) AS model,
lp.pool_index,
lp.url,
lp.worker_user,
lp.pool_status,
lp.stratum_active,
lp.accepted,
lp.rejected,
lp.stale,
lp.get_failures,
lp.remote_failures,
lp.pool_rejected_percent,
lp.pool_stale_percent,
to_timestamp(lp.last_share_time_epoch) AS last_share_time,
lp.created_at
FROM latest_pool lp
LEFT JOIN miners m ON m.id = lp.miner_id
LEFT JOIN rooms ON rooms.id = m.room_id
ORDER BY rooms.name NULLS LAST, COALESCE(m.current_mac, lp.mac_address), lp.pool_index;
"""
)
rows = [dict(row) for row in cur.fetchall()]
return api_response(data={"pool_info": rows})
finally:
if conn is not None:
conn.close()
@app.get("/api/dashboard/runtime-fields-latest")
def dashboard_runtime_fields_latest(current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
m.id::text AS miner_id,
rooms.name AS room_name,
host(m.current_ip) AS current_ip,
m.current_mac,
m.status,
COALESCE(m.miner_type, m.model) AS model,
m.miner_sn,
s.created_at AS latest_snapshot,
s.fan_speed_in,
s.fan_speed_out,
s.env_temp_c,
s.uptime_seconds,
s.elapsed_seconds
FROM miners m
LEFT JOIN rooms ON rooms.id = m.room_id
LEFT JOIN LATERAL (
SELECT *
FROM miner_status_snapshots snap
WHERE snap.miner_id = m.id
ORDER BY snap.created_at DESC
LIMIT 1
) s ON true
ORDER BY rooms.name NULLS LAST, COALESCE(m.miner_type, m.model), m.current_mac;
"""
)
rows = [dict(row) for row in cur.fetchall()]
return api_response(data={"runtime_fields": rows})
finally:
if conn is not None:
conn.close()
@app.get("/api/dashboard/m30-command-test-lab")
def dashboard_m30_command_test_lab(current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
q.id,
q.command_status,
q.requested_by_username,
q.request_payload ->> 'test_action' AS test_action,
q.request_payload ->> 'action' AS action,
q.request_payload ->> 'performance_mode' AS performance_mode,
q.created_at,
q.claimed_at,
q.completed_at,
q.error_message,
left(COALESCE(q.result_payload::text, ''), 700) AS result_preview,
rooms.name AS room_name,
m.current_mac,
host(m.current_ip) AS current_ip,
COALESCE(m.miner_type, m.model) AS model
FROM minerops_agent_command_queue q
LEFT JOIN miners m
ON m.id = CASE
WHEN q.request_payload ->> 'miner_id' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
THEN (q.request_payload ->> 'miner_id')::uuid
ELSE NULL
END
LEFT JOIN rooms ON rooms.id = m.room_id
WHERE q.command_type = 'miner_control'
AND q.request_payload ->> 'test_lab' = 'm30'
ORDER BY q.created_at DESC
LIMIT 40;
"""
)
recent_tests = [dict(row) for row in cur.fetchall()]
return api_response(data={"recent_tests": recent_tests})
finally:
if conn is not None:
conn.close()
@app.post("/api/dashboard/m30-command-test-lab/run")
def run_dashboard_m30_command_test_lab(request: M30CommandTestRequest, current_user: dict = Depends(get_current_user)):
import json as _json
import uuid as _uuid
miner_id = (request.miner_id or "").strip()
test_action = (request.test_action or "").strip().lower()
action = test_action
performance_mode = ""
control_method = "api3"
legacy_4028_command = ""
legacy_4028_json = ""
legacy_4028_actions = {
"legacy_4028_summary": "summary",
"legacy_4028_version": "version",
"legacy_4028_pools": "pools",
"legacy_4028_devs": "devs",
"legacy_4028_stats": "stats",
"legacy_4028_reboot": "reboot",
"legacy_4028_restart": "restart",
}
if test_action == "low":
action = "performance_mode"
performance_mode = "low"
elif test_action == "normal":
action = "performance_mode"
performance_mode = "normal"
elif test_action == "high":
action = "performance_mode"
performance_mode = "high"
elif test_action in legacy_4028_actions:
action = "legacy_4028"
control_method = "legacy_4028"
legacy_4028_command = legacy_4028_actions[test_action]
legacy_4028_json = _json.dumps({"command": legacy_4028_command})
if action not in ("reboot", "suspend", "resume", "performance_mode", "legacy_4028"):
raise HTTPException(status_code=400, detail="Invalid M30 test action.")
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
miners.id::text AS miner_id,
host(miners.current_ip) AS ip_address,
miners.current_mac AS mac_address,
rooms.name AS room_name,
COALESCE(miners.miner_type, miners.model) AS model
FROM miners
LEFT JOIN rooms ON rooms.id = miners.room_id
WHERE miners.id = %s::uuid;
""",
(miner_id,),
)
miner = cur.fetchone()
if miner is None:
raise HTTPException(status_code=404, detail="Miner not found.")
if not miner.get("ip_address"):
raise HTTPException(status_code=400, detail="Miner has no current IP address.")
payload = {
"miner_id": miner["miner_id"],
"action": action,
"performance_mode": performance_mode,
"control_method": control_method,
"legacy_4028_command": legacy_4028_command,
"legacy_4028_json": legacy_4028_json,
"ip_address": miner["ip_address"],
"current_ip": miner["ip_address"],
"mac_address": miner["mac_address"],
"dry_run": False,
"test_lab": "m30",
"test_action": test_action,
"model": miner.get("model") or "",
}
command_id = str(_uuid.uuid4())
username = current_user.get("username") or current_user.get("email") or "unknown"
user_id = str(current_user.get("id") or "")
cur.execute(
"""
INSERT INTO minerops_agent_command_queue (
id,
agent_id,
command_type,
command_status,
requested_by_user_id,
requested_by_username,
request_payload
)
VALUES (%s, 'plexus-windows-agent', 'miner_control', 'pending', %s, %s, %s::jsonb);
""",
(
command_id,
user_id,
username,
_json.dumps(payload),
),
)
conn.commit()
return api_response(
data={
"command_id": command_id,
"miner": dict(miner),
"test_action": test_action,
},
meta={"message": "M30 command test queued."},
)
finally:
if conn is not None:
conn.close()
@app.get("/api/dashboard/scheduled-bulk-actions")
def dashboard_scheduled_bulk_actions(current_user: dict = Depends(get_current_user)):
minerops_ensure_scheduled_bulk_actions_table()
minerops_reconcile_scheduled_bulk_action_statuses()
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
id,
name,
target_type,
target_value,
action,
performance_mode,
repeat_rule,
scheduled_for,
status,
queued_command_count,
last_queued_command_count,
run_count,
last_error,
requested_by_username,
created_at,
updated_at,
processed_at
FROM minerops_scheduled_bulk_actions
ORDER BY
CASE WHEN status = 'pending' THEN 0 ELSE 1 END,
scheduled_for ASC,
created_at DESC
LIMIT 80;
"""
)
schedules = [dict(row) for row in cur.fetchall()]
schedule_ids = [row.get("id") for row in schedules if row.get("id")]
command_counts_by_schedule = {}
if schedule_ids:
cur.execute(
"""
SELECT
request_payload ->> 'scheduled_action_id' AS scheduled_action_id,
COUNT(*) AS command_total_count,
COUNT(*) FILTER (WHERE command_status = 'pending') AS command_pending_count,
COUNT(*) FILTER (WHERE command_status = 'completed') AS command_completed_count,
COUNT(*) FILTER (WHERE command_status = 'failed') AS command_failed_count,
COUNT(*) FILTER (WHERE command_status = 'cancelled') AS command_cancelled_count,
COUNT(*) FILTER (WHERE command_status NOT IN ('completed', 'failed', 'cancelled')) AS command_active_count
FROM minerops_agent_command_queue
WHERE request_payload ->> 'scheduled_action_id' = ANY(%s)
GROUP BY request_payload ->> 'scheduled_action_id';
""",
(schedule_ids,),
)
command_counts_by_schedule = {
row["scheduled_action_id"]: dict(row)
for row in cur.fetchall()
}
for schedule in schedules:
counts = command_counts_by_schedule.get(schedule.get("id"), {})
schedule["command_total_count"] = int(counts.get("command_total_count") or 0)
schedule["command_pending_count"] = int(counts.get("command_pending_count") or 0)
schedule["command_completed_count"] = int(counts.get("command_completed_count") or 0)
schedule["command_failed_count"] = int(counts.get("command_failed_count") or 0)
schedule["command_cancelled_count"] = int(counts.get("command_cancelled_count") or 0)
schedule["command_active_count"] = int(counts.get("command_active_count") or 0)
cur.execute(
"""
SELECT name
FROM rooms
ORDER BY sort_order, name;
"""
)
units = [row["name"] for row in cur.fetchall()]
cur.execute(
"""
SELECT DISTINCT COALESCE(miner_type, model) AS model_name
FROM miners
WHERE COALESCE(miner_type, model) IS NOT NULL
AND COALESCE(miner_type, model) <> ''
ORDER BY COALESCE(miner_type, model);
"""
)
models = [row["model_name"] for row in cur.fetchall()]
return api_response(
data={
"schedules": schedules,
"units": units,
"models": models,
}
)
finally:
if conn is not None:
conn.close()
@app.post("/api/dashboard/scheduled-bulk-actions")
def create_dashboard_scheduled_bulk_action(request: ScheduledBulkActionCreateRequest, current_user: dict = Depends(get_current_user)):
import datetime as _datetime
import uuid as _uuid
minerops_ensure_scheduled_bulk_actions_table()
target_type = (request.target_type or "").strip().lower()
target_value = (request.target_value or "").strip()
command_action = (getattr(request, "action", None) or "performance_mode").strip().lower()
performance_mode = (request.performance_mode or "").strip().lower()
repeat_rule = (getattr(request, "repeat_rule", None) or "once").strip().lower()
if target_type not in ("all", "unit", "model", "configured_mode", "selected_miners"):
raise HTTPException(status_code=400, detail="Invalid target type.")
if target_type != "all" and not target_value:
raise HTTPException(status_code=400, detail="Target value is required.")
if command_action not in ("performance_mode", "suspend", "resume"):
raise HTTPException(status_code=400, detail="Invalid action.")
if command_action == "performance_mode" and performance_mode not in ("low", "normal", "high"):
raise HTTPException(status_code=400, detail="Invalid performance mode.")
if command_action in ("suspend", "resume"):
performance_mode = ""
if repeat_rule not in ("once", "daily", "weekdays", "weekends"):
raise HTTPException(status_code=400, detail="Invalid repeat rule.")
try:
raw_dt = (request.scheduled_for or "").strip().replace("Z", "+00:00")
scheduled_for = _datetime.datetime.fromisoformat(raw_dt)
if scheduled_for.tzinfo is None:
scheduled_for = scheduled_for.replace(tzinfo=_datetime.timezone.utc)
except Exception:
raise HTTPException(status_code=400, detail="Invalid scheduled date/time.")
action_id = str(_uuid.uuid4())
username = current_user.get("username") or current_user.get("email") or "unknown"
user_id = str(current_user.get("id") or "")
conn = None
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO minerops_scheduled_bulk_actions (
id,
name,
target_type,
target_value,
action,
performance_mode,
repeat_rule,
scheduled_for,
status,
requested_by_user_id,
requested_by_username
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', %s, %s);
""",
(
action_id,
request.name or "",
target_type,
target_value,
command_action,
performance_mode,
repeat_rule,
scheduled_for,
user_id,
username,
),
)
conn.commit()
return api_response(
data={"id": action_id},
meta={"message": "Scheduled bulk action created."},
)
finally:
if conn is not None:
conn.close()
@app.post("/api/dashboard/scheduled-bulk-actions/{action_id}/cancel")
def cancel_dashboard_scheduled_bulk_action(action_id: str, current_user: dict = Depends(get_current_user)):
minerops_ensure_scheduled_bulk_actions_table()
conn = None
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute(
"""
UPDATE minerops_agent_command_queue
SET
command_status = 'cancelled',
completed_at = now(),
error_message = COALESCE(error_message, '') || ' Cancelled by admin from scheduled action.'
WHERE request_payload ->> 'scheduled_action_id' = %s
AND command_status = 'pending';
""",
(action_id,),
)
cur.execute(
"""
UPDATE minerops_scheduled_bulk_actions
SET status = 'cancelled',
updated_at = now(),
last_error = CASE
WHEN COALESCE(last_error, '') = '' THEN 'Cancelled by admin.'
ELSE last_error
END
WHERE id = %s
AND status IN ('pending', 'queued', 'running', 'failed')
RETURNING id;
""",
(action_id,),
)
row = cur.fetchone()
conn.commit()
if row is None:
raise HTTPException(status_code=404, detail="Scheduled action not found or cannot be cancelled.")
return api_response(meta={"message": "Scheduled action cancelled."})
finally:
if conn is not None:
conn.close()
@app.post("/api/dashboard/scheduled-bulk-actions/{action_id}/delete")
def delete_dashboard_scheduled_bulk_action(action_id: str, current_user: dict = Depends(get_current_user)):
minerops_ensure_scheduled_bulk_actions_table()
conn = None
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute(
"""
UPDATE minerops_agent_command_queue
SET
command_status = 'cancelled',
completed_at = now(),
error_message = COALESCE(error_message, '') || ' Cancelled because scheduled action was deleted.'
WHERE request_payload ->> 'scheduled_action_id' = %s
AND command_status = 'pending';
""",
(action_id,),
)
cur.execute(
"""
DELETE FROM minerops_scheduled_bulk_actions
WHERE id = %s
RETURNING id;
""",
(action_id,),
)
row = cur.fetchone()
conn.commit()
if row is None:
raise HTTPException(status_code=404, detail="Scheduled action not found.")
return api_response(meta={"message": "Scheduled action deleted."})
finally:
if conn is not None:
conn.close()
@app.post("/api/dashboard/scheduled-bulk-actions/process-due")
def process_due_dashboard_scheduled_bulk_actions(current_user: dict = Depends(get_current_user)):
processed = minerops_process_due_scheduled_bulk_actions()
return api_response(data={"processed": processed})
@app.get("/api/dashboard/bulk-action-status")
def dashboard_bulk_action_status(current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
COALESCE(INITCAP(configured_performance_mode), 'Unknown') AS configured_mode,
COALESCE(configured_performance_mode_source, 'none') AS source,
COUNT(*) AS miner_count
FROM miners
GROUP BY configured_performance_mode, configured_performance_mode_source
ORDER BY configured_mode, source;
"""
)
mode_counts = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT
COALESCE(q.request_payload ->> 'action', 'unknown') AS action,
COALESCE(q.request_payload ->> 'performance_mode', '') AS performance_mode,
q.command_status,
COUNT(*) AS command_count
FROM minerops_agent_command_queue q
WHERE q.command_type = 'miner_control'
AND q.created_at >= now() - interval '6 hours'
GROUP BY
q.request_payload ->> 'action',
q.request_payload ->> 'performance_mode',
q.command_status
ORDER BY command_count DESC, action, performance_mode, command_status;
"""
)
recent_summary = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT
q.id,
q.command_status,
q.requested_by_username,
q.request_payload ->> 'action' AS action,
q.request_payload ->> 'performance_mode' AS performance_mode,
q.created_at,
q.claimed_at,
q.completed_at,
q.error_message,
rooms.name AS room_name,
m.current_mac,
host(m.current_ip) AS current_ip
FROM minerops_agent_command_queue q
LEFT JOIN miners m
ON m.id = CASE
WHEN q.request_payload ->> 'miner_id' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
THEN (q.request_payload ->> 'miner_id')::uuid
ELSE NULL
END
LEFT JOIN rooms ON rooms.id = m.room_id
WHERE q.command_type = 'miner_control'
ORDER BY q.created_at DESC
LIMIT 40;
"""
)
recent_commands = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT
q.id,
q.command_status,
q.requested_by_username,
q.request_payload ->> 'action' AS action,
q.request_payload ->> 'performance_mode' AS performance_mode,
q.created_at,
q.completed_at,
q.error_message,
rooms.name AS room_name,
m.current_mac,
host(m.current_ip) AS current_ip
FROM minerops_agent_command_queue q
LEFT JOIN miners m
ON m.id = CASE
WHEN q.request_payload ->> 'miner_id' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
THEN (q.request_payload ->> 'miner_id')::uuid
ELSE NULL
END
LEFT JOIN rooms ON rooms.id = m.room_id
WHERE q.command_type = 'miner_control'
AND (
q.command_status <> 'completed'
OR COALESCE(q.error_message, '') <> ''
)
ORDER BY q.created_at DESC
LIMIT 12;
"""
)
recent_failures = [dict(row) for row in cur.fetchall()]
return api_response(
data={
"configured_mode_counts": mode_counts,
"recent_summary": recent_summary,
"recent_commands": recent_commands,
"recent_failures": recent_failures,
}
)
finally:
if conn is not None:
conn.close()
@app.get("/api/dashboard/miner-table")
def dashboard_miner_table(current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
WITH latest AS (
SELECT DISTINCT ON (miner_id)
miner_id,
observed_at,
observed_ip,
mhs_av,
power_w,
power_rate_w_per_th,
chip_temp_max_c,
chip_temp_avg_c,
fan_speed_in_rpm,
fan_speed_out_rpm,
hash_stable,
pool_rejected_percent,
COALESCE(
raw_summary_json #>> '{Msg,Power Mode}',
raw_summary_json #>> '{MSG,Power Mode}',
raw_summary_json #>> '{msg,Power Mode}',
raw_summary_json ->> 'Power Mode',
raw_summary_json ->> 'PowerMode',
raw_summary_json ->> 'power_mode'
) AS runtime_power_state
FROM miner_status_snapshots
ORDER BY miner_id, observed_at DESC
)
SELECT
miners.id::text,
COALESCE(rooms.name, 'Unassigned') AS room_name,
host(miners.current_ip) AS current_ip,
miners.current_mac,
miners.hostname,
miners.miner_sn,
miners.power_sn,
miners.model,
miners.miner_type,
miners.firmware_version,
miners.status,
miners.last_seen_at,
latest.observed_at,
ROUND((latest.mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths,
ROUND(latest.power_w::numeric, 2)::float AS power_w,
ROUND(latest.power_rate_w_per_th::numeric, 2)::float AS power_rate_w_per_th,
ROUND(latest.chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c,
ROUND(latest.chip_temp_avg_c::numeric, 2)::float AS chip_temp_avg_c,
latest.fan_speed_in_rpm,
latest.fan_speed_out_rpm,
latest.hash_stable,
INITCAP(miners.configured_performance_mode) AS performance_mode,
miners.configured_performance_mode_source,
miners.configured_performance_mode_updated_at,
latest.runtime_power_state,
ROUND(latest.pool_rejected_percent::numeric, 4)::float AS pool_rejected_percent
FROM miners
LEFT JOIN rooms ON rooms.id = miners.room_id
LEFT JOIN latest ON latest.miner_id = miners.id
ORDER BY rooms.sort_order, miners.current_ip;
"""
)
rows = [dict(row) for row in cur.fetchall()]
return api_response(data=rows)
finally:
if conn is not None:
conn.close()
@app.get("/api/dashboard/hot-miners")
def dashboard_hot_miners(
min_temp_c: float = 90,
limit: int = 100,
current_user: dict = Depends(get_current_user),
):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
WITH latest AS (
SELECT DISTINCT ON (miner_id)
miner_id,
observed_at,
chip_temp_max_c,
chip_temp_avg_c,
power_w,
mhs_av,
hash_stable
FROM miner_status_snapshots
ORDER BY miner_id, observed_at DESC
)
SELECT
miners.id::text,
COALESCE(rooms.name, 'Unassigned') AS room_name,
host(miners.current_ip) AS current_ip,
miners.current_mac,
miners.model,
miners.miner_type,
miners.firmware_version,
miners.status,
latest.observed_at,
ROUND(latest.chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c,
ROUND(latest.chip_temp_avg_c::numeric, 2)::float AS chip_temp_avg_c,
ROUND(latest.power_w::numeric, 2)::float AS power_w,
ROUND((latest.mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths,
latest.hash_stable
FROM miners
LEFT JOIN rooms ON rooms.id = miners.room_id
JOIN latest ON latest.miner_id = miners.id
WHERE latest.chip_temp_max_c >= %s
ORDER BY latest.chip_temp_max_c DESC
LIMIT %s;
""",
(min_temp_c, limit),
)
rows = [dict(row) for row in cur.fetchall()]
return api_response(data=rows)
finally:
if conn is not None:
conn.close()
@app.get("/api/dashboard/unstable-miners")
def dashboard_unstable_miners(
limit: int = 100,
current_user: dict = Depends(get_current_user),
):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
WITH latest AS (
SELECT DISTINCT ON (miner_id)
miner_id,
observed_at,
chip_temp_max_c,
power_w,
mhs_av,
hash_stable,
pool_rejected_percent
FROM miner_status_snapshots
ORDER BY miner_id, observed_at DESC
)
SELECT
miners.id::text,
COALESCE(rooms.name, 'Unassigned') AS room_name,
host(miners.current_ip) AS current_ip,
miners.current_mac,
miners.model,
miners.miner_type,
miners.firmware_version,
miners.status,
latest.observed_at,
ROUND((latest.mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths,
ROUND(latest.power_w::numeric, 2)::float AS power_w,
ROUND(latest.chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c,
latest.hash_stable,
ROUND(latest.pool_rejected_percent::numeric, 4)::float AS pool_rejected_percent
FROM miners
LEFT JOIN rooms ON rooms.id = miners.room_id
JOIN latest ON latest.miner_id = miners.id
WHERE latest.hash_stable = false
OR latest.pool_rejected_percent >= 1
ORDER BY latest.hash_stable ASC, latest.pool_rejected_percent DESC NULLS LAST, latest.chip_temp_max_c DESC NULLS LAST
LIMIT %s;
""",
(limit,),
)
rows = [dict(row) for row in cur.fetchall()]
return api_response(data=rows)
finally:
if conn is not None:
conn.close()
@app.get("/api/dashboard/stale-miners")
def dashboard_stale_miners(
minutes: int = 30,
limit: int = 200,
current_user: dict = Depends(get_current_user),
):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
miners.id::text,
COALESCE(rooms.name, 'Unassigned') AS room_name,
host(miners.current_ip) AS current_ip,
miners.current_mac,
miners.model,
miners.miner_type,
miners.firmware_version,
miners.status,
miners.last_seen_at,
ROUND(EXTRACT(EPOCH FROM (now() - miners.last_seen_at)) / 60.0, 1)::float AS minutes_since_seen
FROM miners
LEFT JOIN rooms ON rooms.id = miners.room_id
WHERE miners.last_seen_at < now() - make_interval(mins => %s)
ORDER BY miners.last_seen_at ASC
LIMIT %s;
""",
(minutes, limit),
)
rows = [dict(row) for row in cur.fetchall()]
return api_response(data=rows)
finally:
if conn is not None:
conn.close()
MINEROPS_UNIFIED_CSS = r"""
:root {
--minerops-bg: #f4f7fb;
--minerops-card: #ffffff;
--minerops-border: #d9e2ec;
--minerops-text: #102a43;
--minerops-muted: #52606d;
--minerops-blue: #0b63ce;
--minerops-blue-dark: #084b9a;
--minerops-green: #0f766e;
--minerops-red: #dc2626;
}
body {
background: var(--minerops-bg) !important;
color: var(--minerops-text) !important;
font-family: "Segoe UI", Arial, sans-serif !important;
margin: 0 !important;
padding: 20px !important;
}
/* Hide old page headers/navs that made the pages look duplicated */
body > header:not(.minerops-shell-header) {
display: none !important;
}
body > h1 {
display: none !important;
}
body > .topbar {
display: none !important;
}
.app-header {
display: none !important;
}
#dashboardLocationMapLink {
display: none !important;
}
.minerops-shell-header {
background: linear-gradient(135deg, #0b63ce, #0f766e);
color: white;
border-radius: 16px;
padding: 18px 20px;
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.18);
}
.minerops-kicker {
font-size: 13px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.86;
margin-bottom: 4px;
}
.minerops-shell-header h1 {
display: block !important;
margin: 0;
color: white;
font-size: 28px;
line-height: 1.1;
}
.minerops-subtitle {
margin-top: 6px;
font-size: 14px;
opacity: 0.9;
}
.minerops-nav {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.minerops-nav-link {
display: inline-block;
border: 1px solid rgba(255, 255, 255, 0.45);
background: rgba(255, 255, 255, 0.14);
color: white !important;
border-radius: 999px;
padding: 8px 12px;
font-weight: 800;
font-size: 13px;
text-decoration: none !important;
}
.minerops-nav-link:hover {
background: rgba(255, 255, 255, 0.24);
}
.minerops-nav-link.active {
background: white;
color: var(--minerops-blue) !important;
}
.card {
background: var(--minerops-card) !important;
border: 1px solid var(--minerops-border) !important;
border-radius: 14px !important;
padding: 16px !important;
margin-bottom: 16px !important;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06) !important;
}
.card h2 {
color: var(--minerops-text) !important;
margin-top: 0 !important;
}
.small,
.muted {
color: var(--minerops-muted) !important;
}
button,
input[type="button"],
input[type="submit"],
.issue-action-button {
background: var(--minerops-blue) !important;
color: white !important;
border: 0 !important;
border-radius: 8px !important;
padding: 8px 11px !important;
font-weight: 800 !important;
cursor: pointer !important;
text-decoration: none !important;
}
button:hover,
input[type="button"]:hover,
input[type="submit"]:hover,
.issue-action-button:hover {
background: var(--minerops-blue-dark) !important;
}
button.secondary {
background: #64748b !important;
}
button.danger,
.danger {
background: var(--minerops-red) !important;
color: white !important;
}
input,
select,
textarea {
border: 1px solid #bcccdc !important;
border-radius: 8px !important;
padding: 8px !important;
background: white !important;
color: var(--minerops-text) !important;
}
table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 10px;
overflow: hidden;
}
th {
background: #eef2ff !important;
color: #1e293b !important;
text-align: left;
font-size: 13px;
}
th,
td {
border-bottom: 1px solid #e2e8f0 !important;
padding: 8px !important;
vertical-align: top;
}
tr:hover td {
background: #f8fafc !important;
}
.status {
border-radius: 10px !important;
padding: 10px 12px !important;
margin-top: 10px !important;
border: 1px solid var(--minerops-border) !important;
background: #f8fafc !important;
}
.status.ok {
background: #ecfdf5 !important;
border-color: #6ee7b7 !important;
}
.status.warn {
background: #fff7ed !important;
border-color: #fdba74 !important;
}
.status.bad {
background: #fef2f2 !important;
border-color: #fca5a5 !important;
}
.sticky-mini-toolbar,
.card-toolbar {
background: rgba(255, 255, 255, 0.96) !important;
border: 1px solid var(--minerops-border) !important;
border-radius: 14px !important;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.10) !important;
padding: 10px !important;
}
.sticky-mini-toolbar {
top: 8px !important;
}
/* Step 80: Header auth controls */
.minerops-auth {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-left: 8px;
}
.minerops-auth input {
width: 115px !important;
min-width: 90px !important;
padding: 7px 8px !important;
border-radius: 999px !important;
border: 1px solid rgba(255, 255, 255, 0.55) !important;
background: rgba(255, 255, 255, 0.92) !important;
font-size: 12px !important;
}
.minerops-auth button {
border-radius: 999px !important;
padding: 7px 10px !important;
font-size: 12px !important;
background: rgba(255, 255, 255, 0.18) !important;
border: 1px solid rgba(255, 255, 255, 0.45) !important;
color: white !important;
}
.minerops-auth button:hover {
background: rgba(255, 255, 255, 0.28) !important;
}
.minerops-auth-user {
color: white;
font-size: 13px;
font-weight: 800;
padding: 7px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
border: 1px solid rgba(255, 255, 255, 0.30);
}
.minerops-auth-message {
color: white;
font-size: 12px;
font-weight: 700;
opacity: 0.92;
}
.minerops-hidden-login-card {
display: none !important;
}
@media (max-width: 950px) {
.minerops-auth {
width: 100%;
margin-left: 0;
}
.minerops-auth input {
width: 140px !important;
}
}
/* Step 83: Global miner search */
.minerops-global-search {
position: relative;
display: flex;
align-items: center;
gap: 6px;
margin-left: 8px;
}
.minerops-global-search input {
width: 230px !important;
min-width: 180px !important;
border-radius: 999px !important;
padding: 8px 12px !important;
border: 1px solid rgba(255, 255, 255, 0.55) !important;
background: rgba(255, 255, 255, 0.95) !important;
font-size: 13px !important;
}
.minerops-search-results {
position: absolute;
top: 40px;
right: 0;
z-index: 5000;
width: min(680px, 92vw);
max-height: 440px;
overflow: auto;
background: white;
color: var(--minerops-text);
border: 1px solid var(--minerops-border);
border-radius: 14px;
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.28);
padding: 8px;
display: none;
}
.minerops-search-results.open {
display: block;
}
.minerops-search-result {
display: grid;
grid-template-columns: 150px 110px 1fr;
gap: 8px;
padding: 9px;
border-radius: 10px;
cursor: pointer;
border-bottom: 1px solid #e2e8f0;
}
.minerops-search-result:hover,
.minerops-search-result.active {
background: #eff6ff;
}
.minerops-search-result-main {
font-weight: 900;
color: #0f172a;
}
.minerops-search-result-sub {
font-size: 12px;
color: #475569;
}
.minerops-search-empty {
padding: 10px;
color: #475569;
font-size: 13px;
}
@media (max-width: 950px) {
.minerops-global-search {
width: 100%;
margin-left: 0;
}
.minerops-global-search input {
width: 100% !important;
}
.minerops-search-results {
left: 0;
right: auto;
width: 92vw;
}
}
/* Step 84: Global scan action */
.minerops-header-scan {
display: flex;
align-items: center;
gap: 6px;
margin-left: 4px;
}
.minerops-scan-button {
background: rgba(255, 255, 255, 0.18) !important;
border: 1px solid rgba(255, 255, 255, 0.45) !important;
color: white !important;
border-radius: 999px !important;
padding: 8px 12px !important;
font-size: 13px !important;
font-weight: 800 !important;
}
.minerops-scan-button:hover {
background: rgba(255, 255, 255, 0.28) !important;
}
.minerops-scan-button.running {
background: #f59e0b !important;
color: #111827 !important;
}
.minerops-scan-status {
color: white;
font-size: 12px;
font-weight: 700;
opacity: 0.92;
max-width: 260px;
}
@media (max-width: 950px) {
.minerops-header-scan {
width: 100%;
margin-left: 0;
}
.minerops-scan-status {
max-width: 100%;
}
}
@media (max-width: 800px) {
body {
padding: 12px !important;
}
.minerops-shell-header {
align-items: flex-start;
flex-direction: column;
}
.minerops-nav {
justify-content: flex-start;
}
}
"""
@app.get("/assets/minerops-unified.css")
async def minerops_unified_css():
return HTMLResponse(content=MINEROPS_UNIFIED_CSS, media_type="text/css")
MINEROPS_SHELL_AUTH_JS = r"""
(function () {
const TOKEN_KEYS = [
"minerops_token",
"access_token",
"token",
"auth_token",
"authToken"
];
const USERNAME_KEY = "minerops_username";
async function apiPatch(url, body) {
const response = await fetch(url, {
method: "PATCH",
credentials: "same-origin",
headers: authHeaders({
"Accept": "application/json",
"Content-Type": "application/json"
}),
body: JSON.stringify(body || {})
});
const payload = await response.json().catch(function () {
return null;
});
if (!response.ok || (payload && payload.error)) {
throw new Error((payload && payload.error) || "Request failed");
}
return payload ? payload.data : null;
}
async function apiDelete(url) {
const response = await fetch(url, {
method: "DELETE",
credentials: "same-origin",
headers: authHeaders({
"Accept": "application/json"
})
});
const payload = await response.json().catch(function () {
return null;
});
if (!response.ok || (payload && payload.error)) {
throw new Error((payload && payload.error) || "Request failed");
}
return payload ? payload.data : null;
}
function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function looksLikeToken(value) {
if (!value || typeof value !== "string") {
return false;
}
if (value.split(".").length === 3 && value.length > 30) {
return true;
}
return value.length > 40 && !value.includes(" ");
}
function findTokenRecursive(value) {
if (!value) {
return "";
}
if (typeof value === "string") {
return looksLikeToken(value) ? value : "";
}
if (Array.isArray(value)) {
for (const item of value) {
const found = findTokenRecursive(item);
if (found) {
return found;
}
}
return "";
}
if (typeof value === "object") {
const preferredKeys = [
"access_token",
"accessToken",
"token",
"jwt",
"auth_token",
"authToken"
];
for (const key of preferredKeys) {
if (looksLikeToken(value[key])) {
return value[key];
}
}
for (const key of Object.keys(value)) {
if (key.toLowerCase().includes("token")) {
const found = findTokenRecursive(value[key]);
if (found) {
return found;
}
}
}
for (const key of Object.keys(value)) {
const found = findTokenRecursive(value[key]);
if (found) {
return found;
}
}
}
return "";
}
function getToken() {
for (const key of TOKEN_KEYS) {
const value = localStorage.getItem(key);
if (value) {
return value;
}
}
return "";
}
function setToken(token) {
for (const key of TOKEN_KEYS) {
localStorage.setItem(key, token);
}
}
function clearToken() {
for (const key of TOKEN_KEYS) {
localStorage.removeItem(key);
}
localStorage.removeItem(USERNAME_KEY);
}
function authHeaders() {
const token = getToken();
if (!token) {
return {};
}
return {
"Authorization": "Bearer " + token
};
}
function findLegacyLoginContainer(heading) {
let node = heading.parentElement;
let best = null;
for (let i = 0; i < 7 && node && node !== document.body; i++) {
const hasPassword = node.querySelector && node.querySelector('input[type="password"]');
const hasClearToken = node.textContent && node.textContent.toLowerCase().includes("clear token");
const isCard = node.classList && (node.classList.contains("card") || node.classList.contains("panel"));
if (hasPassword || hasClearToken) {
best = node;
}
if (isCard) {
best = node;
break;
}
node = node.parentElement;
}
return best || heading.parentElement;
}
function hideLegacyLoginCards() {
document.querySelectorAll("h1, h2, h3").forEach(heading => {
if (heading.closest(".minerops-shell-header")) {
return;
}
const title = heading.textContent.trim().toLowerCase();
if (
title === "login" ||
title === "login / refresh" ||
title === "login/refresh"
) {
const container = findLegacyLoginContainer(heading);
if (container && !container.closest(".minerops-shell-header")) {
container.classList.add("minerops-hidden-login-card");
container.style.display = "none";
}
}
});
}
function setLegacyLoginInputs(username, password) {
const usernameInputs = Array.from(document.querySelectorAll('input[type="text"], input:not([type])'))
.filter(input => !input.closest(".minerops-shell-header"));
const passwordInputs = Array.from(document.querySelectorAll('input[type="password"]'))
.filter(input => !input.closest(".minerops-shell-header"));
for (const input of usernameInputs) {
const idName = ((input.id || "") + " " + (input.name || "") + " " + (input.placeholder || "")).toLowerCase();
if (idName.includes("user") || idName.includes("admin") || idName.includes("login")) {
input.value = username;
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
}
}
for (const input of passwordInputs) {
input.value = password;
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
}
}
async function tryLegacyPageLogin(username, password) {
setLegacyLoginInputs(username, password);
const functionNames = [
"login",
"doLogin",
"loginUser",
"dashboardLogin",
"locationLogin"
];
for (const functionName of functionNames) {
if (typeof window[functionName] === "function") {
try {
const result = window[functionName]();
await Promise.resolve(result);
await delay(800);
if (getToken()) {
return true;
}
} catch (error) {
// Keep trying other methods.
}
}
}
const buttons = Array.from(document.querySelectorAll("button"))
.filter(button => !button.closest(".minerops-shell-header"))
.filter(button => button.textContent.trim().toLowerCase() === "login");
for (const button of buttons) {
try {
button.click();
await delay(1000);
if (getToken()) {
return true;
}
} catch (error) {
// Keep trying.
}
}
return false;
}
async function parseLoginResponse(response) {
const text = await response.text();
let data = null;
try {
data = text ? JSON.parse(text) : {};
} catch (error) {
data = text;
}
if (!response.ok) {
const message = data && typeof data === "object"
? (data.detail || data.message || data.error || "Login failed.")
: (text || "Login failed.");
throw new Error(message);
}
return findTokenRecursive(data) || findTokenRecursive(text);
}
async function tryJsonLogin(username, password) {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
username: username,
password: password
})
});
return await parseLoginResponse(response);
}
async function tryFormLogin(username, password) {
const form = new URLSearchParams();
form.set("username", username);
form.set("password", password);
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: form.toString()
});
return await parseLoginResponse(response);
}
async function getCurrentUser() {
try {
const response = await fetch("/api/auth/me", {
credentials: "same-origin",
headers: authHeaders()
});
if (!response.ok) {
window.location.replace("/login?next=" + encodeURIComponent(window.location.pathname));
return null;
}
const payload = await response.json();
const user = payload && payload.data ? payload.data : payload;
if (user && !getToken()) {
setToken("cookie-session");
}
return user;
} catch (error) {
return null;
}
}
function userDisplayName(user) {
const stored = localStorage.getItem(USERNAME_KEY);
const candidates = [
stored,
user && user.username,
user && user.user_name,
user && user.name,
user && user.email
];
for (const candidate of candidates) {
if (!candidate) {
continue;
}
const value = String(candidate).trim();
if (!value) {
continue;
}
if (value.toLowerCase() === "signed in") {
continue;
}
return value;
}
return "";
}
async function loginFromHeader() {
const username = document.getElementById("mineropsHeaderUsername")?.value || "";
const password = document.getElementById("mineropsHeaderPassword")?.value || "";
const message = document.getElementById("mineropsHeaderAuthMessage");
if (!username || !password) {
if (message) {
message.textContent = "Enter username and password.";
}
return;
}
if (message) {
message.textContent = "Signing in...";
}
let token = "";
let lastError = null;
try {
token = await tryJsonLogin(username, password);
} catch (error) {
lastError = error;
}
if (!token) {
try {
token = await tryFormLogin(username, password);
} catch (error) {
lastError = error;
}
}
if (token) {
setToken(token);
localStorage.setItem(USERNAME_KEY, username);
window.location.reload();
return;
}
if (message) {
message.textContent = "Trying page login...";
}
const legacyWorked = await tryLegacyPageLogin(username, password);
if (legacyWorked || getToken()) {
localStorage.setItem(USERNAME_KEY, username);
window.location.reload();
return;
}
if (message) {
message.textContent = lastError ? lastError.message : "Login failed. No token was returned.";
}
}
async function logoutFromHeader() {
try {
await fetch("/api/auth/logout", {
method: "POST",
credentials: "same-origin"
});
} finally {
clearToken();
window.location.replace("/login");
}
}
async function renderHeaderAuth() {
const nav = document.querySelector(".minerops-nav");
if (!nav) {
return;
}
let authArea = document.getElementById("mineropsHeaderAuth");
if (!authArea) {
authArea = document.createElement("div");
authArea.id = "mineropsHeaderAuth";
authArea.className = "minerops-auth";
nav.appendChild(authArea);
}
const user = await getCurrentUser();
if (user) {
const displayName = userDisplayName(user);
authArea.innerHTML = displayName
? `
<span class="minerops-auth-user">Signed in: ${escapeHtml(displayName)}</span>
<button type="button" id="mineropsHeaderLogout">Logout</button>
`
: `
<span class="minerops-auth-user">Signed in</span>
<button type="button" id="mineropsHeaderLogout">Logout</button>
`;
if (user.role === "Admin") {
const accountsLink = document.createElement("a");
accountsLink.href = "/accounts";
accountsLink.textContent = "Accounts";
authArea.prepend(accountsLink);
}
document.getElementById("mineropsHeaderLogout").onclick = logoutFromHeader;
return;
}
authArea.innerHTML = `
<input id="mineropsHeaderUsername" type="text" placeholder="Username" autocomplete="username">
<input id="mineropsHeaderPassword" type="password" placeholder="Password" autocomplete="current-password">
<button type="button" id="mineropsHeaderLogin">Login</button>
<span id="mineropsHeaderAuthMessage" class="minerops-auth-message"></span>
`;
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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 = `<div class="minerops-search-empty">${escapeHtml(message)}</div>`;
}
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 = `<div class="minerops-search-empty">Searching...</div>`;
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 = `
<div class="minerops-search-empty">
No matching miners found.<br>
<span style="font-size: 11px;">${escapeHtml(lastLoadSummary)}</span>
</div>
`;
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 `
<div class="minerops-search-result" data-index="${index}">
<div>
<div class="minerops-search-result-main">${escapeHtml(mac)}</div>
<div class="minerops-search-result-sub">${escapeHtml(ip)}</div>
</div>
<div>
<div class="minerops-search-result-main">${escapeHtml(model)}</div>
<div class="minerops-search-result-sub">${escapeHtml(extra)}</div>
</div>
<div>
<div class="minerops-search-result-main">${escapeHtml(location)}</div>
<div class="minerops-search-result-sub">${escapeHtml(row.miner_id || "No miner ID")}</div>
</div>
</div>
`;
}).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 = `
<input id="mineropsGlobalSearchInput" type="search" placeholder="Search MAC, IP, model, rack...">
<div id="mineropsGlobalSearchResults" class="minerops-search-results"></div>
`;
const authArea = document.getElementById("mineropsHeaderAuth");
if (authArea) {
nav.insertBefore(search, authArea);
} else {
nav.appendChild(search);
}
const input = document.getElementById("mineropsGlobalSearchInput");
const delayedSearch = debounce(runGlobalSearch, 180);
input.addEventListener("input", delayedSearch);
input.addEventListener("focus", runGlobalSearch);
input.addEventListener("keydown", handleSearchKeydown);
document.addEventListener("click", event => {
if (!search.contains(event.target)) {
document.getElementById("mineropsGlobalSearchResults")?.classList.remove("open");
}
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", setupGlobalMinerSearch);
} else {
setupGlobalMinerSearch();
}
setTimeout(setupGlobalMinerSearch, 500);
setTimeout(setupGlobalMinerSearch, 1200);
window.mineropsRefreshGlobalSearch = function () {
searchCache = null;
runGlobalSearch();
};
})();
"""
@app.get("/assets/minerops-global-search.js")
async def minerops_global_search_js():
return HTMLResponse(content=MINEROPS_GLOBAL_SEARCH_JS, media_type="text/javascript")
MINEROPS_HEADER_SCAN_JS = r"""
// Phase 5F: Force Hub Mode into existing header scan JS
(function () {
async function getHubMode() {
try {
const response = await fetch("/api/hub/mode", { cache: "no-store" });
if (!response.ok) return null;
return await response.json();
} catch (error) {
return null;
}
}
function showHubModeBanner() {
let banner = document.getElementById("mineropsHubModeBanner");
if (!banner) {
banner = document.createElement("div");
banner.id = "mineropsHubModeBanner";
banner.style.cssText = [
"max-width:1500px",
"margin:12px auto",
"padding:12px 16px",
"border-radius:12px",
"border:1px solid #facc15",
"background:#fef9c3",
"color:#713f12",
"font-weight:700",
"box-shadow:0 2px 8px rgba(15,23,42,.12)"
].join(";");
const header = document.querySelector(".minerops-shell-header");
if (header && header.parentNode) {
header.parentNode.insertBefore(banner, header.nextSibling);
} else {
document.body.insertBefore(banner, document.body.firstChild);
}
}
banner.textContent = "Hub Mode: local miner scanning is disabled on this home server. The Windows site agent will poll miners and push updates here.";
}
function disableHubScanButtons() {
document.querySelectorAll("button, a").forEach(function (el) {
const text = (el.textContent || "").trim().toLowerCase();
if (
text.includes("run known-miner scan") ||
text.includes("run scan") ||
text.includes("scan now") ||
text.includes("scanning")
) {
el.textContent = "Hub Mode: Windows Agent";
el.title = "Local scanning is disabled on the hub. The Windows site agent will poll miners.";
el.disabled = true;
el.style.opacity = "0.65";
el.style.cursor = "not-allowed";
el.onclick = function (event) {
event.preventDefault();
alert("Hub Mode is enabled. The Windows site agent will poll miners and push updates to this hub.");
return false;
};
}
});
}
function updateScannerPanel() {
const state = document.getElementById("scannerState");
if (state) state.textContent = "Hub Mode";
const next = document.getElementById("scannerNextRun");
if (next) next.textContent = "Windows Agent";
const interval = document.getElementById("scannerInterval");
if (interval) interval.textContent = "Disabled";
const started = document.getElementById("scannerLastStarted");
if (started) started.textContent = "Local scanner disabled. Waiting for Windows site agent updates.";
const refreshed = document.getElementById("scannerPanelRefresh");
if (refreshed) refreshed.textContent = "Hub Mode active.";
}
async function applyHubMode() {
const data = await getHubMode();
if (!data || !data.hub_mode) return;
showHubModeBanner();
disableHubScanButtons();
updateScannerPanel();
}
document.addEventListener("DOMContentLoaded", function () {
applyHubMode();
setTimeout(applyHubMode, 500);
setTimeout(applyHubMode, 1500);
setInterval(applyHubMode, 4000);
});
})();
(function () {
const TOKEN_KEYS = [
"minerops_token",
"minerops_access_token",
"access_token",
"accessToken",
"token",
"auth_token",
"authToken",
"jwt"
];
let scanRunning = false;
function storageGet(key) {
return localStorage.getItem(key) || sessionStorage.getItem(key) || "";
}
function getToken() {
for (const key of TOKEN_KEYS) {
const value = storageGet(key);
if (value) {
return value;
}
}
for (const store of [localStorage, sessionStorage]) {
for (let i = 0; i < store.length; i++) {
const key = store.key(i) || "";
const lower = key.toLowerCase();
if (lower.includes("token") || lower.includes("jwt") || lower.includes("auth")) {
const value = store.getItem(key);
if (value && value.length > 20) {
return value;
}
}
}
}
return "";
}
function authHeaders() {
const token = getToken();
if (!token) {
return {};
}
return {
"Authorization": "Bearer " + token
};
}
function setScanStatus(message) {
const element = document.getElementById("mineropsHeaderScanStatus");
if (element) {
element.textContent = message || "";
}
}
function setScanButtonRunning(isRunning) {
scanRunning = isRunning;
const button = document.getElementById("mineropsHeaderScanButton");
if (!button) {
return;
}
button.disabled = isRunning;
button.classList.toggle("running", isRunning);
button.textContent = isRunning ? "Scanning..." : "Run Scan Now";
}
function summarizeScanResult(data) {
if (!data || typeof data !== "object") {
return "Scan requested.";
}
const total = data.total || data.total_miners || data.miners_total || data.scanned || data.scanned_count;
const success = data.success || data.success_count || data.ok || data.ok_count;
const failed = data.failed || data.failed_count || data.error_count;
const duration = data.duration_seconds || data.elapsed_seconds || data.seconds;
const parts = [];
if (total !== undefined && total !== null) {
parts.push("total " + total);
}
if (success !== undefined && success !== null) {
parts.push("success " + success);
}
if (failed !== undefined && failed !== null) {
parts.push("failed " + failed);
}
if (duration !== undefined && duration !== null) {
parts.push(String(duration).substring(0, 5) + " sec");
}
if (parts.length === 0 && data.message) {
return data.message;
}
if (parts.length === 0 && data.status) {
return "Scan " + data.status + ".";
}
return parts.length ? "Scan done: " + parts.join(", ") + "." : "Scan requested.";
}
async function fetchJson(url, options) {
const response = await fetch(url, options || {});
const text = await response.text();
let data = null;
try {
data = text ? JSON.parse(text) : {};
} catch (error) {
data = text;
}
if (!response.ok) {
const message = data && typeof data === "object"
? (data.detail || data.message || data.error || "Request failed.")
: (text || "Request failed.");
throw new Error(message);
}
return data;
}
async function runHeaderKnownMinerScan() {
if (scanRunning) {
return;
}
const token = getToken();
if (!token) {
setScanStatus("Log in first.");
return;
}
setScanButtonRunning(true);
setScanStatus("Starting known-miner scan...");
try {
const data = await fetchJson("/api/scanner/run-known-now", {
method: "POST",
headers: {
...authHeaders(),
"Content-Type": "application/json"
},
body: "{}"
});
setScanStatus(summarizeScanResult(data));
if (typeof window.mineropsRefreshGlobalSearch === "function") {
window.mineropsRefreshGlobalSearch();
}
if (typeof window.refreshAll === "function") {
try {
await window.refreshAll();
} catch (error) {
// Page refresh helper is optional.
}
}
} catch (error) {
setScanStatus("Scan error: " + error.message);
} finally {
setScanButtonRunning(false);
}
}
async function updateHeaderScanStatus() {
const token = getToken();
if (!token) {
setScanStatus("");
return;
}
try {
const data = await fetchJson("/api/scanner/status", {
headers: authHeaders()
});
if (!data || typeof data !== "object") {
return;
}
const running = data.running || data.is_running || data.scan_running;
if (running) {
setScanButtonRunning(true);
setScanStatus("Scanner running...");
return;
}
setScanButtonRunning(false);
const lastSuccess = data.last_success_at || data.last_completed_at || data.last_run_at;
const lastSummary = data.last_summary || data.summary;
if (lastSummary && typeof lastSummary === "string") {
setScanStatus(lastSummary);
return;
}
if (lastSuccess) {
setScanStatus("Last scan: " + new Date(lastSuccess).toLocaleString());
}
} catch (error) {
// Keep header quiet if status is unavailable.
}
}
function setupHeaderScanButton() {
const nav = document.querySelector(".minerops-nav");
if (!nav || document.getElementById("mineropsHeaderScan")) {
return;
}
const container = document.createElement("div");
container.id = "mineropsHeaderScan";
container.className = "minerops-header-scan";
container.innerHTML = `
<button id="mineropsHeaderScanButton" class="minerops-scan-button" type="button">Run Scan Now</button>
<span id="mineropsHeaderScanStatus" class="minerops-scan-status"></span>
`;
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function minerIdFromPage() {
const card = document.getElementById("minerControlCard");
if (card && card.dataset.minerId) {
return card.dataset.minerId;
}
const match = window.location.pathname.match(/\/miners\/([^\/]+)\/detail/);
return match ? decodeURIComponent(match[1]) : "";
}
function getMinerOpsTokenForAgentControl() {
const keys = [
"minerops_token",
"mineropsToken",
"access_token",
"token",
"auth_token",
"jwt"
];
for (const storage of [window.localStorage, window.sessionStorage]) {
if (!storage) {
continue;
}
for (const key of keys) {
const value = storage.getItem(key);
if (value && value.trim()) {
return value.trim();
}
}
}
return "";
}
function getMinerIdForAgentControl() {
const pathMatch = window.location.pathname.match(/\/miners\/([^\/]+)\/detail/);
if (pathMatch && pathMatch[1]) {
return decodeURIComponent(pathMatch[1]);
}
const dataId =
document.body?.dataset?.minerId ||
document.documentElement?.dataset?.minerId ||
"";
return dataId;
}
async function postAgentMinerControl(action, dryRun, label) {
const minerId = getMinerIdForAgentControl();
if (!minerId) {
throw new Error("Could not determine miner ID from this page.");
}
const headers = {
"Content-Type": "application/json"
};
const token = getMinerOpsTokenForAgentControl();
if (token) {
headers.Authorization = "Bearer " + token;
}
const response = await fetch(`/api/miners/${encodeURIComponent(minerId)}/agent-control`, {
method: "POST",
headers,
body: JSON.stringify({
action,
dry_run: !!dryRun,
performance_mode: window.__mineropsPerformanceMode || null
})
});
let data = {};
try {
data = await response.json();
} catch {
data = {};
}
if (!response.ok) {
const detail = data.detail || data.error || data.message || response.statusText;
throw new Error(detail);
}
setMinerControlStatus(
`${label} command queued for Windows Agent. Command ID: ${data.command_id || "unknown"}`,
"ok"
);
return data;
}
function setMinerControlStatus(message, className) {
const status = document.getElementById("minerControlStatus");
if (!status) {
return;
}
status.textContent = message;
status.className = "status " + (className || "warn");
}
async function fetchJson(url, options) {
const response = await fetch(url, options || {});
const text = await response.text();
let data = null;
try {
data = text ? JSON.parse(text) : {};
} catch (error) {
data = text;
}
if (!response.ok) {
let message = text || "Request failed.";
if (data && typeof data === "object") {
if (typeof data.detail === "string") {
message = data.detail;
} else if (data.detail && data.detail.message) {
message = data.detail.message;
} else if (data.message) {
message = data.message;
} else {
message = JSON.stringify(data);
}
}
throw new Error(message);
}
return data;
}
async function postRebootCommand(dryRun, confirmText) {
const label = dryRun ? "Dry-run reboot" : "Reboot";
try {
setMinerControlStatus(`${label} command is being queued for Windows Agent...`, "warn");
await postAgentMinerControl("reboot", dryRun, label);
} catch (err) {
setMinerControlStatus(`${label} failed: ${err.message}`, "bad");
}
}
function extractPerformanceModeFromSnapshot(snapshot) {
if (!snapshot) {
return "";
}
const raw = snapshot.raw_summary_json || snapshot.raw_summary || snapshot.summary || {};
const msg = raw.Msg || raw.MSG || raw.msg || {};
return (
msg["Power Mode"] ||
msg.PowerMode ||
msg.power_mode ||
raw["Power Mode"] ||
raw.PowerMode ||
raw.power_mode ||
""
);
}
function setCurrentPerformanceModeBadge(mode, suffix) {
const badge = document.getElementById("currentPerformanceModeBadge");
if (!badge) {
return;
}
const cleanMode = mode ? String(mode).trim() : "Unknown";
const extra = suffix ? " " + suffix : "";
badge.textContent = "Configured Mode: " + cleanMode + extra;
const lowered = cleanMode.toLowerCase();
if (lowered === "low") {
badge.style.background = "#dcfce7";
badge.style.color = "#166534";
} else if (lowered === "normal") {
badge.style.background = "#dbeafe";
badge.style.color = "#1e40af";
} else if (lowered === "high") {
badge.style.background = "#fee2e2";
badge.style.color = "#991b1b";
} else {
badge.style.background = "#e5e7eb";
badge.style.color = "#111827";
}
}
async function loadCurrentPerformanceModeBadge() {
try {
const minerId = getMinerIdForAgentControl();
if (!minerId) {
setCurrentPerformanceModeBadge("Unknown");
return;
}
const headers = {};
const token = getMinerOpsTokenForAgentControl();
if (token) {
headers.Authorization = "Bearer " + token;
}
const response = await fetch(`/api/miners/${encodeURIComponent(minerId)}/configured-performance-mode`, {
method: "GET",
headers
});
if (!response.ok) {
throw new Error(response.statusText || "Snapshot request failed");
}
const body = await response.json();
const data = body.data || body || {};
const mode = data.configured_performance_mode || "Unknown";
const runtimeState = data.runtime_power_state ? "runtime: " + data.runtime_power_state : "";
setCurrentPerformanceModeBadge(mode || "Unknown", runtimeState ? "(" + runtimeState + ")" : "");
} catch (err) {
setCurrentPerformanceModeBadge("Unknown");
}
}
async function setPerformanceMode() {
const select = document.getElementById("performanceModeSelect");
const mode = (select && select.value ? select.value : "normal").toLowerCase();
let warning = "Set this miner to " + mode.toUpperCase() + " performance mode?";
if (mode === "high") {
warning += "\n\nHigh mode can increase heat and power use. Avoid this on miners with temperature errors.";
}
if (!confirm(warning)) {
setMinerControlStatus("Performance mode change cancelled.", "warn");
return;
}
try {
window.__mineropsPerformanceMode = mode;
setMinerControlStatus("Performance mode command is being queued for Windows Agent...", "warn");
await postAgentMinerControl("performance_mode", false, "Performance mode " + mode.toUpperCase());
setCurrentPerformanceModeBadge(mode, "(queued)");
} catch (err) {
setMinerControlStatus("Performance mode failed: " + err.message, "bad");
} finally {
window.__mineropsPerformanceMode = null;
}
}
async function dryRunMinerReboot() {
await postRebootCommand(true, "DRYRUN");
}
async function realRebootMiner() {
const confirmed = confirm(
"Reboot this miner now?\n\nThis will send the reboot command immediately."
);
if (!confirmed) {
setMinerControlStatus("Real reboot cancelled.", "warn");
return;
}
await postRebootCommand(false, "REBOOT");
}
async function postPowerCommand(action, label) {
try {
setMinerControlStatus(`${label} command is being queued for Windows Agent...`, "warn");
await postAgentMinerControl(action, false, label);
} catch (err) {
setMinerControlStatus(`${label} failed: ${err.message}`, "bad");
}
}
async function powerOffMiner() {
const confirmed = confirm(
"Suspend this miner now?\n\nThis will use the authenticated WhatsMiner API3 service stop command."
);
if (!confirmed) {
setMinerControlStatus("Suspend cancelled.", "warn");
return;
}
await postPowerCommand("suspend", "Suspend");
}
async function powerOnMiner() {
const confirmed = confirm(
"Resume this miner now?\n\nThis will use the authenticated WhatsMiner API3 service start command."
);
if (!confirmed) {
setMinerControlStatus("Resume cancelled.", "warn");
return;
}
await postPowerCommand("resume", "Resume");
}
function formatDate(value) {
if (!value) {
return "-";
}
try {
return new Date(value).toLocaleString();
} catch (error) {
return value;
}
}
async function loadMinerCommandHistory() {
const minerId = minerIdFromPage();
const body = document.getElementById("minerCommandHistoryBody");
if (!minerId || !body) {
return;
}
const token = getToken();
if (!token) {
body.innerHTML = '<div class="status warn">Log in to view command history.</div>';
return;
}
try {
const data = await fetchJson("/api/miners/" + encodeURIComponent(minerId) + "/commands", {
headers: authHeaders()
});
const commands = data.commands || [];
if (commands.length === 0) {
body.innerHTML = '<div class="status ok">No command history for this miner yet.</div>';
return;
}
body.innerHTML = `
<table>
<thead>
<tr>
<th>Time</th>
<th>Command</th>
<th>App Status</th>
<th>Target</th>
<th>User</th>
<th>Error</th>
</tr>
</thead>
<tbody>
${commands.map(command => `
<tr>
<td>${escapeHtml(formatDate(command.created_at))}</td>
<td>${escapeHtml(command.command_type)}</td>
<td>${escapeHtml(command.command_status)}<div class="small">TCP/log status, not miner acceptance proof</div></td>
<td>${escapeHtml((command.target_ip || "-") + " / " + (command.target_mac || "-"))}</td>
<td>${escapeHtml(command.requested_by_username || "-")}</td>
<td>${escapeHtml(command.error_message || "")}</td>
</tr>
`).join("")}
</tbody>
</table>
`;
} catch (error) {
body.innerHTML = '<div class="status bad">Command history error: ' + escapeHtml(error.message) + '</div>';
}
}
function setupMinerControlCard() {
const realButton = document.getElementById("realRebootButton");
if (realButton && realButton.dataset.handlerAttached !== "true") {
realButton.dataset.handlerAttached = "true";
realButton.onclick = realRebootMiner;
}
const powerOffButton = document.getElementById("powerOffButton");
if (powerOffButton && powerOffButton.dataset.handlerAttached !== "true") {
powerOffButton.dataset.handlerAttached = "true";
powerOffButton.onclick = powerOffMiner;
}
const powerOnButton = document.getElementById("powerOnButton");
if (powerOnButton && powerOnButton.dataset.handlerAttached !== "true") {
powerOnButton.dataset.handlerAttached = "true";
powerOnButton.onclick = powerOnMiner;
}
loadMinerCommandHistory();
loadCurrentPerformanceModeBadge();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", setupMinerControlCard);
} else {
setupMinerControlCard();
}
setTimeout(setupMinerControlCard, 600);
window.dryRunMinerReboot = dryRunMinerReboot;
window.realRebootMiner = realRebootMiner;
window.setPerformanceMode = setPerformanceMode;
window.loadCurrentPerformanceModeBadge = loadCurrentPerformanceModeBadge;
window.suspendMiner = powerOffMiner;
window.suspendMiner = powerOffMiner;
window.powerOffMiner = powerOffMiner;
window.resumeMiner = powerOnMiner;
window.resumeMiner = powerOnMiner;
window.powerOnMiner = powerOnMiner;
window.loadMinerCommandHistory = loadMinerCommandHistory;
})();
"""
@app.get("/assets/minerops-miner-control.js")
async def minerops_miner_control_js():
return HTMLResponse(content=MINEROPS_MINER_CONTROL_JS, media_type="text/javascript")
@app.get("/dashboard", response_class=HTMLResponse)
def minerops_dashboard_page():
return """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>MinerOps Dashboard</title>
<style>
body {
margin: 0;
font-family: Arial, sans-serif;
background: #f4f6f8;
color: #1f2933;
}
header {
background: #0f172a;
color: white;
padding: 18px 28px;
}
header h1 {
margin: 0;
font-size: 24px;
}
header .sub {
color: #cbd5e1;
margin-top: 4px;
}
.container {
padding: 24px;
max-width: 1600px;
margin: auto;
}
.card {
background: white;
border: 1px solid #d9e2ec;
border-radius: 8px;
padding: 18px;
margin-bottom: 18px;
box-shadow: 0 2px 5px rgba(0,0,0,0.04);
}
.login-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: end;
}
label {
font-size: 13px;
font-weight: bold;
display: block;
margin-bottom: 4px;
}
input, select {
padding: 9px;
border: 1px solid #bcccdc;
border-radius: 5px;
}
button {
padding: 10px 14px;
border: none;
border-radius: 5px;
background: #2563eb;
color: white;
font-weight: bold;
cursor: pointer;
}
button.secondary {
background: #475569;
}
button.danger {
background: #b91c1c;
}
.status {
margin-top: 10px;
padding: 9px;
border-radius: 5px;
}
.ok {
background: #dcfce7;
border: 1px solid #86efac;
}
.warn {
background: #fef9c3;
border: 1px solid #fde047;
}
.bad {
background: #fee2e2;
border: 1px solid #fca5a5;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 12px;
}
.metric {
background: #f8fafc;
border: 1px solid #e2e8f0;
padding: 14px;
border-radius: 7px;
}
.metric .label {
font-size: 12px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.metric .value {
font-size: 26px;
font-weight: bold;
margin-top: 6px;
}
.section-title {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.section-title h2 {
margin: 0;
font-size: 19px;
}
.tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.tab {
background: #e2e8f0;
color: #0f172a;
}
.tab.active {
background: #2563eb;
color: white;
}
.table-wrap {
overflow-x: auto;
max-height: 620px;
overflow-y: auto;
border: 1px solid #e2e8f0;
border-radius: 7px;
}
table {
border-collapse: collapse;
width: 100%;
font-size: 13px;
background: white;
}
th, td {
padding: 8px 10px;
border-bottom: 1px solid #e2e8f0;
text-align: left;
white-space: nowrap;
}
th {
background: #f1f5f9;
position: sticky;
top: 0;
z-index: 1;
}
tr:hover {
background: #f8fafc;
}
.pill {
display: inline-block;
padding: 3px 7px;
border-radius: 999px;
font-size: 12px;
font-weight: bold;
}
.pill.online {
background: #dcfce7;
color: #166534;
}
.pill.hot {
background: #fee2e2;
color: #991b1b;
}
.pill.warn {
background: #fef9c3;
color: #854d0e;
border: none;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 12px;
align-items: end;
}
.controls input {
min-width: 260px;
}
.small {
color: #64748b;
font-size: 12px;
}
.two-col {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 18px;
}
pre {
background: #111827;
color: #f9fafb;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
}
/* Step 79: Unified app look */
:root {{
--app-bg: #f4f7fb;
--app-card: #ffffff;
--app-border: #d9e2ec;
--app-text: #102a43;
--app-muted: #52606d;
--app-blue: #0b63ce;
--app-blue-dark: #084b9a;
--app-green: #0f766e;
--app-yellow-bg: #fff7ed;
--app-yellow-border: #fdba74;
--app-red-bg: #fef2f2;
--app-red-border: #fca5a5;
--app-green-bg: #ecfdf5;
--app-green-border: #6ee7b7;
}}
body {{
background: var(--app-bg) !important;
color: var(--app-text);
font-family: Segoe UI, Arial, sans-serif;
margin: 0;
padding: 24px;
}}
.topbar {{
display: none !important;
}}
#dashboardLocationMapLink {{
display: none !important;
}}
.app-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);
}}
.app-brand h1 {{
margin: 0;
color: white;
font-size: 28px;
line-height: 1.1;
}}
.app-kicker {{
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.85;
margin-bottom: 4px;
}}
.app-nav {{
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
align-items: center;
}}
.app-nav-link,
.app-nav-button {{
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: 700;
font-size: 13px;
text-decoration: none;
cursor: pointer;
}}
.app-nav-link:hover,
.app-nav-button:hover {{
background: rgba(255, 255, 255, 0.24);
}}
.app-nav-link.active {{
background: white;
color: var(--app-blue) !important;
}}
.card {{
background: var(--app-card) !important;
border: 1px solid var(--app-border) !important;
border-radius: 14px !important;
padding: 16px !important;
margin-bottom: 16px !important;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
}}
.card h2 {{
color: var(--app-text);
margin-top: 0;
}}
.small,
.muted {{
color: var(--app-muted);
}}
button,
input[type="button"],
input[type="submit"] {{
background: var(--app-blue);
color: white;
border: 0;
border-radius: 8px;
padding: 8px 11px;
font-weight: 700;
cursor: pointer;
}}
button:hover,
input[type="button"]:hover,
input[type="submit"]:hover {{
background: var(--app-blue-dark);
}}
button.secondary {{
background: #64748b;
}}
button.danger,
.danger {{
background: #dc2626 !important;
color: white !important;
}}
input,
select,
textarea {{
border: 1px solid #bcccdc;
border-radius: 8px;
padding: 8px;
background: white;
color: var(--app-text);
}}
table {{
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 10px;
overflow: hidden;
}}
th {{
background: #eef2ff;
color: #1e293b;
text-align: left;
font-size: 13px;
}}
th,
td {{
border-bottom: 1px solid #e2e8f0;
padding: 8px;
vertical-align: top;
}}
tr:hover td {{
background: #f8fafc;
}}
.status {{
border-radius: 10px;
padding: 10px 12px;
margin-top: 10px;
border: 1px solid var(--app-border);
background: #f8fafc;
}}
.status.ok {{
background: var(--app-green-bg);
border-color: var(--app-green-border);
}}
.status.warn {{
background: var(--app-yellow-bg);
border-color: var(--app-yellow-border);
}}
.status.bad {{
background: var(--app-red-bg);
border-color: var(--app-red-border);
}}
.sticky-mini-toolbar {{
border-radius: 14px !important;
border: 1px solid var(--app-border) !important;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12) !important;
}}
@media (max-width: 800px) {{
body {{
padding: 12px;
}}
.app-header {{
align-items: flex-start;
flex-direction: column;
}}
.app-nav {{
justify-content: flex-start;
}}
}}
</style>
<link rel="stylesheet" href="/assets/minerops-unified.css">
</head>
<body>
<header class="minerops-shell-header">
<div>
<div class="minerops-kicker">MinerOps Console</div>
<h1>MinerOps Dashboard</h1>
<div class="minerops-subtitle">Plexus Mining Site</div>
</div>
<nav class="minerops-nav">
<a class="minerops-nav-link active" href="/dashboard">Dashboard</a>
<a class="minerops-nav-link" href="/locations">Location Map</a>
<a class="minerops-nav-link" href="javascript:window.location.reload()">Refresh</a>
</nav>
</header>
<header>
<h1>MinerOps Dashboard</h1>
<div class="sub">Plexus Mining Site</div>
</header>
<div class="container">
<div class="card">
<div class="section-title">
<h2>Login</h2>
<button class="secondary" onclick="refreshAll()">Refresh Dashboard</button>
</div>
<div class="login-row">
<div>
<label>Username</label>
<input id="username" type="text" value="admin">
</div>
<div>
<label>Password</label>
<input id="password" type="password">
</div>
<div>
<button onclick="login()">Login</button>
</div>
<div>
<button class="secondary" onclick="logout()">Clear Token</button>
</div>
</div>
<div id="loginStatus" class="status warn">Not logged in.</div>
<div class="small">Token is stored in this browser until it expires or you click Clear Token.</div>
</div>
<div class="card">
<div class="section-title">
<h2>Fleet Summary</h2>
<span id="lastRefresh" class="small">Not refreshed yet.</span>
</div>
<div class="grid">
<div class="metric"><div class="label">Total Miners</div><div class="value" id="totalMiners">-</div></div>
<div class="metric"><div class="label">Online</div><div class="value" id="onlineMiners">-</div></div>
<div class="metric"><div class="label">Unit 4</div><div class="value" id="unit4Count">-</div></div>
<div class="metric"><div class="label">Unit 6</div><div class="value" id="unit6Count">-</div></div>
<div class="metric"><div class="label">Hot >= 90C</div><div class="value" id="hotCount">-</div></div>
<div class="metric"><div class="label">Unstable</div><div class="value" id="unstableCount">-</div></div>
<div class="metric"><div class="label">Stale 30m</div><div class="value" id="staleCount">-</div></div>
<div class="metric"><div class="label">Total TH/s</div><div class="value" id="totalHashrate">-</div></div>
<div class="metric"><div class="label">Total Power W</div><div class="value" id="totalPower">-</div></div>
</div>
</div>
<div class="card">
<div class="section-title">
<h2>Scanner Status</h2>
<button onclick="runKnownMinerScanNow()">Run Known-Miner Scan Now</button>
</div>
<div class="grid">
<div class="metric"><div class="label">State</div><div class="value" id="scannerState">-</div></div>
<div class="metric"><div class="label">Interval</div><div class="value" id="scannerInterval">-</div></div>
<div class="metric"><div class="label">Last Duration</div><div class="value" id="scannerDuration">-</div></div>
<div class="metric"><div class="label">Total Miners</div><div class="value" id="scannerTotal">-</div></div>
<div class="metric"><div class="label">Success</div><div class="value" id="scannerSuccess">-</div></div>
<div class="metric"><div class="label">Failed</div><div class="value" id="scannerFailed">-</div></div>
<div class="metric"><div class="label">Last Finished</div><div class="value" id="scannerLastFinished">-</div></div>
<div class="metric"><div class="label">Next Run</div><div class="value" id="scannerNextRun">-</div></div>
</div>
<div class="small" id="scannerLastStarted">Last started: -</div>
<div class="small" id="scannerPanelRefresh">Scanner panel refreshed: -</div>
<div class="small" id="scannerPollCount">Scanner poll count: 0</div>
</div>
<div class="card">
<div class="section-title">
<h2>Issues / Alerts</h2>
<span class="small">Sorted by severity, MAC-first.</span>
</div>
<div class="grid">
<div class="metric"><div class="label">Total Issues</div><div class="value" id="issueTotal">-</div></div>
<div class="metric"><div class="label">Critical</div><div class="value" id="issueCritical">-</div></div>
<div class="metric"><div class="label">Warning</div><div class="value" id="issueWarning">-</div></div>
<div class="metric"><div class="label">Info</div><div class="value" id="issueInfo">-</div></div>
</div>
<div class="controls">
<div>
<label>Issue Search</label>
<input id="issueSearchBox" type="text" placeholder="MAC, IP, model, issue..." oninput="renderIssues()">
</div>
<div>
<label>Severity</label>
<select id="issueSeverityFilter" onchange="renderIssues()">
<option value="">All</option>
<option value="critical">Critical</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</select>
</div>
</div>
<div id="repairActionStatus" class="status warn">Repair status actions ready.</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Severity</th>
<th>Issue</th>
<th>MAC</th>
<th>IP</th>
<th>Unit</th>
<th>Model</th>
<th>Firmware</th>
<th>Detail</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody id="issueBody"></tbody>
</table>
</div>
</div>
<div class="card">
<div class="section-title">
<h2>Open Repairs / Follow-ups</h2>
<span class="small">Only shows repairs that are not completed.</span>
</div>
<div class="grid">
<div class="metric"><div class="label">Open Repairs</div><div class="value" id="openRepairTotal">-</div></div>
<div class="metric"><div class="label">Needs Follow-up</div><div class="value" id="openRepairFollowup">-</div></div>
<div class="metric"><div class="label">In Progress</div><div class="value" id="openRepairInProgress">-</div></div>
<div class="metric"><div class="label">Failed</div><div class="value" id="openRepairFailed">-</div></div>
</div>
<div class="controls">
<div>
<label>Open Repair Search</label>
<input id="openRepairSearchBox" type="text" placeholder="MAC, IP, serial, component, note..." oninput="renderOpenRepairs()">
</div>
</div>
<div id="openRepairActionStatus" class="status warn">Open repair actions ready.</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Date</th>
<th>App Status</th>
<th>Actions</th>
<th>Type</th>
<th>MAC</th>
<th>IP</th>
<th>Unit</th>
<th>Model</th>
<th>Title</th>
<th>Component</th>
<th>Slot</th>
<th>Serial</th>
<th>Notes</th>
<th>By</th>
</tr>
</thead>
<tbody id="openRepairBody"></tbody>
</table>
</div>
</div> <div class="card">
<div class="section-title">
<h2>Recent Repair / Maintenance Activity</h2>
<span class="small">Fleet-wide repair history, MAC-first.</span>
</div>
<div class="grid">
<div class="metric"><div class="label">Shown</div><div class="value" id="repairTotal">-</div></div>
<div class="metric"><div class="label">Completed</div><div class="value" id="repairCompleted">-</div></div>
<div class="metric"><div class="label">Needs Follow-up</div><div class="value" id="repairFollowup">-</div></div>
<div class="metric"><div class="label">In Progress</div><div class="value" id="repairInProgress">-</div></div>
</div>
<div class="controls">
<div>
<label>Repair Search</label>
<input id="repairSearchBox" type="text" placeholder="MAC, IP, serial, component, note..." oninput="renderRepairEvents()">
</div>
<div>
<label>Status</label>
<select id="repairStatusFilter" onchange="renderRepairEvents()">
<option value="">All</option>
<option value="completed">Completed</option>
<option value="in_progress">In progress</option>
<option value="needs_followup">Needs follow-up</option>
<option value="failed">Failed</option>
</select>
</div>
<div>
<label>Type</label>
<select id="repairTypeFilter" onchange="renderRepairEvents()">
<option value="">All</option>
<option value="general_note">General note</option>
<option value="control_board_changed">Control board changed</option>
<option value="mac_changed">MAC changed</option>
<option value="hashboard_replaced">Hashboard replaced</option>
<option value="psu_replaced">PSU replaced</option>
<option value="fan_replaced">Fan replaced</option>
<option value="cleaned">Cleaned</option>
<option value="moved">Moved</option>
<option value="firmware_updated">Firmware updated</option>
<option value="troubleshooting">Troubleshooting</option>
</select>
</div>
<div>
<label>Export</label>
<button onclick="exportRepairEventsCsv()">Export Repair CSV</button>
</div>
</div>
<div id="repairExportStatus" class="status warn">Repair CSV export ready.</div>
<div id="repairActionStatus" class="status warn">Repair status actions ready.</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Date</th>
<th>App Status</th>
<th>Actions</th>
<th>Type</th>
<th>MAC</th>
<th>IP</th>
<th>Unit</th>
<th>Model</th>
<th>Title</th>
<th>Component</th>
<th>Slot</th>
<th>Serial</th>
<th>Notes</th>
<th>By</th>
</tr>
</thead>
<tbody id="repairBody"></tbody>
</table>
</div>
</div> <div class="two-col">
<div class="card">
<h2>Models</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Model</th><th>Count</th></tr></thead>
<tbody id="modelBody"></tbody>
</table>
</div>
</div>
<div class="card">
<h2>Firmware</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Firmware</th><th>Count</th></tr></thead>
<tbody id="firmwareBody"></tbody>
</table>
</div>
</div>
</div>
<div class="card">
<div class="section-title">
<h2>Miner Tables</h2>
<span class="small">Search by IP, MAC, serial, model, firmware, or unit.</span>
</div>
<div class="tabs">
<button class="tab active" id="tabAll" onclick="showTable('all')">All Miners</button>
<button class="tab" id="tabHot" onclick="showTable('hot')">Hot Miners</button>
<button class="tab" id="tabUnstable" onclick="showTable('unstable')">Unstable Miners</button>
<button class="tab" id="tabStale" onclick="showTable('stale')">Stale Miners</button>
</div>
<div class="controls">
<div>
<label>Search</label>
<input id="searchBox" type="text" placeholder="10.30, CC:0C, M60S, serial..." oninput="renderCurrentTable()">
<select id="modeFilter" onchange="renderCurrentTable()">
<option value="">All Modes</option>
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
<option value="unknown">Unknown</option>
</select>
<button type="button" onclick="selectVisibleMiners()">Select Visible</button>
<button type="button" onclick="clearSelectedMiners()">Clear Selection</button>
<select id="bulkActionSelect">
<option value="">Bulk Action...</option>
<option value="suspend">Suspend Selected</option>
<option value="resume">Resume Selected</option>
<option value="performance_low">Set Low Mode</option>
<option value="performance_normal">Set Normal Mode</option>
<option value="performance_high">Set High Mode - Use Carefully</option>
</select>
<button type="button" class="danger" onclick="runBulkMinerAction()">Run Bulk Action</button>
<span id="bulkActionStatus" class="status">Selected: 0</span>
</div>
<div>
<label>Unit</label>
<select id="unitFilter" onchange="renderCurrentTable()">
<option value="">All Units</option>
<option value="Unit 4">Unit 4</option>
<option value="Unit 6">Unit 6</option>
<option value="Unit 12">Unit 12</option>
</select>
</div>
</div>
<div id="repairActionStatus" class="status warn">Repair status actions ready.</div>
<div class="table-wrap">
<table>
<thead id="mainTableHead"></thead>
<tbody id="mainTableBody"></tbody>
</table>
</div>
</div>
<div class="card">
<h2>Status</h2>
<pre id="rawStatus">Ready.</pre>
</div>
</div>
<script>
let token = localStorage.getItem("minerops_token") || "";
let dashboard = null;
let minerTable = [];
let selectedMinerIds = new Set();
let bulkActionDashboardStatus = {};
let scheduledBulkActions = {};
let m30CommandTestLab = {};
let runtimeFieldsLatest = {};
let poolInfoLatest = {};
let hotMiners = [];
let unstableMiners = [];
let staleMiners = [];
let scannerStatus = null;
let scannerPollTimer = null;
let scannerPollCount = 0;
let issues = [];
let repairEvents = [];
let currentTable = "all";
function setLoginStatus(message, className) {
const element = document.getElementById("loginStatus");
element.textContent = message;
element.className = "status " + className;
}
function setRaw(message) {
document.getElementById("rawStatus").textContent = message;
}
function fmt(value) {
if (value === null || value === undefined || value === "") {
return "-";
}
return value;
}
function fmtNumber(value) {
if (value === null || value === undefined || value === "") {
return "-";
}
return Number(value).toLocaleString();
}
function fmtTemp(value) {
if (value === null || value === undefined) {
return "-";
}
return Number(value).toFixed(1);
}
function escapeHtml(value) {
return String(fmt(value))
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
async function apiGet(path) {
if (!token) {
throw new Error("Missing token. Please log in.");
}
const response = await fetch(path, {
headers: {
"Authorization": "Bearer " + token
}
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || "API error");
}
return result.data;
}
async function apiPost(path) {
if (!token) {
throw new Error("Missing token. Please log in.");
}
const response = await fetch(path, {
method: "POST",
headers: {
"Authorization": "Bearer " + token
}
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || "API error");
}
return result;
}
async function refreshScannerStatusOnly() {
try {
scannerStatus = await apiGet("/api/scanner/status");
scannerPollCount += 1;
renderScannerStatus();
if (scannerStatus.running) {
setRaw("Scanner is running... Success: " + scannerStatus.last_success_count + " | Failed: " + scannerStatus.last_failure_count);
scannerPollTimer = setTimeout(refreshScannerStatusOnly, 5000);
} else {
setRaw("Scanner is idle. Last scan success: " + scannerStatus.last_success_count + " | Failed: " + scannerStatus.last_failure_count);
if (scannerPollTimer) {
clearTimeout(scannerPollTimer);
scannerPollTimer = null;
}
await refreshAll();
}
} catch (error) {
setRaw("Scanner refresh error: " + error.message);
}
}
async function runKnownMinerScanNow() {
try {
setRaw("Starting known-miner scan...");
const result = await apiPost("/api/scanner/run-known-now");
scannerPollCount = 0;
scannerStatus = result.data;
renderScannerStatus();
if (scannerPollTimer) {
clearTimeout(scannerPollTimer);
scannerPollTimer = null;
}
scannerPollTimer = setTimeout(refreshScannerStatusOnly, 5000);
setRaw(result.meta?.message || "Known-miner scan requested.");
} catch (error) {
setRaw("Scanner error: " + error.message);
}
}
async function login() {
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
username: username,
password: password
})
});
const result = await response.json();
if (!response.ok) {
setLoginStatus("Login failed: " + (result.detail || "Unknown error"), "bad");
return;
}
token = result.data.access_token;
localStorage.setItem("minerops_token", token);
setLoginStatus("Logged in. Token expires in " + result.data.expires_in + " seconds.", "ok");
await refreshAll();
}
function logout() {
token = "";
localStorage.removeItem("minerops_token");
setLoginStatus("Token cleared.", "warn");
}
async function refreshAll() {
try {
setRaw("Refreshing dashboard...");
dashboard = await apiGet("/api/dashboard/summary");
minerTable = await apiGet("/api/dashboard/miner-table");
hotMiners = await apiGet("/api/dashboard/hot-miners?min_temp_c=90");
unstableMiners = await apiGet("/api/dashboard/unstable-miners");
staleMiners = await apiGet("/api/dashboard/stale-miners?minutes=30");
scannerStatus = await apiGet("/api/scanner/status");
issues = await apiGet("/api/dashboard/issues");
repairEvents = await apiGet("/api/maintenance/events?limit=100");
bulkActionDashboardStatus = await apiGet("/api/dashboard/bulk-action-status");
scheduledBulkActions = await apiGet("/api/dashboard/scheduled-bulk-actions");
m30CommandTestLab = await apiGet("/api/dashboard/m30-command-test-lab");
runtimeFieldsLatest = await apiGet("/api/dashboard/runtime-fields-latest");
poolInfoLatest = await apiGet("/api/dashboard/pool-info-latest");
renderSummary();
renderScannerStatus();
renderIssues();
renderOpenRepairs();
renderRepairEvents();
renderBulkActionDashboardStatus();
renderScheduledBulkActions();
renderM30CommandTestLab();
renderRuntimeFieldsPanel();
renderPoolInfoPanel();
renderBreakdowns();
renderCurrentTable();
document.getElementById("lastRefresh").textContent = "Last refresh: " + new Date().toLocaleString();
setRaw("Dashboard refreshed successfully.");
} catch (error) {
setRaw("Error: " + error.message);
if (String(error.message).toLowerCase().includes("token")) {
setLoginStatus("Please log in again.", "bad");
}
}
}
function renderSummary() {
const totals = dashboard.totals || {};
const telemetry = dashboard.telemetry || {};
const byRoom = dashboard.by_room || [];
const unit4 = byRoom.find(row => row.room_name === "Unit 4");
const unit6 = byRoom.find(row => row.room_name === "Unit 6");
document.getElementById("totalMiners").textContent = fmtNumber(totals.total_miners);
document.getElementById("onlineMiners").textContent = fmtNumber(totals.online_miners);
document.getElementById("unit4Count").textContent = fmtNumber(unit4 ? unit4.miner_count : 0);
document.getElementById("unit6Count").textContent = fmtNumber(unit6 ? unit6.miner_count : 0);
document.getElementById("hotCount").textContent = fmtNumber(telemetry.miners_chip_temp_90c_or_higher);
document.getElementById("unstableCount").textContent = fmtNumber(telemetry.miners_hash_unstable);
document.getElementById("staleCount").textContent = fmtNumber(totals.not_seen_last_30_minutes);
document.getElementById("totalHashrate").textContent = fmtNumber(telemetry.total_hashrate_ths);
document.getElementById("totalPower").textContent = fmtNumber(telemetry.total_power_w);
}
function fmtDateTime(value) {
if (!value) {
return "-";
}
try {
return new Date(value).toLocaleString();
} catch {
return value;
}
}
function renderScannerStatus() {
if (!scannerStatus) {
return;
}
document.getElementById("scannerState").textContent = scannerStatus.running ? "Running" : "Idle";
document.getElementById("scannerInterval").textContent = scannerStatus.interval_seconds + "s";
document.getElementById("scannerDuration").textContent = scannerStatus.last_duration_seconds ? scannerStatus.last_duration_seconds + "s" : "-";
document.getElementById("scannerTotal").textContent = fmtNumber(scannerStatus.last_total_miners);
document.getElementById("scannerSuccess").textContent = fmtNumber(scannerStatus.last_success_count);
document.getElementById("scannerFailed").textContent = fmtNumber(scannerStatus.last_failure_count);
document.getElementById("scannerLastFinished").textContent = fmtDateTime(scannerStatus.last_finished_at);
document.getElementById("scannerNextRun").textContent = fmtDateTime(scannerStatus.next_run_at);
document.getElementById("scannerLastStarted").textContent = "Last started: " + fmtDateTime(scannerStatus.last_started_at);
const refreshElement = document.getElementById("scannerPanelRefresh");
if (refreshElement) {
refreshElement.textContent = "Scanner panel refreshed: " + new Date().toLocaleTimeString();
}
const pollElement = document.getElementById("scannerPollCount");
if (pollElement) {
pollElement.textContent = "Scanner poll count: " + scannerPollCount;
}
}
function setRepairExportStatus(message, className) {
const element = document.getElementById("repairExportStatus");
if (!element) {
return;
}
element.textContent = message;
element.className = "status " + className;
}
async function exportRepairEventsCsv() {
try {
if (!token) {
throw new Error("Missing token. Please log in.");
}
const params = new URLSearchParams();
params.set("limit", "5000");
const search = document.getElementById("repairSearchBox")?.value.trim() || "";
const status = document.getElementById("repairStatusFilter")?.value || "";
const type = document.getElementById("repairTypeFilter")?.value || "";
if (search) {
params.set("q", search);
}
if (status) {
params.set("event_status", status);
}
if (type) {
params.set("event_type", type);
}
setRepairExportStatus("Preparing CSV export...", "warn");
const response = await fetch("/api/maintenance/events/export.csv?" + params.toString(), {
headers: {
"Authorization": "Bearer " + token
}
});
if (!response.ok) {
let errorText = "CSV export failed.";
try {
const result = await response.json();
errorText = result.detail || errorText;
} catch (ignore) {}
throw new Error(errorText);
}
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
const now = new Date();
const timestamp = now.toISOString().replaceAll(":", "").replaceAll("-", "").split(".")[0];
link.href = downloadUrl;
link.download = "minerops-maintenance-events-" + timestamp + ".csv";
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(downloadUrl);
setRepairExportStatus("CSV export downloaded.", "ok");
} catch (error) {
setRepairExportStatus("CSV export error: " + error.message, "bad");
}
}
async function apiPatch(path, bodyData) {
if (!token) {
throw new Error("Missing token. Please log in.");
}
const response = await fetch(path, {
method: "PATCH",
headers: {
"Authorization": "Bearer " + token,
"Content-Type": "application/json"
},
body: JSON.stringify(bodyData)
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || "API error");
}
return result.data;
}
function setRepairActionStatus(message, className) {
const ids = ["repairActionStatus", "openRepairActionStatus"];
for (const id of ids) {
const element = document.getElementById(id);
if (element) {
element.textContent = message;
element.className = "status " + className;
}
}
}
async function updateRepairStatus(minerIdForEvent, eventId, newStatus) {
try {
const confirmed = confirm("Change this repair status to " + newStatus + "?");
if (!confirmed) {
return;
}
setRepairActionStatus("Updating repair status...", "warn");
await apiPatch(
"/api/miners/" + encodeURIComponent(minerIdForEvent) + "/maintenance-events/" + encodeURIComponent(eventId),
{
event_status: newStatus
}
);
setRepairActionStatus("Repair status updated to " + newStatus + ".", "ok");
await refreshAll();
} catch (error) {
setRepairActionStatus("Error updating repair status: " + error.message, "bad");
}
}
function repairActionButtons(event) {
const minerIdForEvent = event.miner_id || "";
const eventId = event.event_id || "";
return `
<button onclick="updateRepairStatus('${minerIdForEvent}', '${eventId}', 'completed')">Complete</button>
<button onclick="updateRepairStatus('${minerIdForEvent}', '${eventId}', 'needs_followup')">Follow-up</button>
<button onclick="updateRepairStatus('${minerIdForEvent}', '${eventId}', 'in_progress')">In Progress</button>
<button class="danger" onclick="updateRepairStatus('${minerIdForEvent}', '${eventId}', 'failed')">Failed</button>
`;
}
function renderOpenRepairs() {
const openStatuses = ["needs_followup", "in_progress", "failed"];
const openEvents = repairEvents.filter(event => openStatuses.includes(event.event_status));
const counts = {
needs_followup: 0,
in_progress: 0,
failed: 0
};
for (const event of openEvents) {
if (counts[event.event_status] !== undefined) {
counts[event.event_status] += 1;
}
}
document.getElementById("openRepairTotal").textContent = fmtNumber(openEvents.length);
document.getElementById("openRepairFollowup").textContent = fmtNumber(counts.needs_followup);
document.getElementById("openRepairInProgress").textContent = fmtNumber(counts.in_progress);
document.getElementById("openRepairFailed").textContent = fmtNumber(counts.failed);
const search = document.getElementById("openRepairSearchBox")?.value.trim().toLowerCase() || "";
const filtered = openEvents.filter(event => {
if (!search) {
return true;
}
const haystack = [
event.event_at,
event.event_status,
event.event_type,
event.event_title,
event.description,
event.current_mac,
event.current_ip,
event.old_mac,
event.new_mac,
event.room_name,
event.model,
event.miner_type,
event.firmware_version,
event.miner_sn,
event.power_sn,
event.component_type,
event.component_slot,
event.component_serial,
event.created_by_username
].join(" ").toLowerCase();
return haystack.includes(search);
});
if (filtered.length === 0) {
document.getElementById("openRepairBody").innerHTML = `
<tr>
<td colspan="14">No open repairs found.</td>
</tr>
`;
return;
}
document.getElementById("openRepairBody").innerHTML = filtered.map(event => {
let statusClass = "pill warn";
if (event.event_status === "failed") {
statusClass = "pill hot";
}
return `
<tr>
<td>${escapeHtml(event.event_at)}</td>
<td><span class="${statusClass}">${escapeHtml(event.event_status)}</span></td>
<td>${repairActionButtons(event)}</td>
<td>${escapeHtml(event.event_type)}</td>
<td><a href="/miners/${encodeURIComponent(event.miner_id)}/detail" target="_blank">${escapeHtml(event.current_mac)}</a></td>
<td>${escapeHtml(event.current_ip)}</td>
<td>${escapeHtml(event.room_name)}</td>
<td>${escapeHtml(event.miner_type || event.model)}</td>
<td>${escapeHtml(event.event_title)}</td>
<td>${escapeHtml(event.component_type)}</td>
<td>${escapeHtml(event.component_slot)}</td>
<td>${escapeHtml(event.component_serial)}</td>
<td>${escapeHtml(event.description)}</td>
<td>${escapeHtml(event.created_by_username)}</td>
</tr>
`;
}).join("");
}
function renderRepairEvents() {
const statusCounts = {
completed: 0,
needs_followup: 0,
in_progress: 0
};
for (const event of repairEvents) {
if (statusCounts[event.event_status] !== undefined) {
statusCounts[event.event_status] += 1;
}
}
document.getElementById("repairTotal").textContent = fmtNumber(repairEvents.length);
document.getElementById("repairCompleted").textContent = fmtNumber(statusCounts.completed);
document.getElementById("repairFollowup").textContent = fmtNumber(statusCounts.needs_followup);
document.getElementById("repairInProgress").textContent = fmtNumber(statusCounts.in_progress);
const search = document.getElementById("repairSearchBox")?.value.trim().toLowerCase() || "";
const status = document.getElementById("repairStatusFilter")?.value || "";
const type = document.getElementById("repairTypeFilter")?.value || "";
const filtered = repairEvents.filter(event => {
if (status && event.event_status !== status) {
return false;
}
if (type && event.event_type !== type) {
return false;
}
if (!search) {
return true;
}
const haystack = [
event.event_at,
event.event_status,
event.event_type,
event.event_title,
event.description,
event.current_mac,
event.current_ip,
event.old_mac,
event.new_mac,
event.room_name,
event.model,
event.miner_type,
event.firmware_version,
event.miner_sn,
event.power_sn,
event.component_type,
event.component_slot,
event.component_serial,
event.created_by_username
].join(" ").toLowerCase();
return haystack.includes(search);
});
document.getElementById("repairBody").innerHTML = filtered.map(event => {
let statusClass = "pill online";
if (event.event_status === "needs_followup") {
statusClass = "pill warn";
}
if (event.event_status === "failed") {
statusClass = "pill hot";
}
if (event.event_status === "in_progress") {
statusClass = "pill warn";
}
return `
<tr>
<td>${escapeHtml(event.event_at)}</td>
<td><span class="${statusClass}">${escapeHtml(event.event_status)}</span></td>
<td>${repairActionButtons(event)}</td>
<td>${escapeHtml(event.event_type)}</td>
<td><a href="/miners/${encodeURIComponent(event.miner_id)}/detail" target="_blank">${escapeHtml(event.current_mac)}</a></td>
<td>${escapeHtml(event.current_ip)}</td>
<td>${escapeHtml(event.room_name)}</td>
<td>${escapeHtml(event.miner_type || event.model)}</td>
<td>${escapeHtml(event.event_title)}</td>
<td>${escapeHtml(event.component_type)}</td>
<td>${escapeHtml(event.component_slot)}</td>
<td>${escapeHtml(event.component_serial)}</td>
<td>${escapeHtml(event.description)}</td>
<td>${escapeHtml(event.created_by_username)}</td>
</tr>
`;
}).join("");
}
function renderIssues() {
const severityCounts = {
critical: 0,
warning: 0,
info: 0
};
for (const issue of issues) {
if (severityCounts[issue.severity] !== undefined) {
severityCounts[issue.severity] += 1;
}
}
document.getElementById("issueTotal").textContent = fmtNumber(issues.length);
document.getElementById("issueCritical").textContent = fmtNumber(severityCounts.critical);
document.getElementById("issueWarning").textContent = fmtNumber(severityCounts.warning);
document.getElementById("issueInfo").textContent = fmtNumber(severityCounts.info);
const search = document.getElementById("issueSearchBox")?.value.trim().toLowerCase() || "";
const severity = document.getElementById("issueSeverityFilter")?.value || "";
const filtered = issues.filter(issue => {
if (severity && issue.severity !== severity) {
return false;
}
if (!search) {
return true;
}
const haystack = [
issue.severity,
issue.issue_type,
issue.title,
issue.detail,
issue.current_mac,
issue.current_ip,
issue.room_name,
issue.model,
issue.firmware_version
].join(" ").toLowerCase();
return haystack.includes(search);
});
document.getElementById("issueBody").innerHTML = filtered.map(issue => {
let severityClass = "pill warn";
if (issue.severity === "critical") {
severityClass = "pill hot";
}
if (issue.severity === "info") {
severityClass = "pill online";
}
return `
<tr>
<td><span class="${severityClass}">${escapeHtml(issue.severity)}</span></td>
<td>${escapeHtml(issue.title)}</td>
<td><a href="/miners/${encodeURIComponent(issue.miner_id)}/detail" target="_blank">${escapeHtml(issue.current_mac)}</a></td>
<td>${escapeHtml(issue.current_ip)}</td>
<td>${escapeHtml(issue.room_name)}</td>
<td>${escapeHtml(issue.model)}</td>
<td>${escapeHtml(issue.firmware_version)}</td>
<td>${escapeHtml(issue.detail)}</td>
<td>${escapeHtml(issue.last_seen_at)}</td>
</tr>
`;
}).join("");
}
function formatCommandLabel(row) {
const action = row.action || "unknown";
const mode = row.performance_mode || "";
if (action === "performance_mode" && mode) {
return "Set " + mode.charAt(0).toUpperCase() + mode.slice(1) + " Mode";
}
return action;
}
function ensureBulkActionDashboardPanel() {
let panel = document.getElementById("dashboardBulkActionStatusPanel");
if (panel) {
return panel;
}
panel = document.createElement("div");
panel.id = "dashboardBulkActionStatusPanel";
panel.className = "card";
panel.innerHTML = `
<h2>Bulk Action Status</h2>
<div id="bulkModeSummary" class="small"></div>
<div id="bulkRecentSummary" class="small" style="margin-top: 8px;"></div>
<h3 style="margin-top: 14px;">Recent Failures / Timeouts</h3>
<div id="bulkRecentFailures" class="small"></div>
<h3 style="margin-top: 14px;">Recent Miner Control Commands</h3>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>Created</th>
<th>Status</th>
<th>Action</th>
<th>Unit</th>
<th>MAC</th>
<th>IP</th>
<th>User</th>
<th>Error</th>
</tr>
</thead>
<tbody id="bulkRecentCommandsBody"></tbody>
</table>
</div>
`;
const mainTable = document.getElementById("mainTableBody");
const tableCard = mainTable ? mainTable.closest(".card") : null;
if (tableCard && tableCard.parentNode) {
tableCard.parentNode.insertBefore(panel, tableCard);
} else {
document.body.appendChild(panel);
}
return panel;
}
function renderBulkActionDashboardStatus() {
const panel = ensureBulkActionDashboardPanel();
const data = bulkActionDashboardStatus || {};
const modeCounts = data.configured_mode_counts || [];
const recentSummary = data.recent_summary || [];
const failures = data.recent_failures || [];
const recent = data.recent_commands || [];
document.getElementById("bulkModeSummary").innerHTML = modeCounts.length
? modeCounts.map(row => {
return '<span class="pill">' + escapeHtml(row.configured_mode) + ': ' + escapeHtml(row.miner_count) + '</span> '
+ '<span class="small">(' + escapeHtml(row.source) + ')</span>';
}).join(" &nbsp; ")
: "No configured mode data yet.";
document.getElementById("bulkRecentSummary").innerHTML = recentSummary.length
? "Last 6 hours: " + recentSummary.map(row => {
const label = formatCommandLabel(row);
return '<span class="pill">' + escapeHtml(label) + ' / ' + escapeHtml(row.command_status) + ': ' + escapeHtml(row.command_count) + '</span>';
}).join(" ")
: "No miner control commands in the last 6 hours.";
document.getElementById("bulkRecentFailures").innerHTML = failures.length
? failures.map(row => {
return '<div><strong>' + escapeHtml(row.command_status) + '</strong> '
+ escapeHtml(formatCommandLabel(row)) + ' '
+ escapeHtml(row.room_name || '') + ' '
+ escapeHtml(row.current_mac || '') + ' '
+ escapeHtml(row.current_ip || '') + ' - '
+ escapeHtml(row.error_message || 'No error message') + '</div>';
}).join("")
: '<span class="pill online">No recent failures found</span>';
document.getElementById("bulkRecentCommandsBody").innerHTML = recent.map(row => {
const statusClass = row.command_status === "completed" ? "online" : (row.command_status === "failed" ? "hot" : "warn");
return `
<tr>
<td>${escapeHtml(row.created_at || "")}</td>
<td><span class="pill ${statusClass}">${escapeHtml(row.command_status || "")}</span></td>
<td>${escapeHtml(formatCommandLabel(row))}</td>
<td>${escapeHtml(row.room_name || "")}</td>
<td>${escapeHtml(row.current_mac || "")}</td>
<td>${escapeHtml(row.current_ip || "")}</td>
<td>${escapeHtml(row.requested_by_username || "")}</td>
<td>${escapeHtml(row.error_message || "")}</td>
</tr>
`;
}).join("");
}
function ensureScheduledBulkActionsPanel() {
let panel = document.getElementById("scheduledBulkActionsPanel");
if (panel) {
return panel;
}
panel = document.createElement("div");
panel.id = "scheduledBulkActionsPanel";
panel.className = "card";
panel.innerHTML = `
<h2>Scheduled Bulk Actions</h2>
<div class="small">Schedule suspend, resume, or Low / Normal / High mode changes for units, models, or selected miners. Due schedules are processed when the Windows Agent checks in.</div>
<div class="grid" style="margin-top: 12px;">
<div>
<label>Name</label>
<input id="scheduledBulkName" type="text" placeholder="Unit 4 evening low mode">
</div>
<div>
<label>Target Type</label>
<select id="scheduledBulkTargetType" onchange="renderScheduledTargetOptions()">
<option value="unit">Unit</option>
<option value="all">All Online Miners</option>
<option value="model">Model</option>
<option value="selected_miners">Selected Dashboard Miners</option>
<option value="configured_mode">Currently Configured Mode</option>
</select>
</div>
<div>
<label>Target</label>
<select id="scheduledBulkTargetValue"></select>
</div>
<div>
<label>Action</label>
<select id="scheduledBulkAction">
<option value="performance_low">Set Low Mode</option>
<option value="performance_normal">Set Normal Mode</option>
<option value="performance_high">Set High Mode - Use Carefully</option>
<option value="suspend">Suspend</option>
<option value="resume">Resume</option>
</select>
</div>
<div>
<label>Repeat</label>
<select id="scheduledBulkRepeat">
<option value="once">One time</option>
<option value="daily">Daily</option>
<option value="weekdays">Weekdays</option>
<option value="weekends">Weekends</option>
</select>
</div>
<div>
<label>Date / Time</label>
<input id="scheduledBulkWhen" type="datetime-local">
</div>
</div>
<div style="margin-top: 12px;">
<button type="button" onclick="createScheduledBulkAction()">Create Schedule</button>
<button type="button" class="secondary" onclick="processDueScheduledBulkActionsNow()">Process Due Now</button>
<span id="scheduledBulkStatus" class="status">Ready</span>
</div>
<h3 style="margin-top: 14px;">Scheduled Actions</h3>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>Scheduled For</th>
<th>Status</th>
<th>Name</th>
<th>Target</th>
<th>Action</th>
<th>Repeat</th>
<th>Queued</th>
<th>Commands</th>
<th>User</th>
<th>Error</th>
</tr>
</thead>
<tbody id="scheduledBulkActionsBody"></tbody>
</table>
</div>
`;
const bulkPanel = document.getElementById("dashboardBulkActionStatusPanel");
if (bulkPanel && bulkPanel.parentNode) {
bulkPanel.parentNode.insertBefore(panel, bulkPanel.nextSibling);
} else {
const mainTable = document.getElementById("mainTableBody");
const tableCard = mainTable ? mainTable.closest(".card") : null;
if (tableCard && tableCard.parentNode) {
tableCard.parentNode.insertBefore(panel, tableCard);
} else {
document.body.appendChild(panel);
}
}
return panel;
}
function setScheduledBulkStatus(message, className) {
const element = document.getElementById("scheduledBulkStatus");
if (!element) {
return;
}
element.textContent = message || "Ready";
element.className = "status " + (className || "");
}
function renderScheduledTargetOptions() {
const typeElement = document.getElementById("scheduledBulkTargetType");
const valueElement = document.getElementById("scheduledBulkTargetValue");
if (!typeElement || !valueElement) {
return;
}
const targetType = typeElement.value;
const data = scheduledBulkActions || {};
let options = [];
if (targetType === "all") {
options = [{ value: "", label: "All Online Miners" }];
valueElement.disabled = true;
} else if (targetType === "unit") {
options = (data.units || []).map(name => ({ value: name, label: name }));
valueElement.disabled = false;
} else if (targetType === "model") {
options = (data.models || []).map(name => ({ value: name, label: name }));
valueElement.disabled = false;
} else if (targetType === "selected_miners") {
const selected = Array.from(selectedMinerIds || []);
if (selected.length) {
options = [{
value: selected.join(","),
label: selected.length + " selected miner" + (selected.length === 1 ? "" : "s")
}];
} else {
options = [{
value: "",
label: "No dashboard miners selected"
}];
}
valueElement.disabled = true;
} else if (targetType === "configured_mode") {
options = [
{ value: "low", label: "Currently Low" },
{ value: "normal", label: "Currently Normal" },
{ value: "high", label: "Currently High" },
{ value: "", label: "Currently Unknown" }
];
valueElement.disabled = false;
}
valueElement.innerHTML = options.map(opt => {
return '<option value="' + escapeHtml(opt.value) + '">' + escapeHtml(opt.label) + '</option>';
}).join("");
}
function formatScheduledActionLabel(row) {
const action = row.action || "performance_mode";
const mode = row.performance_mode || "";
if (action === "performance_mode") {
return "Set " + (mode ? mode.charAt(0).toUpperCase() + mode.slice(1) : "Unknown") + " Mode";
}
if (action === "suspend") {
return "Suspend";
}
if (action === "resume") {
return "Resume";
}
return action;
}
function scheduledActionButtonsHtml(row) {
const id = row.id || "";
const status = row.status || "";
const canCancel = ["pending", "queued", "running", "failed", "completed_with_errors"].includes(status);
const cancelButton = canCancel
? '<button type="button" class="secondary" style="margin-left:6px;padding:4px 8px;font-size:12px;" onclick="cancelScheduledBulkAction(\\'' + escapeHtml(id) + '\\')">Cancel</button>'
: "";
const deleteButton = '<button type="button" class="danger" style="margin-left:6px;padding:4px 8px;font-size:12px;" onclick="deleteScheduledBulkAction(\\'' + escapeHtml(id) + '\\')">Delete</button>';
return cancelButton + deleteButton;
}
function renderScheduledBulkActions() {
ensureScheduledBulkActionsPanel();
renderScheduledTargetOptions();
const rows = (scheduledBulkActions && scheduledBulkActions.schedules) ? scheduledBulkActions.schedules : [];
document.getElementById("scheduledBulkActionsBody").innerHTML = rows.map(row => {
let targetLabel = row.target_type === "all" ? "All Online Miners" : ((row.target_type || "") + ": " + (row.target_value || ""));
if (row.target_type === "selected_miners") {
const count = row.target_value ? String(row.target_value).split(",").filter(Boolean).length : 0;
targetLabel = "selected miners: " + count;
}
const actionLabel = formatScheduledActionLabel(row);
const repeatLabel = row.repeat_rule || "once";
const statusLower = row.status || "";
const statusClass = ["pending", "queued", "running"].includes(statusLower)
? "warn"
: (["failed", "completed_with_errors"].includes(statusLower) ? "hot" : "online");
const canCancel = ["pending", "queued", "running", "failed"].includes(row.status || "");
const cancelButton = canCancel
? '<button type="button" class="secondary" onclick="cancelScheduledBulkAction(\\'' + escapeHtml(row.id) + '\\')">Cancel</button>'
: "";
const deleteButton = '<button type="button" class="danger" onclick="deleteScheduledBulkAction(\\'' + escapeHtml(row.id) + '\\')">Delete</button>';
const actionButtons = cancelButton + " " + deleteButton;
return `
<tr>
<td>${escapeHtml(row.scheduled_for || "")}</td>
<td><span class="pill ${statusClass}">${escapeHtml(row.status || "")}</span>${scheduledActionButtonsHtml(row)}</td>
<td>${escapeHtml(row.name || "")}</td>
<td>${escapeHtml(targetLabel)}</td>
<td>${escapeHtml(actionLabel)}</td>
<td>${escapeHtml(repeatLabel)}</td>
<td>${escapeHtml(row.queued_command_count || 0)} total / ${escapeHtml(row.last_queued_command_count || 0)} last</td>
<td>
<span class="pill">Total ${escapeHtml(row.command_total_count || 0)}</span>
<span class="pill online">Done ${escapeHtml(row.command_completed_count || 0)}</span>
<span class="pill warn">Pending ${escapeHtml(row.command_pending_count || 0)}</span>
<span class="pill hot">Failed ${escapeHtml(row.command_failed_count || 0)}</span>
<span class="pill">Cancelled ${escapeHtml(row.command_cancelled_count || 0)}</span>
</td>
<td>${escapeHtml(row.requested_by_username || "")}</td>
<td>${escapeHtml(row.last_error || "")}</td>
</tr>
`;
}).join("");
}
async function createScheduledBulkAction() {
try {
const name = document.getElementById("scheduledBulkName")?.value || "";
const targetType = document.getElementById("scheduledBulkTargetType")?.value || "unit";
const targetValue = document.getElementById("scheduledBulkTargetValue")?.value || "";
const actionChoice = document.getElementById("scheduledBulkAction")?.value || "performance_normal";
const repeatRule = document.getElementById("scheduledBulkRepeat")?.value || "once";
const whenLocal = document.getElementById("scheduledBulkWhen")?.value || "";
let action = actionChoice;
let mode = "";
if (actionChoice === "performance_low") {
action = "performance_mode";
mode = "low";
} else if (actionChoice === "performance_normal") {
action = "performance_mode";
mode = "normal";
} else if (actionChoice === "performance_high") {
action = "performance_mode";
mode = "high";
}
if (targetType === "selected_miners" && !targetValue) {
setScheduledBulkStatus("Select one or more dashboard miners first.", "warn");
return;
}
if (!whenLocal) {
setScheduledBulkStatus("Choose a date/time first.", "warn");
return;
}
if (mode === "high") {
const highOk = prompt("High Mode can increase heat and power draw. Type HIGH to schedule it.");
if (highOk !== "HIGH") {
setScheduledBulkStatus("High Mode schedule cancelled.", "warn");
return;
}
}
if ((action === "suspend" || action === "resume") && repeatRule !== "once") {
const ok = confirm("Create a recurring " + action + " schedule? This can repeatedly stop or start miners.");
if (!ok) {
setScheduledBulkStatus("Recurring " + action + " schedule cancelled.", "warn");
return;
}
}
const scheduledDate = new Date(whenLocal);
if (isNaN(scheduledDate.getTime())) {
setScheduledBulkStatus("Invalid date/time.", "bad");
return;
}
const body = {
name: name,
target_type: targetType,
target_value: targetValue,
action: action,
performance_mode: mode,
repeat_rule: repeatRule,
scheduled_for: scheduledDate.toISOString()
};
setScheduledBulkStatus("Creating schedule...", "warn");
await apiPostJson("/api/dashboard/scheduled-bulk-actions", body);
setScheduledBulkStatus("Schedule created.", "ok");
await refreshAll();
} catch (error) {
setScheduledBulkStatus("Error: " + error.message, "bad");
}
}
async function cancelScheduledBulkAction(actionId) {
try {
if (!confirm("Cancel this scheduled action?")) {
return;
}
setScheduledBulkStatus("Cancelling schedule...", "warn");
await apiPostJson("/api/dashboard/scheduled-bulk-actions/" + encodeURIComponent(actionId) + "/cancel", {});
setScheduledBulkStatus("Schedule cancelled.", "ok");
await refreshAll();
} catch (error) {
setScheduledBulkStatus("Error: " + error.message, "bad");
}
}
async function deleteScheduledBulkAction(actionId) {
try {
if (!confirm("Delete this scheduled action? This removes it from the schedule list and cancels any pending commands from it.")) {
return;
}
setScheduledBulkStatus("Deleting schedule...", "warn");
await apiPostJson("/api/dashboard/scheduled-bulk-actions/" + encodeURIComponent(actionId) + "/delete", {});
setScheduledBulkStatus("Schedule deleted.", "ok");
await refreshAll();
} catch (error) {
setScheduledBulkStatus("Error: " + error.message, "bad");
}
}
async function processDueScheduledBulkActionsNow() {
try {
setScheduledBulkStatus("Processing due schedules...", "warn");
await apiPostJson("/api/dashboard/scheduled-bulk-actions/process-due", {});
setScheduledBulkStatus("Due schedules processed.", "ok");
await refreshAll();
} catch (error) {
setScheduledBulkStatus("Error: " + error.message, "bad");
}
}
function formatMinerOpsSeconds(value) {
const seconds = Number(value || 0);
if (!seconds || seconds <= 0) {
return "";
}
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return days + "d " + hours + "h";
}
if (hours > 0) {
return hours + "h " + minutes + "m";
}
return minutes + "m";
}
function formatRuntimeNumber(value, decimals) {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) {
return "";
}
return number.toFixed(decimals || 0);
}
function ensurePoolInfoPanel() {
let panel = document.getElementById("poolInfoPanel");
if (panel) {
return panel;
}
panel = document.createElement("div");
panel.id = "poolInfoPanel";
panel.className = "card";
panel.innerHTML = `
<h2>Pool Info</h2>
<div class="small">
Latest pool details collected through the WhatsMiner 4028 pools command.
</div>
<div style="overflow-x:auto; margin-top: 10px;">
<table>
<thead>
<tr>
<th>Unit</th>
<th>MAC</th>
<th>IP</th>
<th>Model</th>
<th>Pool</th>
<th>Status</th>
<th>Active</th>
<th>Worker/User</th>
<th>URL</th>
<th>Accepted</th>
<th>Rejected</th>
<th>Rejected %</th>
<th>Last Share</th>
</tr>
</thead>
<tbody id="poolInfoBody"></tbody>
</table>
</div>
`;
const runtimePanel = document.getElementById("runtimeFieldsPanel");
if (runtimePanel && runtimePanel.parentNode) {
runtimePanel.parentNode.insertBefore(panel, runtimePanel.nextSibling);
} else {
document.body.appendChild(panel);
}
return panel;
}
function renderPoolInfoPanel() {
ensurePoolInfoPanel();
const rows = (poolInfoLatest && poolInfoLatest.pool_info)
? poolInfoLatest.pool_info
: [];
document.getElementById("poolInfoBody").innerHTML = rows.map(row => {
const statusClass = (row.pool_status || "").toLowerCase() === "alive" ? "online" : "warn";
const activeText = row.stratum_active === true ? "Yes" : (row.stratum_active === false ? "No" : "");
return `
<tr>
<td>${escapeHtml(row.room_name || "")}</td>
<td>${escapeHtml(row.current_mac || "")}</td>
<td>${escapeHtml(row.current_ip || "")}</td>
<td>${escapeHtml(row.model || "")}</td>
<td>${escapeHtml(row.pool_index || "")}</td>
<td><span class="pill ${statusClass}">${escapeHtml(row.pool_status || "")}</span></td>
<td>${escapeHtml(activeText)}</td>
<td>${escapeHtml(row.worker_user || "")}</td>
<td>${escapeHtml(row.url || "")}</td>
<td>${escapeHtml(row.accepted || 0)}</td>
<td>${escapeHtml(row.rejected || 0)}</td>
<td>${escapeHtml(formatRuntimeNumber(row.pool_rejected_percent, 4))}</td>
<td>${escapeHtml(row.last_share_time || "")}</td>
</tr>
`;
}).join("");
}
function ensureRuntimeFieldsPanel() {
let panel = document.getElementById("runtimeFieldsPanel");
if (panel) {
return panel;
}
panel = document.createElement("div");
panel.id = "runtimeFieldsPanel";
panel.className = "card";
panel.innerHTML = `
<h2>Runtime / Fan Details</h2>
<div class="small">
Latest fan, environment, uptime, and elapsed readings from the newest snapshot for each miner.
</div>
<div style="overflow-x:auto; margin-top: 10px;">
<table>
<thead>
<tr>
<th>Unit</th>
<th>MAC</th>
<th>IP</th>
<th>Model</th>
<th>Miner SN</th>
<th>Env C</th>
<th>Fan In</th>
<th>Fan Out</th>
<th>Uptime</th>
<th>Elapsed</th>
</tr>
</thead>
<tbody id="runtimeFieldsBody"></tbody>
</table>
</div>
`;
const m30Panel = document.getElementById("m30CommandTestLabPanel");
const scheduledPanel = document.getElementById("scheduledBulkActionsPanel");
if (m30Panel && m30Panel.parentNode) {
m30Panel.parentNode.insertBefore(panel, m30Panel.nextSibling);
} else if (scheduledPanel && scheduledPanel.parentNode) {
scheduledPanel.parentNode.insertBefore(panel, scheduledPanel.nextSibling);
} else {
document.body.appendChild(panel);
}
return panel;
}
function renderRuntimeFieldsPanel() {
ensureRuntimeFieldsPanel();
const rows = (runtimeFieldsLatest && runtimeFieldsLatest.runtime_fields)
? runtimeFieldsLatest.runtime_fields
: [];
const visibleRows = rows.filter(row => {
return row.fan_speed_in || row.fan_speed_out || row.env_temp_c || row.uptime_seconds || row.elapsed_seconds || row.miner_sn;
});
document.getElementById("runtimeFieldsBody").innerHTML = visibleRows.map(row => {
return `
<tr>
<td>${escapeHtml(row.room_name || "")}</td>
<td>${escapeHtml(row.current_mac || "")}</td>
<td>${escapeHtml(row.current_ip || "")}</td>
<td>${escapeHtml(row.model || "")}</td>
<td>${escapeHtml(row.miner_sn || "")}</td>
<td>${escapeHtml(formatRuntimeNumber(row.env_temp_c, 1))}</td>
<td>${escapeHtml(formatRuntimeNumber(row.fan_speed_in, 0))}</td>
<td>${escapeHtml(formatRuntimeNumber(row.fan_speed_out, 0))}</td>
<td>${escapeHtml(formatMinerOpsSeconds(row.uptime_seconds))}</td>
<td>${escapeHtml(formatMinerOpsSeconds(row.elapsed_seconds))}</td>
</tr>
`;
}).join("");
}
function ensureM30CommandTestLabPanel() {
let panel = document.getElementById("m30CommandTestLabPanel");
if (panel) {
return panel;
}
panel = document.createElement("div");
panel.id = "m30CommandTestLabPanel";
panel.className = "card";
panel.innerHTML = `
<h2>M30 Command Test Lab</h2>
<div class="small">
Select exactly one miner from the dashboard table, then test one command at a time.
Use this for M30 / M30S / M30S+ compatibility testing.
</div>
<div style="margin-top: 12px;">
<label>Test Action</label>
<select id="m30TestAction">
<option value="reboot">Reboot</option>
<option value="suspend">Suspend</option>
<option value="resume">Resume</option>
<option value="low">Set Low Mode</option>
<option value="normal">Set Normal Mode</option>
<option value="high">Set High Mode - Use Carefully</option>
<option value="legacy_4028_summary">Legacy 4028 Summary - Read Only</option>
<option value="legacy_4028_version">Legacy 4028 Version - Read Only</option>
<option value="legacy_4028_pools">Legacy 4028 Pools - Read Only</option>
<option value="legacy_4028_devs">Legacy 4028 Devs - Read Only</option>
<option value="legacy_4028_stats">Legacy 4028 Stats - Read Only</option>
<option value="legacy_4028_reboot">Legacy 4028 Reboot - Test Carefully</option>
<option value="legacy_4028_restart">Legacy 4028 Restart - Test Carefully</option>
</select>
<button type="button" onclick="runM30CommandTest()">Queue Test Command</button>
<span id="m30CommandTestStatus" class="status">Ready</span>
</div>
<div id="m30SelectedMinerInfo" class="small" style="margin-top: 8px;"></div>
<h3 style="margin-top: 14px;">Recent M30 Command Tests</h3>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>Created</th>
<th>Status</th>
<th>Action</th>
<th>Unit</th>
<th>MAC</th>
<th>IP</th>
<th>Model</th>
<th>User</th>
<th>Error</th>
</tr>
</thead>
<tbody id="m30CommandTestBody"></tbody>
</table>
</div>
`;
const scheduledPanel = document.getElementById("scheduledBulkActionsPanel");
if (scheduledPanel && scheduledPanel.parentNode) {
scheduledPanel.parentNode.insertBefore(panel, scheduledPanel.nextSibling);
} else {
const mainTable = document.getElementById("mainTableBody");
const tableCard = mainTable ? mainTable.closest(".card") : null;
if (tableCard && tableCard.parentNode) {
tableCard.parentNode.insertBefore(panel, tableCard);
} else {
document.body.appendChild(panel);
}
}
return panel;
}
function setM30CommandTestStatus(message, className) {
const element = document.getElementById("m30CommandTestStatus");
if (!element) {
return;
}
element.textContent = message || "Ready";
element.className = "status " + (className || "");
}
function getSelectedMinerRowsForM30Lab() {
const selected = Array.from(selectedMinerIds || []);
return selected.map(id => minerTable.find(row => row.id === id)).filter(Boolean);
}
function formatM30TestAction(row) {
const testAction = row.test_action || row.action || "";
if (testAction.indexOf("legacy_4028_") === 0) {
return "Legacy 4028 " + testAction.replace("legacy_4028_", "").replace("_", " ");
}
if (testAction === "low") {
return "Set Low Mode";
}
if (testAction === "normal") {
return "Set Normal Mode";
}
if (testAction === "high") {
return "Set High Mode";
}
return testAction;
}
function renderM30CommandTestLab() {
ensureM30CommandTestLabPanel();
const selectedRows = getSelectedMinerRowsForM30Lab();
const info = document.getElementById("m30SelectedMinerInfo");
if (selectedRows.length === 1) {
const row = selectedRows[0];
info.innerHTML = "Selected: "
+ escapeHtml(row.room_name || "") + " | "
+ escapeHtml(row.current_mac || "") + " | "
+ escapeHtml(row.current_ip || "") + " | "
+ escapeHtml(row.miner_type || row.model || "");
} else if (selectedRows.length > 1) {
info.innerHTML = '<span class="pill warn">Select only one miner for this test lab. Currently selected: ' + escapeHtml(selectedRows.length) + '</span>';
} else {
info.innerHTML = '<span class="pill warn">No miner selected.</span>';
}
const rows = (m30CommandTestLab && m30CommandTestLab.recent_tests) ? m30CommandTestLab.recent_tests : [];
document.getElementById("m30CommandTestBody").innerHTML = rows.map(row => {
const status = row.command_status || "";
const statusClass = status === "completed" ? "online" : (status === "failed" ? "hot" : "warn");
return `
<tr>
<td>${escapeHtml(row.created_at || "")}</td>
<td><span class="pill ${statusClass}">${escapeHtml(status)}</span></td>
<td>${escapeHtml(formatM30TestAction(row))}</td>
<td>${escapeHtml(row.room_name || "")}</td>
<td>${escapeHtml(row.current_mac || "")}</td>
<td>${escapeHtml(row.current_ip || "")}</td>
<td>${escapeHtml(row.model || "")}</td>
<td>${escapeHtml(row.requested_by_username || "")}</td>
<td>${escapeHtml(row.error_message || "")}</td>
<td><code>${escapeHtml(row.result_preview || "")}</code></td>
</tr>
`;
}).join("");
}
async function runM30CommandTest() {
try {
const selectedRows = getSelectedMinerRowsForM30Lab();
if (selectedRows.length !== 1) {
setM30CommandTestStatus("Select exactly one miner first.", "warn");
return;
}
const action = document.getElementById("m30TestAction")?.value || "reboot";
if (action === "high") {
const highOk = prompt("High Mode can increase heat and power draw. Type HIGH to continue.");
if (highOk !== "HIGH") {
setM30CommandTestStatus("High Mode test cancelled.", "warn");
return;
}
}
const ok = confirm("Queue M30 command test '" + action + "' for " + (selectedRows[0].current_mac || selectedRows[0].current_ip) + "?");
if (!ok) {
setM30CommandTestStatus("Test cancelled.", "warn");
return;
}
setM30CommandTestStatus("Queueing test command...", "warn");
await apiPostJson("/api/dashboard/m30-command-test-lab/run", {
miner_id: selectedRows[0].id,
test_action: action
});
setM30CommandTestStatus("Test command queued.", "ok");
await refreshAll();
} catch (error) {
setM30CommandTestStatus("Error: " + error.message, "bad");
}
}
function renderBreakdowns() {
const models = dashboard.by_model || [];
const firmware = dashboard.by_firmware || [];
document.getElementById("modelBody").innerHTML = models.map(row => `
<tr>
<td>${escapeHtml(row.model)}</td>
<td>${escapeHtml(row.miner_count)}</td>
</tr>
`).join("");
document.getElementById("firmwareBody").innerHTML = firmware.map(row => `
<tr>
<td>${escapeHtml(row.firmware_version)}</td>
<td>${escapeHtml(row.miner_count)}</td>
</tr>
`).join("");
}
function setActiveTab(name) {
for (const tab of ["All", "Hot", "Unstable", "Stale"]) {
document.getElementById("tab" + tab).classList.remove("active");
}
const id = "tab" + name.charAt(0).toUpperCase() + name.slice(1);
document.getElementById(id).classList.add("active");
}
function showTable(name) {
currentTable = name;
setActiveTab(name);
renderCurrentTable();
}
function getCurrentRows() {
if (currentTable === "hot") {
return hotMiners;
}
if (currentTable === "unstable") {
return unstableMiners;
}
if (currentTable === "stale") {
return staleMiners;
}
return minerTable;
}
function rowMatchesFilters(row) {
const search = document.getElementById("searchBox").value.trim().toLowerCase();
const unit = document.getElementById("unitFilter").value;
const mode = document.getElementById("modeFilter")?.value || "";
if (unit && row.room_name !== unit) {
return false;
}
if (mode) {
const rowMode = row.performance_mode ? String(row.performance_mode).trim().toLowerCase() : "unknown";
if (rowMode !== mode) {
return false;
}
}
if (!search) {
return true;
}
const haystack = [
row.room_name,
row.current_ip,
row.current_mac,
row.hostname,
row.miner_sn,
row.power_sn,
row.model,
row.miner_type,
row.firmware_version,
row.status
].join(" ").toLowerCase();
return haystack.includes(search);
}
function updateBulkActionStatus(message, className) {
const element = document.getElementById("bulkActionStatus");
if (!element) {
return;
}
const suffix = message ? " - " + message : "";
element.textContent = "Selected: " + selectedMinerIds.size + suffix;
element.className = "status " + (className || "");
if (typeof renderScheduledTargetOptions === "function") {
renderScheduledTargetOptions();
}
if (typeof renderM30CommandTestLab === "function") {
renderM30CommandTestLab();
}
}
function getVisibleRowsForBulkActions() {
return getCurrentRows().filter(rowMatchesFilters);
}
function toggleMinerSelection(minerId, checked) {
if (!minerId) {
return;
}
if (checked) {
selectedMinerIds.add(minerId);
} else {
selectedMinerIds.delete(minerId);
}
updateBulkActionStatus("", "");
}
function selectVisibleMiners() {
const rows = getVisibleRowsForBulkActions();
for (const row of rows) {
if (row.id) {
selectedMinerIds.add(row.id);
}
}
renderCurrentTable();
updateBulkActionStatus("visible miners selected", "ok");
}
function clearSelectedMiners() {
selectedMinerIds.clear();
renderCurrentTable();
updateBulkActionStatus("selection cleared", "warn");
}
async function postBulkMinerControl(minerId, action, performanceMode) {
const body = {
action: action,
dry_run: false
};
if (performanceMode) {
body.performance_mode = performanceMode;
}
return apiPostJson("/api/miners/" + encodeURIComponent(minerId) + "/agent-control", body);
}
async function apiPostJson(path, bodyData) {
if (!token) {
throw new Error("Missing token. Please log in.");
}
const response = await fetch(path, {
method: "POST",
headers: {
"Authorization": "Bearer " + token,
"Content-Type": "application/json"
},
body: JSON.stringify(bodyData || {})
});
const rawText = await response.text();
let result = {};
try {
result = rawText ? JSON.parse(rawText) : {};
} catch (err) {
result = {
detail: rawText || "Non-JSON API response"
};
}
if (!response.ok) {
throw new Error(result.detail || result.message || rawText || "API error");
}
return result.data || result;
}
async function runBulkMinerAction() {
const actionValue = document.getElementById("bulkActionSelect")?.value || "";
const selected = Array.from(selectedMinerIds);
if (!actionValue) {
updateBulkActionStatus("choose a bulk action first", "warn");
return;
}
if (selected.length === 0) {
updateBulkActionStatus("no miners selected", "warn");
return;
}
let action = actionValue;
let performanceMode = "";
let label = actionValue;
if (actionValue === "performance_low") {
action = "performance_mode";
performanceMode = "low";
label = "set Low Mode";
} else if (actionValue === "performance_normal") {
action = "performance_mode";
performanceMode = "normal";
label = "set Normal Mode";
} else if (actionValue === "performance_high") {
action = "performance_mode";
performanceMode = "high";
label = "set High Mode";
}
if (actionValue === "performance_high") {
const highOk = prompt("High Mode can increase heat and power draw. Type HIGH to continue.");
if (highOk !== "HIGH") {
updateBulkActionStatus("High Mode cancelled", "warn");
return;
}
}
const confirmed = confirm("Run bulk action '" + label + "' on " + selected.length + " selected miners?");
if (!confirmed) {
updateBulkActionStatus("bulk action cancelled", "warn");
return;
}
updateBulkActionStatus("queueing " + selected.length + " commands...", "warn");
let ok = 0;
let failed = 0;
for (const minerId of selected) {
try {
await postBulkMinerControl(minerId, action, performanceMode);
ok += 1;
} catch (err) {
failed += 1;
console.error("Bulk command failed for miner", minerId, err);
}
}
updateBulkActionStatus("queued " + ok + ", failed " + failed, failed ? "warn" : "ok");
await refreshAll();
}
function renderCurrentTable() {
const rows = getCurrentRows().filter(rowMatchesFilters);
if (currentTable === "stale") {
renderStaleTable(rows);
return;
}
renderMinerTable(rows);
}
function renderMinerTable(rows) {
document.getElementById("mainTableHead").innerHTML = `
<tr>
<th>Select</th>
<th>Unit</th>
<th>MAC</th>
<th>IP</th>
<th>Model</th>
<th>Firmware</th>
<th>App Status</th>
<th>Configured Mode</th>
<th>TH/s</th>
<th>Power W</th>
<th>W/TH</th>
<th>Max C</th>
<th>Avg C</th>
<th>Hash Stable</th>
<th>Rejected %</th>
<th>Last Seen</th>
</tr>
`;
document.getElementById("mainTableBody").innerHTML = rows.map(row => {
const tempClass = Number(row.chip_temp_max_c) >= 90 ? "pill hot" : "";
const stableText = row.hash_stable === false ? '<span class="pill warn">No</span>' : "Yes";
const status = '<span class="pill online">' + escapeHtml(row.status) + '</span>';
const modeRaw = row.performance_mode ? String(row.performance_mode) : "Unknown";
const modeLower = modeRaw.toLowerCase();
const runtimePowerState = row.runtime_power_state ? String(row.runtime_power_state) : "Unknown";
const modeTitle = "Configured Mode: " + modeRaw
+ "\\nSource: " + (row.configured_performance_mode_source || "none")
+ "\\nUpdated: " + (row.configured_performance_mode_updated_at || "never")
+ "\\nRuntime Power State: " + runtimePowerState;
let modeStyle = "background:#e5e7eb;color:#111827;";
if (modeLower === "low") {
modeStyle = "background:#dcfce7;color:#166534;";
} else if (modeLower === "normal") {
modeStyle = "background:#dbeafe;color:#1e40af;";
} else if (modeLower === "high") {
modeStyle = "background:#fee2e2;color:#991b1b;";
}
const modeBadge = '<span class="pill" title="' + escapeHtml(modeTitle) + '" style="' + modeStyle + '">' + escapeHtml(modeRaw) + '</span>';
return `
<tr>
<td><input type="checkbox" class="bulkMinerCheckbox" onchange="toggleMinerSelection('${row.id}', this.checked)" ${selectedMinerIds.has(row.id) ? "checked" : ""}></td>
<td>${escapeHtml(row.room_name)}</td>
<td><a href="/miners/${encodeURIComponent(row.id)}/detail" target="_blank">${escapeHtml(row.current_mac)}</a></td>
<td>${escapeHtml(row.current_ip)}</td>
<td>${escapeHtml(row.miner_type || row.model)}</td>
<td>${escapeHtml(row.firmware_version)}</td>
<td>${status}</td>
<td>${modeBadge}</td>
<td>${escapeHtml(row.hashrate_ths)}</td>
<td>${escapeHtml(row.power_w)}</td>
<td>${escapeHtml(row.power_rate_w_per_th)}</td>
<td><span class="${tempClass}">${escapeHtml(fmtTemp(row.chip_temp_max_c))}</span></td>
<td>${escapeHtml(fmtTemp(row.chip_temp_avg_c))}</td>
<td>${stableText}</td>
<td>${escapeHtml(row.pool_rejected_percent)}</td>
<td>${escapeHtml(row.last_seen_at)}</td>
</tr>
`;
}).join("");
updateBulkActionStatus("", "");
}
function renderStaleTable(rows) {
document.getElementById("mainTableHead").innerHTML = `
<tr>
<th>Select</th>
<th>Unit</th>
<th>MAC</th>
<th>IP</th>
<th>Model</th>
<th>Firmware</th>
<th>App Status</th>
<th>Minutes Since Seen</th>
<th>Last Seen</th>
</tr>
`;
document.getElementById("mainTableBody").innerHTML = rows.map(row => `
<tr>
<td>${escapeHtml(row.room_name)}</td>
<td><a href="/miners/${encodeURIComponent(row.id)}/detail" target="_blank">${escapeHtml(row.current_mac)}</a></td>
<td>${escapeHtml(row.current_ip)}</td>
<td>${escapeHtml(row.miner_type || row.model)}</td>
<td>${escapeHtml(row.firmware_version)}</td>
<td>${escapeHtml(row.status)}</td>
<td>${escapeHtml(row.minutes_since_seen)}</td>
<td>${escapeHtml(row.last_seen_at)}</td>
</tr>
`).join("");
}
if (token) {
setLoginStatus("Saved token found in this browser.", "ok");
refreshAll();
}
if (typeof selectVisibleMiners === "function") {
window.selectVisibleMiners = selectVisibleMiners;
}
if (typeof clearSelectedMiners === "function") {
window.clearSelectedMiners = clearSelectedMiners;
}
if (typeof runBulkMinerAction === "function") {
window.runBulkMinerAction = runBulkMinerAction;
}
if (typeof toggleMinerSelection === "function") {
window.toggleMinerSelection = toggleMinerSelection;
}
</script>
<div id="dashboardLocationMapLink" style="position: fixed; top: 12px; right: 12px; z-index: 9999;">
<a href="/locations" style="display: inline-block; padding: 10px 14px; background: #0b63ce; color: white; border-radius: 8px; text-decoration: none; font-weight: bold; box-shadow: 0 2px 6px rgba(15, 23, 42, 0.25);">
Location Map
</a>
</div>
<script src="/assets/minerops-shell-auth.js"></script>
<script src="/assets/minerops-global-search.js"></script>
<script src="/assets/minerops-header-scan.js"></script>
<script src="/assets/minerops-hub-mode.js"></script>
<script src="/assets/minerops-miner-detail-extra.js?v=1"></script>
</body>
</html>
"""
@app.get("/dashboard", response_class=HTMLResponse)
def minerops_dashboard_page():
return """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>MinerOps Dashboard</title>
<style>
body {
margin: 0;
font-family: Arial, sans-serif;
background: #f4f6f8;
color: #1f2933;
}
header {
background: #0f172a;
color: white;
padding: 18px 28px;
}
header h1 {
margin: 0;
font-size: 24px;
}
header .sub {
color: #cbd5e1;
margin-top: 4px;
}
.container {
padding: 24px;
max-width: 1600px;
margin: auto;
}
.card {
background: white;
border: 1px solid #d9e2ec;
border-radius: 8px;
padding: 18px;
margin-bottom: 18px;
box-shadow: 0 2px 5px rgba(0,0,0,0.04);
}
.login-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: end;
}
label {
font-size: 13px;
font-weight: bold;
display: block;
margin-bottom: 4px;
}
input, select {
padding: 9px;
border: 1px solid #bcccdc;
border-radius: 5px;
}
button {
padding: 10px 14px;
border: none;
border-radius: 5px;
background: #2563eb;
color: white;
font-weight: bold;
cursor: pointer;
}
button.secondary {
background: #475569;
}
button.danger {
background: #b91c1c;
}
.status {
margin-top: 10px;
padding: 9px;
border-radius: 5px;
}
.ok {
background: #dcfce7;
border: 1px solid #86efac;
}
.warn {
background: #fef9c3;
border: 1px solid #fde047;
}
.bad {
background: #fee2e2;
border: 1px solid #fca5a5;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 12px;
}
.metric {
background: #f8fafc;
border: 1px solid #e2e8f0;
padding: 14px;
border-radius: 7px;
}
.metric .label {
font-size: 12px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.metric .value {
font-size: 26px;
font-weight: bold;
margin-top: 6px;
}
.section-title {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.section-title h2 {
margin: 0;
font-size: 19px;
}
.tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.tab {
background: #e2e8f0;
color: #0f172a;
}
.tab.active {
background: #2563eb;
color: white;
}
.table-wrap {
overflow-x: auto;
max-height: 620px;
overflow-y: auto;
border: 1px solid #e2e8f0;
border-radius: 7px;
}
table {
border-collapse: collapse;
width: 100%;
font-size: 13px;
background: white;
}
th, td {
padding: 8px 10px;
border-bottom: 1px solid #e2e8f0;
text-align: left;
white-space: nowrap;
}
th {
background: #f1f5f9;
position: sticky;
top: 0;
z-index: 1;
}
tr:hover {
background: #f8fafc;
}
.pill {
display: inline-block;
padding: 3px 7px;
border-radius: 999px;
font-size: 12px;
font-weight: bold;
}
.pill.online {
background: #dcfce7;
color: #166534;
}
.pill.hot {
background: #fee2e2;
color: #991b1b;
}
.pill.warn {
background: #fef9c3;
color: #854d0e;
border: none;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 12px;
align-items: end;
}
.controls input {
min-width: 260px;
}
.small {
color: #64748b;
font-size: 12px;
}
.two-col {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 18px;
}
pre {
background: #111827;
color: #f9fafb;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
}
/* Step 79: Unified app look */
:root {{
--app-bg: #f4f7fb;
--app-card: #ffffff;
--app-border: #d9e2ec;
--app-text: #102a43;
--app-muted: #52606d;
--app-blue: #0b63ce;
--app-blue-dark: #084b9a;
--app-green: #0f766e;
--app-yellow-bg: #fff7ed;
--app-yellow-border: #fdba74;
--app-red-bg: #fef2f2;
--app-red-border: #fca5a5;
--app-green-bg: #ecfdf5;
--app-green-border: #6ee7b7;
}}
body {{
background: var(--app-bg) !important;
color: var(--app-text);
font-family: Segoe UI, Arial, sans-serif;
margin: 0;
padding: 24px;
}}
.topbar {{
display: none !important;
}}
#dashboardLocationMapLink {{
display: none !important;
}}
.app-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);
}}
.app-brand h1 {{
margin: 0;
color: white;
font-size: 28px;
line-height: 1.1;
}}
.app-kicker {{
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.85;
margin-bottom: 4px;
}}
.app-nav {{
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
align-items: center;
}}
.app-nav-link,
.app-nav-button {{
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: 700;
font-size: 13px;
text-decoration: none;
cursor: pointer;
}}
.app-nav-link:hover,
.app-nav-button:hover {{
background: rgba(255, 255, 255, 0.24);
}}
.app-nav-link.active {{
background: white;
color: var(--app-blue) !important;
}}
.card {{
background: var(--app-card) !important;
border: 1px solid var(--app-border) !important;
border-radius: 14px !important;
padding: 16px !important;
margin-bottom: 16px !important;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
}}
.card h2 {{
color: var(--app-text);
margin-top: 0;
}}
.small,
.muted {{
color: var(--app-muted);
}}
button,
input[type="button"],
input[type="submit"] {{
background: var(--app-blue);
color: white;
border: 0;
border-radius: 8px;
padding: 8px 11px;
font-weight: 700;
cursor: pointer;
}}
button:hover,
input[type="button"]:hover,
input[type="submit"]:hover {{
background: var(--app-blue-dark);
}}
button.secondary {{
background: #64748b;
}}
button.danger,
.danger {{
background: #dc2626 !important;
color: white !important;
}}
input,
select,
textarea {{
border: 1px solid #bcccdc;
border-radius: 8px;
padding: 8px;
background: white;
color: var(--app-text);
}}
table {{
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 10px;
overflow: hidden;
}}
th {{
background: #eef2ff;
color: #1e293b;
text-align: left;
font-size: 13px;
}}
th,
td {{
border-bottom: 1px solid #e2e8f0;
padding: 8px;
vertical-align: top;
}}
tr:hover td {{
background: #f8fafc;
}}
.status {{
border-radius: 10px;
padding: 10px 12px;
margin-top: 10px;
border: 1px solid var(--app-border);
background: #f8fafc;
}}
.status.ok {{
background: var(--app-green-bg);
border-color: var(--app-green-border);
}}
.status.warn {{
background: var(--app-yellow-bg);
border-color: var(--app-yellow-border);
}}
.status.bad {{
background: var(--app-red-bg);
border-color: var(--app-red-border);
}}
.sticky-mini-toolbar {{
border-radius: 14px !important;
border: 1px solid var(--app-border) !important;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12) !important;
}}
@media (max-width: 800px) {{
body {{
padding: 12px;
}}
.app-header {{
align-items: flex-start;
flex-direction: column;
}}
.app-nav {{
justify-content: flex-start;
}}
}}
</style>
</head>
<body>
<header>
<h1>MinerOps Dashboard</h1>
<div class="sub">Plexus Mining Site</div>
</header>
<div class="container">
<div class="card">
<div class="section-title">
<h2>Login</h2>
<button class="secondary" onclick="refreshAll()">Refresh Dashboard</button>
</div>
<div class="login-row">
<div>
<label>Username</label>
<input id="username" type="text" value="admin">
</div>
<div>
<label>Password</label>
<input id="password" type="password">
</div>
<div>
<button onclick="login()">Login</button>
</div>
<div>
<button class="secondary" onclick="logout()">Clear Token</button>
</div>
</div>
<div id="loginStatus" class="status warn">Not logged in.</div>
<div class="small">Token is stored in this browser until it expires or you click Clear Token.</div>
</div>
<div class="card">
<div class="section-title">
<h2>Fleet Summary</h2>
<span id="lastRefresh" class="small">Not refreshed yet.</span>
</div>
<div class="grid">
<div class="metric"><div class="label">Total Miners</div><div class="value" id="totalMiners">-</div></div>
<div class="metric"><div class="label">Online</div><div class="value" id="onlineMiners">-</div></div>
<div class="metric"><div class="label">Unit 4</div><div class="value" id="unit4Count">-</div></div>
<div class="metric"><div class="label">Unit 6</div><div class="value" id="unit6Count">-</div></div>
<div class="metric"><div class="label">Hot >= 90C</div><div class="value" id="hotCount">-</div></div>
<div class="metric"><div class="label">Unstable</div><div class="value" id="unstableCount">-</div></div>
<div class="metric"><div class="label">Stale 30m</div><div class="value" id="staleCount">-</div></div>
<div class="metric"><div class="label">Total TH/s</div><div class="value" id="totalHashrate">-</div></div>
<div class="metric"><div class="label">Total Power W</div><div class="value" id="totalPower">-</div></div>
</div>
</div>
<div class="card">
<div class="section-title">
<h2>Scanner Status</h2>
<button onclick="runKnownMinerScanNow()">Run Known-Miner Scan Now</button>
</div>
<div class="grid">
<div class="metric"><div class="label">State</div><div class="value" id="scannerState">-</div></div>
<div class="metric"><div class="label">Interval</div><div class="value" id="scannerInterval">-</div></div>
<div class="metric"><div class="label">Last Duration</div><div class="value" id="scannerDuration">-</div></div>
<div class="metric"><div class="label">Total Miners</div><div class="value" id="scannerTotal">-</div></div>
<div class="metric"><div class="label">Success</div><div class="value" id="scannerSuccess">-</div></div>
<div class="metric"><div class="label">Failed</div><div class="value" id="scannerFailed">-</div></div>
<div class="metric"><div class="label">Last Finished</div><div class="value" id="scannerLastFinished">-</div></div>
<div class="metric"><div class="label">Next Run</div><div class="value" id="scannerNextRun">-</div></div>
</div>
<div class="small" id="scannerLastStarted">Last started: -</div>
<div class="small" id="scannerPanelRefresh">Scanner panel refreshed: -</div>
<div class="small" id="scannerPollCount">Scanner poll count: 0</div>
</div>
<div class="card">
<div class="section-title">
<h2>Issues / Alerts</h2>
<span class="small">Sorted by severity, MAC-first.</span>
</div>
<div class="grid">
<div class="metric"><div class="label">Total Issues</div><div class="value" id="issueTotal">-</div></div>
<div class="metric"><div class="label">Critical</div><div class="value" id="issueCritical">-</div></div>
<div class="metric"><div class="label">Warning</div><div class="value" id="issueWarning">-</div></div>
<div class="metric"><div class="label">Info</div><div class="value" id="issueInfo">-</div></div>
</div>
<div class="controls">
<div>
<label>Issue Search</label>
<input id="issueSearchBox" type="text" placeholder="MAC, IP, model, issue..." oninput="renderIssues()">
</div>
<div>
<label>Severity</label>
<select id="issueSeverityFilter" onchange="renderIssues()">
<option value="">All</option>
<option value="critical">Critical</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</select>
</div>
</div>
<div id="repairActionStatus" class="status warn">Repair status actions ready.</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Severity</th>
<th>Issue</th>
<th>MAC</th>
<th>IP</th>
<th>Unit</th>
<th>Model</th>
<th>Firmware</th>
<th>Detail</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody id="issueBody"></tbody>
</table>
</div>
</div>
<div class="two-col">
<div class="card">
<h2>Models</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Model</th><th>Count</th></tr></thead>
<tbody id="modelBody"></tbody>
</table>
</div>
</div>
<div class="card">
<h2>Firmware</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Firmware</th><th>Count</th></tr></thead>
<tbody id="firmwareBody"></tbody>
</table>
</div>
</div>
</div>
<div class="card">
<div class="section-title">
<h2>Miner Tables</h2>
<span class="small">Search by IP, MAC, serial, model, firmware, or unit.</span>
</div>
<div class="tabs">
<button class="tab active" id="tabAll" onclick="showTable('all')">All Miners</button>
<button class="tab" id="tabHot" onclick="showTable('hot')">Hot Miners</button>
<button class="tab" id="tabUnstable" onclick="showTable('unstable')">Unstable Miners</button>
<button class="tab" id="tabStale" onclick="showTable('stale')">Stale Miners</button>
</div>
<div class="controls">
<div>
<label>Search</label>
<input id="searchBox" type="text" placeholder="10.30, CC:0C, M60S, serial..." oninput="renderCurrentTable()">
<select id="modeFilter" onchange="renderCurrentTable()">
<option value="">All Modes</option>
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
<option value="unknown">Unknown</option>
</select>
<button type="button" onclick="selectVisibleMiners()">Select Visible</button>
<button type="button" onclick="clearSelectedMiners()">Clear Selection</button>
<select id="bulkActionSelect">
<option value="">Bulk Action...</option>
<option value="suspend">Suspend Selected</option>
<option value="resume">Resume Selected</option>
<option value="performance_low">Set Low Mode</option>
<option value="performance_normal">Set Normal Mode</option>
<option value="performance_high">Set High Mode - Use Carefully</option>
</select>
<button type="button" class="danger" onclick="runBulkMinerAction()">Run Bulk Action</button>
<span id="bulkActionStatus" class="status">Selected: 0</span>
</div>
<div>
<label>Unit</label>
<select id="unitFilter" onchange="renderCurrentTable()">
<option value="">All Units</option>
<option value="Unit 4">Unit 4</option>
<option value="Unit 6">Unit 6</option>
<option value="Unit 12">Unit 12</option>
</select>
</div>
</div>
<div id="repairActionStatus" class="status warn">Repair status actions ready.</div>
<div class="table-wrap">
<table>
<thead id="mainTableHead"></thead>
<tbody id="mainTableBody"></tbody>
</table>
</div>
</div>
<div class="card">
<h2>Status</h2>
<pre id="rawStatus">Ready.</pre>
</div>
</div>
<script>
let token = localStorage.getItem("minerops_token") || "";
let dashboard = null;
let minerTable = [];
let selectedMinerIds = new Set();
let bulkActionDashboardStatus = {};
let scheduledBulkActions = {};
let m30CommandTestLab = {};
let hotMiners = [];
let unstableMiners = [];
let staleMiners = [];
let scannerStatus = null;
let scannerPollTimer = null;
let scannerPollCount = 0;
let issues = [];
let repairEvents = [];
let currentTable = "all";
function setLoginStatus(message, className) {
const element = document.getElementById("loginStatus");
element.textContent = message;
element.className = "status " + className;
}
function setRaw(message) {
document.getElementById("rawStatus").textContent = message;
}
function fmt(value) {
if (value === null || value === undefined || value === "") {
return "-";
}
return value;
}
function fmtNumber(value) {
if (value === null || value === undefined || value === "") {
return "-";
}
return Number(value).toLocaleString();
}
function fmtTemp(value) {
if (value === null || value === undefined) {
return "-";
}
return Number(value).toFixed(1);
}
function escapeHtml(value) {
return String(fmt(value))
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
async function apiGet(path) {
if (!token) {
throw new Error("Missing token. Please log in.");
}
const response = await fetch(path, {
headers: {
"Authorization": "Bearer " + token
}
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || "API error");
}
return result.data;
}
async function apiPost(path) {
if (!token) {
throw new Error("Missing token. Please log in.");
}
const response = await fetch(path, {
method: "POST",
headers: {
"Authorization": "Bearer " + token
}
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || "API error");
}
return result;
}
async function refreshScannerStatusOnly() {
try {
scannerStatus = await apiGet("/api/scanner/status");
scannerPollCount += 1;
renderScannerStatus();
if (scannerStatus.running) {
setRaw("Scanner is running... Success: " + scannerStatus.last_success_count + " | Failed: " + scannerStatus.last_failure_count);
scannerPollTimer = setTimeout(refreshScannerStatusOnly, 5000);
} else {
setRaw("Scanner is idle. Last scan success: " + scannerStatus.last_success_count + " | Failed: " + scannerStatus.last_failure_count);
if (scannerPollTimer) {
clearTimeout(scannerPollTimer);
scannerPollTimer = null;
}
await refreshAll();
}
} catch (error) {
setRaw("Scanner refresh error: " + error.message);
}
}
async function runKnownMinerScanNow() {
try {
setRaw("Starting known-miner scan...");
const result = await apiPost("/api/scanner/run-known-now");
scannerPollCount = 0;
scannerStatus = result.data;
renderScannerStatus();
if (scannerPollTimer) {
clearTimeout(scannerPollTimer);
scannerPollTimer = null;
}
scannerPollTimer = setTimeout(refreshScannerStatusOnly, 5000);
setRaw(result.meta?.message || "Known-miner scan requested.");
} catch (error) {
setRaw("Scanner error: " + error.message);
}
}
async function login() {
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
username: username,
password: password
})
});
const result = await response.json();
if (!response.ok) {
setLoginStatus("Login failed: " + (result.detail || "Unknown error"), "bad");
return;
}
token = result.data.access_token;
localStorage.setItem("minerops_token", token);
setLoginStatus("Logged in. Token expires in " + result.data.expires_in + " seconds.", "ok");
await refreshAll();
}
function logout() {
token = "";
localStorage.removeItem("minerops_token");
setLoginStatus("Token cleared.", "warn");
}
async function refreshAll() {
try {
setRaw("Refreshing dashboard...");
dashboard = await apiGet("/api/dashboard/summary");
minerTable = await apiGet("/api/dashboard/miner-table");
hotMiners = await apiGet("/api/dashboard/hot-miners?min_temp_c=90");
unstableMiners = await apiGet("/api/dashboard/unstable-miners");
staleMiners = await apiGet("/api/dashboard/stale-miners?minutes=30");
scannerStatus = await apiGet("/api/scanner/status");
issues = await apiGet("/api/dashboard/issues");
repairEvents = await apiGet("/api/maintenance/events?limit=100");
bulkActionDashboardStatus = await apiGet("/api/dashboard/bulk-action-status");
scheduledBulkActions = await apiGet("/api/dashboard/scheduled-bulk-actions");
m30CommandTestLab = await apiGet("/api/dashboard/m30-command-test-lab");
renderSummary();
renderScannerStatus();
renderIssues();
renderOpenRepairs();
renderRepairEvents();
renderBulkActionDashboardStatus();
renderScheduledBulkActions();
renderM30CommandTestLab();
renderBreakdowns();
renderCurrentTable();
document.getElementById("lastRefresh").textContent = "Last refresh: " + new Date().toLocaleString();
setRaw("Dashboard refreshed successfully.");
} catch (error) {
setRaw("Error: " + error.message);
if (String(error.message).toLowerCase().includes("token")) {
setLoginStatus("Please log in again.", "bad");
}
}
}
function renderSummary() {
const totals = dashboard.totals || {};
const telemetry = dashboard.telemetry || {};
const byRoom = dashboard.by_room || [];
const unit4 = byRoom.find(row => row.room_name === "Unit 4");
const unit6 = byRoom.find(row => row.room_name === "Unit 6");
document.getElementById("totalMiners").textContent = fmtNumber(totals.total_miners);
document.getElementById("onlineMiners").textContent = fmtNumber(totals.online_miners);
document.getElementById("unit4Count").textContent = fmtNumber(unit4 ? unit4.miner_count : 0);
document.getElementById("unit6Count").textContent = fmtNumber(unit6 ? unit6.miner_count : 0);
document.getElementById("hotCount").textContent = fmtNumber(telemetry.miners_chip_temp_90c_or_higher);
document.getElementById("unstableCount").textContent = fmtNumber(telemetry.miners_hash_unstable);
document.getElementById("staleCount").textContent = fmtNumber(totals.not_seen_last_30_minutes);
document.getElementById("totalHashrate").textContent = fmtNumber(telemetry.total_hashrate_ths);
document.getElementById("totalPower").textContent = fmtNumber(telemetry.total_power_w);
}
function fmtDateTime(value) {
if (!value) {
return "-";
}
try {
return new Date(value).toLocaleString();
} catch {
return value;
}
}
function renderScannerStatus() {
if (!scannerStatus) {
return;
}
document.getElementById("scannerState").textContent = scannerStatus.running ? "Running" : "Idle";
document.getElementById("scannerInterval").textContent = scannerStatus.interval_seconds + "s";
document.getElementById("scannerDuration").textContent = scannerStatus.last_duration_seconds ? scannerStatus.last_duration_seconds + "s" : "-";
document.getElementById("scannerTotal").textContent = fmtNumber(scannerStatus.last_total_miners);
document.getElementById("scannerSuccess").textContent = fmtNumber(scannerStatus.last_success_count);
document.getElementById("scannerFailed").textContent = fmtNumber(scannerStatus.last_failure_count);
document.getElementById("scannerLastFinished").textContent = fmtDateTime(scannerStatus.last_finished_at);
document.getElementById("scannerNextRun").textContent = fmtDateTime(scannerStatus.next_run_at);
document.getElementById("scannerLastStarted").textContent = "Last started: " + fmtDateTime(scannerStatus.last_started_at);
const refreshElement = document.getElementById("scannerPanelRefresh");
if (refreshElement) {
refreshElement.textContent = "Scanner panel refreshed: " + new Date().toLocaleTimeString();
}
const pollElement = document.getElementById("scannerPollCount");
if (pollElement) {
pollElement.textContent = "Scanner poll count: " + scannerPollCount;
}
}
function setRepairExportStatus(message, className) {
const element = document.getElementById("repairExportStatus");
if (!element) {
return;
}
element.textContent = message;
element.className = "status " + className;
}
async function exportRepairEventsCsv() {
try {
if (!token) {
throw new Error("Missing token. Please log in.");
}
const params = new URLSearchParams();
params.set("limit", "5000");
const search = document.getElementById("repairSearchBox")?.value.trim() || "";
const status = document.getElementById("repairStatusFilter")?.value || "";
const type = document.getElementById("repairTypeFilter")?.value || "";
if (search) {
params.set("q", search);
}
if (status) {
params.set("event_status", status);
}
if (type) {
params.set("event_type", type);
}
setRepairExportStatus("Preparing CSV export...", "warn");
const response = await fetch("/api/maintenance/events/export.csv?" + params.toString(), {
headers: {
"Authorization": "Bearer " + token
}
});
if (!response.ok) {
let errorText = "CSV export failed.";
try {
const result = await response.json();
errorText = result.detail || errorText;
} catch (ignore) {}
throw new Error(errorText);
}
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
const now = new Date();
const timestamp = now.toISOString().replaceAll(":", "").replaceAll("-", "").split(".")[0];
link.href = downloadUrl;
link.download = "minerops-maintenance-events-" + timestamp + ".csv";
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(downloadUrl);
setRepairExportStatus("CSV export downloaded.", "ok");
} catch (error) {
setRepairExportStatus("CSV export error: " + error.message, "bad");
}
}
async function apiPatch(path, bodyData) {
if (!token) {
throw new Error("Missing token. Please log in.");
}
const response = await fetch(path, {
method: "PATCH",
headers: {
"Authorization": "Bearer " + token,
"Content-Type": "application/json"
},
body: JSON.stringify(bodyData)
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || "API error");
}
return result.data;
}
function setRepairActionStatus(message, className) {
const ids = ["repairActionStatus", "openRepairActionStatus"];
for (const id of ids) {
const element = document.getElementById(id);
if (element) {
element.textContent = message;
element.className = "status " + className;
}
}
}
async function updateRepairStatus(minerIdForEvent, eventId, newStatus) {
try {
const confirmed = confirm("Change this repair status to " + newStatus + "?");
if (!confirmed) {
return;
}
setRepairActionStatus("Updating repair status...", "warn");
await apiPatch(
"/api/miners/" + encodeURIComponent(minerIdForEvent) + "/maintenance-events/" + encodeURIComponent(eventId),
{
event_status: newStatus
}
);
setRepairActionStatus("Repair status updated to " + newStatus + ".", "ok");
await refreshAll();
} catch (error) {
setRepairActionStatus("Error updating repair status: " + error.message, "bad");
}
}
function repairActionButtons(event) {
const minerIdForEvent = event.miner_id || "";
const eventId = event.event_id || "";
return `
<button onclick="updateRepairStatus('${minerIdForEvent}', '${eventId}', 'completed')">Complete</button>
<button onclick="updateRepairStatus('${minerIdForEvent}', '${eventId}', 'needs_followup')">Follow-up</button>
<button onclick="updateRepairStatus('${minerIdForEvent}', '${eventId}', 'in_progress')">In Progress</button>
<button class="danger" onclick="updateRepairStatus('${minerIdForEvent}', '${eventId}', 'failed')">Failed</button>
`;
}
function renderOpenRepairs() {
const openStatuses = ["needs_followup", "in_progress", "failed"];
const openEvents = repairEvents.filter(event => openStatuses.includes(event.event_status));
const counts = {
needs_followup: 0,
in_progress: 0,
failed: 0
};
for (const event of openEvents) {
if (counts[event.event_status] !== undefined) {
counts[event.event_status] += 1;
}
}
document.getElementById("openRepairTotal").textContent = fmtNumber(openEvents.length);
document.getElementById("openRepairFollowup").textContent = fmtNumber(counts.needs_followup);
document.getElementById("openRepairInProgress").textContent = fmtNumber(counts.in_progress);
document.getElementById("openRepairFailed").textContent = fmtNumber(counts.failed);
const search = document.getElementById("openRepairSearchBox")?.value.trim().toLowerCase() || "";
const filtered = openEvents.filter(event => {
if (!search) {
return true;
}
const haystack = [
event.event_at,
event.event_status,
event.event_type,
event.event_title,
event.description,
event.current_mac,
event.current_ip,
event.old_mac,
event.new_mac,
event.room_name,
event.model,
event.miner_type,
event.firmware_version,
event.miner_sn,
event.power_sn,
event.component_type,
event.component_slot,
event.component_serial,
event.created_by_username
].join(" ").toLowerCase();
return haystack.includes(search);
});
if (filtered.length === 0) {
document.getElementById("openRepairBody").innerHTML = `
<tr>
<td colspan="14">No open repairs found.</td>
</tr>
`;
return;
}
document.getElementById("openRepairBody").innerHTML = filtered.map(event => {
let statusClass = "pill warn";
if (event.event_status === "failed") {
statusClass = "pill hot";
}
return `
<tr>
<td>${escapeHtml(event.event_at)}</td>
<td><span class="${statusClass}">${escapeHtml(event.event_status)}</span></td>
<td>${repairActionButtons(event)}</td>
<td>${escapeHtml(event.event_type)}</td>
<td><a href="/miners/${encodeURIComponent(event.miner_id)}/detail" target="_blank">${escapeHtml(event.current_mac)}</a></td>
<td>${escapeHtml(event.current_ip)}</td>
<td>${escapeHtml(event.room_name)}</td>
<td>${escapeHtml(event.miner_type || event.model)}</td>
<td>${escapeHtml(event.event_title)}</td>
<td>${escapeHtml(event.component_type)}</td>
<td>${escapeHtml(event.component_slot)}</td>
<td>${escapeHtml(event.component_serial)}</td>
<td>${escapeHtml(event.description)}</td>
<td>${escapeHtml(event.created_by_username)}</td>
</tr>
`;
}).join("");
}
function renderRepairEvents() {
const statusCounts = {
completed: 0,
needs_followup: 0,
in_progress: 0
};
for (const event of repairEvents) {
if (statusCounts[event.event_status] !== undefined) {
statusCounts[event.event_status] += 1;
}
}
document.getElementById("repairTotal").textContent = fmtNumber(repairEvents.length);
document.getElementById("repairCompleted").textContent = fmtNumber(statusCounts.completed);
document.getElementById("repairFollowup").textContent = fmtNumber(statusCounts.needs_followup);
document.getElementById("repairInProgress").textContent = fmtNumber(statusCounts.in_progress);
const search = document.getElementById("repairSearchBox")?.value.trim().toLowerCase() || "";
const status = document.getElementById("repairStatusFilter")?.value || "";
const type = document.getElementById("repairTypeFilter")?.value || "";
const filtered = repairEvents.filter(event => {
if (status && event.event_status !== status) {
return false;
}
if (type && event.event_type !== type) {
return false;
}
if (!search) {
return true;
}
const haystack = [
event.event_at,
event.event_status,
event.event_type,
event.event_title,
event.description,
event.current_mac,
event.current_ip,
event.old_mac,
event.new_mac,
event.room_name,
event.model,
event.miner_type,
event.firmware_version,
event.miner_sn,
event.power_sn,
event.component_type,
event.component_slot,
event.component_serial,
event.created_by_username
].join(" ").toLowerCase();
return haystack.includes(search);
});
document.getElementById("repairBody").innerHTML = filtered.map(event => {
let statusClass = "pill online";
if (event.event_status === "needs_followup") {
statusClass = "pill warn";
}
if (event.event_status === "failed") {
statusClass = "pill hot";
}
if (event.event_status === "in_progress") {
statusClass = "pill warn";
}
return `
<tr>
<td>${escapeHtml(event.event_at)}</td>
<td><span class="${statusClass}">${escapeHtml(event.event_status)}</span></td>
<td>${repairActionButtons(event)}</td>
<td>${escapeHtml(event.event_type)}</td>
<td><a href="/miners/${encodeURIComponent(event.miner_id)}/detail" target="_blank">${escapeHtml(event.current_mac)}</a></td>
<td>${escapeHtml(event.current_ip)}</td>
<td>${escapeHtml(event.room_name)}</td>
<td>${escapeHtml(event.miner_type || event.model)}</td>
<td>${escapeHtml(event.event_title)}</td>
<td>${escapeHtml(event.component_type)}</td>
<td>${escapeHtml(event.component_slot)}</td>
<td>${escapeHtml(event.component_serial)}</td>
<td>${escapeHtml(event.description)}</td>
<td>${escapeHtml(event.created_by_username)}</td>
</tr>
`;
}).join("");
}
function renderIssues() {
const severityCounts = {
critical: 0,
warning: 0,
info: 0
};
for (const issue of issues) {
if (severityCounts[issue.severity] !== undefined) {
severityCounts[issue.severity] += 1;
}
}
document.getElementById("issueTotal").textContent = fmtNumber(issues.length);
document.getElementById("issueCritical").textContent = fmtNumber(severityCounts.critical);
document.getElementById("issueWarning").textContent = fmtNumber(severityCounts.warning);
document.getElementById("issueInfo").textContent = fmtNumber(severityCounts.info);
const search = document.getElementById("issueSearchBox")?.value.trim().toLowerCase() || "";
const severity = document.getElementById("issueSeverityFilter")?.value || "";
const filtered = issues.filter(issue => {
if (severity && issue.severity !== severity) {
return false;
}
if (!search) {
return true;
}
const haystack = [
issue.severity,
issue.issue_type,
issue.title,
issue.detail,
issue.current_mac,
issue.current_ip,
issue.room_name,
issue.model,
issue.firmware_version
].join(" ").toLowerCase();
return haystack.includes(search);
});
document.getElementById("issueBody").innerHTML = filtered.map(issue => {
let severityClass = "pill warn";
if (issue.severity === "critical") {
severityClass = "pill hot";
}
if (issue.severity === "info") {
severityClass = "pill online";
}
return `
<tr>
<td><span class="${severityClass}">${escapeHtml(issue.severity)}</span></td>
<td>${escapeHtml(issue.title)}</td>
<td><a href="/miners/${encodeURIComponent(issue.miner_id)}/detail" target="_blank">${escapeHtml(issue.current_mac)}</a></td>
<td>${escapeHtml(issue.current_ip)}</td>
<td>${escapeHtml(issue.room_name)}</td>
<td>${escapeHtml(issue.model)}</td>
<td>${escapeHtml(issue.firmware_version)}</td>
<td>${escapeHtml(issue.detail)}</td>
<td>${escapeHtml(issue.last_seen_at)}</td>
</tr>
`;
}).join("");
}
function formatCommandLabel(row) {
const action = row.action || "unknown";
const mode = row.performance_mode || "";
if (action === "performance_mode" && mode) {
return "Set " + mode.charAt(0).toUpperCase() + mode.slice(1) + " Mode";
}
return action;
}
function ensureBulkActionDashboardPanel() {
let panel = document.getElementById("dashboardBulkActionStatusPanel");
if (panel) {
return panel;
}
panel = document.createElement("div");
panel.id = "dashboardBulkActionStatusPanel";
panel.className = "card";
panel.innerHTML = `
<h2>Bulk Action Status</h2>
<div id="bulkModeSummary" class="small"></div>
<div id="bulkRecentSummary" class="small" style="margin-top: 8px;"></div>
<h3 style="margin-top: 14px;">Recent Failures / Timeouts</h3>
<div id="bulkRecentFailures" class="small"></div>
<h3 style="margin-top: 14px;">Recent Miner Control Commands</h3>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>Created</th>
<th>Status</th>
<th>Action</th>
<th>Unit</th>
<th>MAC</th>
<th>IP</th>
<th>User</th>
<th>Error</th>
</tr>
</thead>
<tbody id="bulkRecentCommandsBody"></tbody>
</table>
</div>
`;
const mainTable = document.getElementById("mainTableBody");
const tableCard = mainTable ? mainTable.closest(".card") : null;
if (tableCard && tableCard.parentNode) {
tableCard.parentNode.insertBefore(panel, tableCard);
} else {
document.body.appendChild(panel);
}
return panel;
}
function renderBulkActionDashboardStatus() {
const panel = ensureBulkActionDashboardPanel();
const data = bulkActionDashboardStatus || {};
const modeCounts = data.configured_mode_counts || [];
const recentSummary = data.recent_summary || [];
const failures = data.recent_failures || [];
const recent = data.recent_commands || [];
document.getElementById("bulkModeSummary").innerHTML = modeCounts.length
? modeCounts.map(row => {
return '<span class="pill">' + escapeHtml(row.configured_mode) + ': ' + escapeHtml(row.miner_count) + '</span> '
+ '<span class="small">(' + escapeHtml(row.source) + ')</span>';
}).join(" &nbsp; ")
: "No configured mode data yet.";
document.getElementById("bulkRecentSummary").innerHTML = recentSummary.length
? "Last 6 hours: " + recentSummary.map(row => {
const label = formatCommandLabel(row);
return '<span class="pill">' + escapeHtml(label) + ' / ' + escapeHtml(row.command_status) + ': ' + escapeHtml(row.command_count) + '</span>';
}).join(" ")
: "No miner control commands in the last 6 hours.";
document.getElementById("bulkRecentFailures").innerHTML = failures.length
? failures.map(row => {
return '<div><strong>' + escapeHtml(row.command_status) + '</strong> '
+ escapeHtml(formatCommandLabel(row)) + ' '
+ escapeHtml(row.room_name || '') + ' '
+ escapeHtml(row.current_mac || '') + ' '
+ escapeHtml(row.current_ip || '') + ' - '
+ escapeHtml(row.error_message || 'No error message') + '</div>';
}).join("")
: '<span class="pill online">No recent failures found</span>';
document.getElementById("bulkRecentCommandsBody").innerHTML = recent.map(row => {
const statusClass = row.command_status === "completed" ? "online" : (row.command_status === "failed" ? "hot" : "warn");
return `
<tr>
<td>${escapeHtml(row.created_at || "")}</td>
<td><span class="pill ${statusClass}">${escapeHtml(row.command_status || "")}</span></td>
<td>${escapeHtml(formatCommandLabel(row))}</td>
<td>${escapeHtml(row.room_name || "")}</td>
<td>${escapeHtml(row.current_mac || "")}</td>
<td>${escapeHtml(row.current_ip || "")}</td>
<td>${escapeHtml(row.requested_by_username || "")}</td>
<td>${escapeHtml(row.error_message || "")}</td>
</tr>
`;
}).join("");
}
function ensureScheduledBulkActionsPanel() {
let panel = document.getElementById("scheduledBulkActionsPanel");
if (panel) {
return panel;
}
panel = document.createElement("div");
panel.id = "scheduledBulkActionsPanel";
panel.className = "card";
panel.innerHTML = `
<h2>Scheduled Bulk Actions</h2>
<div class="small">Schedule suspend, resume, or Low / Normal / High mode changes for units, models, or selected miners. Due schedules are processed when the Windows Agent checks in.</div>
<div class="grid" style="margin-top: 12px;">
<div>
<label>Name</label>
<input id="scheduledBulkName" type="text" placeholder="Unit 4 evening low mode">
</div>
<div>
<label>Target Type</label>
<select id="scheduledBulkTargetType" onchange="renderScheduledTargetOptions()">
<option value="unit">Unit</option>
<option value="all">All Online Miners</option>
<option value="model">Model</option>
<option value="selected_miners">Selected Dashboard Miners</option>
<option value="configured_mode">Currently Configured Mode</option>
</select>
</div>
<div>
<label>Target</label>
<select id="scheduledBulkTargetValue"></select>
</div>
<div>
<label>Action</label>
<select id="scheduledBulkAction">
<option value="performance_low">Set Low Mode</option>
<option value="performance_normal">Set Normal Mode</option>
<option value="performance_high">Set High Mode - Use Carefully</option>
<option value="suspend">Suspend</option>
<option value="resume">Resume</option>
</select>
</div>
<div>
<label>Repeat</label>
<select id="scheduledBulkRepeat">
<option value="once">One time</option>
<option value="daily">Daily</option>
<option value="weekdays">Weekdays</option>
<option value="weekends">Weekends</option>
</select>
</div>
<div>
<label>Date / Time</label>
<input id="scheduledBulkWhen" type="datetime-local">
</div>
</div>
<div style="margin-top: 12px;">
<button type="button" onclick="createScheduledBulkAction()">Create Schedule</button>
<button type="button" class="secondary" onclick="processDueScheduledBulkActionsNow()">Process Due Now</button>
<span id="scheduledBulkStatus" class="status">Ready</span>
</div>
<h3 style="margin-top: 14px;">Scheduled Actions</h3>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>Scheduled For</th>
<th>Status</th>
<th>Name</th>
<th>Target</th>
<th>Action</th>
<th>Repeat</th>
<th>Queued</th>
<th>Commands</th>
<th>User</th>
<th>Error</th>
</tr>
</thead>
<tbody id="scheduledBulkActionsBody"></tbody>
</table>
</div>
`;
const bulkPanel = document.getElementById("dashboardBulkActionStatusPanel");
if (bulkPanel && bulkPanel.parentNode) {
bulkPanel.parentNode.insertBefore(panel, bulkPanel.nextSibling);
} else {
const mainTable = document.getElementById("mainTableBody");
const tableCard = mainTable ? mainTable.closest(".card") : null;
if (tableCard && tableCard.parentNode) {
tableCard.parentNode.insertBefore(panel, tableCard);
} else {
document.body.appendChild(panel);
}
}
return panel;
}
function setScheduledBulkStatus(message, className) {
const element = document.getElementById("scheduledBulkStatus");
if (!element) {
return;
}
element.textContent = message || "Ready";
element.className = "status " + (className || "");
}
function renderScheduledTargetOptions() {
const typeElement = document.getElementById("scheduledBulkTargetType");
const valueElement = document.getElementById("scheduledBulkTargetValue");
if (!typeElement || !valueElement) {
return;
}
const targetType = typeElement.value;
const data = scheduledBulkActions || {};
let options = [];
if (targetType === "all") {
options = [{ value: "", label: "All Online Miners" }];
valueElement.disabled = true;
} else if (targetType === "unit") {
options = (data.units || []).map(name => ({ value: name, label: name }));
valueElement.disabled = false;
} else if (targetType === "model") {
options = (data.models || []).map(name => ({ value: name, label: name }));
valueElement.disabled = false;
} else if (targetType === "selected_miners") {
const selected = Array.from(selectedMinerIds || []);
if (selected.length) {
options = [{
value: selected.join(","),
label: selected.length + " selected miner" + (selected.length === 1 ? "" : "s")
}];
} else {
options = [{
value: "",
label: "No dashboard miners selected"
}];
}
valueElement.disabled = true;
} else if (targetType === "configured_mode") {
options = [
{ value: "low", label: "Currently Low" },
{ value: "normal", label: "Currently Normal" },
{ value: "high", label: "Currently High" },
{ value: "", label: "Currently Unknown" }
];
valueElement.disabled = false;
}
valueElement.innerHTML = options.map(opt => {
return '<option value="' + escapeHtml(opt.value) + '">' + escapeHtml(opt.label) + '</option>';
}).join("");
}
function renderScheduledBulkActions() {
ensureScheduledBulkActionsPanel();
renderScheduledTargetOptions();
const rows = (scheduledBulkActions && scheduledBulkActions.schedules) ? scheduledBulkActions.schedules : [];
document.getElementById("scheduledBulkActionsBody").innerHTML = rows.map(row => {
let targetLabel = row.target_type === "all" ? "All Online Miners" : ((row.target_type || "") + ": " + (row.target_value || ""));
if (row.target_type === "selected_miners") {
const count = row.target_value ? String(row.target_value).split(",").filter(Boolean).length : 0;
targetLabel = "selected miners: " + count;
}
const actionLabel = formatScheduledActionLabel(row);
const repeatLabel = row.repeat_rule || "once";
const statusLower = row.status || "";
const statusClass = ["pending", "queued", "running"].includes(statusLower)
? "warn"
: (["failed", "completed_with_errors"].includes(statusLower) ? "hot" : "online");
const canCancel = ["pending", "queued", "running", "failed"].includes(row.status || "");
const cancelButton = canCancel
? '<button type="button" class="secondary" onclick="cancelScheduledBulkAction(\\'' + escapeHtml(row.id) + '\\')">Cancel</button>'
: "";
const deleteButton = '<button type="button" class="danger" onclick="deleteScheduledBulkAction(\\'' + escapeHtml(row.id) + '\\')">Delete</button>';
const actionButtons = cancelButton + " " + deleteButton;
return `
<tr>
<td>${escapeHtml(row.scheduled_for || "")}</td>
<td><span class="pill ${statusClass}">${escapeHtml(row.status || "")}</span>${scheduledActionButtonsHtml(row)}</td>
<td>${escapeHtml(row.name || "")}</td>
<td>${escapeHtml(targetLabel)}</td>
<td>${escapeHtml(actionLabel)}</td>
<td>${escapeHtml(repeatLabel)}</td>
<td>${escapeHtml(row.queued_command_count || 0)} total / ${escapeHtml(row.last_queued_command_count || 0)} last</td>
<td>
<span class="pill">Total ${escapeHtml(row.command_total_count || 0)}</span>
<span class="pill online">Done ${escapeHtml(row.command_completed_count || 0)}</span>
<span class="pill warn">Pending ${escapeHtml(row.command_pending_count || 0)}</span>
<span class="pill hot">Failed ${escapeHtml(row.command_failed_count || 0)}</span>
<span class="pill">Cancelled ${escapeHtml(row.command_cancelled_count || 0)}</span>
</td>
<td>${escapeHtml(row.requested_by_username || "")}</td>
<td>${escapeHtml(row.last_error || "")}</td>
</tr>
`;
}).join("");
}
async function createScheduledBulkAction() {
try {
const name = document.getElementById("scheduledBulkName")?.value || "";
const targetType = document.getElementById("scheduledBulkTargetType")?.value || "unit";
const targetValue = document.getElementById("scheduledBulkTargetValue")?.value || "";
const actionChoice = document.getElementById("scheduledBulkAction")?.value || "performance_normal";
const repeatRule = document.getElementById("scheduledBulkRepeat")?.value || "once";
const whenLocal = document.getElementById("scheduledBulkWhen")?.value || "";
let action = actionChoice;
let mode = "";
if (actionChoice === "performance_low") {
action = "performance_mode";
mode = "low";
} else if (actionChoice === "performance_normal") {
action = "performance_mode";
mode = "normal";
} else if (actionChoice === "performance_high") {
action = "performance_mode";
mode = "high";
}
if (!whenLocal) {
setScheduledBulkStatus("Choose a date/time first.", "warn");
return;
}
if (mode === "high") {
const highOk = prompt("High Mode can increase heat and power draw. Type HIGH to schedule it.");
if (highOk !== "HIGH") {
setScheduledBulkStatus("High Mode schedule cancelled.", "warn");
return;
}
}
if ((action === "suspend" || action === "resume") && repeatRule !== "once") {
const ok = confirm("Create a recurring " + action + " schedule? This can repeatedly stop or start miners.");
if (!ok) {
setScheduledBulkStatus("Recurring " + action + " schedule cancelled.", "warn");
return;
}
}
const scheduledDate = new Date(whenLocal);
if (isNaN(scheduledDate.getTime())) {
setScheduledBulkStatus("Invalid date/time.", "bad");
return;
}
const body = {
name: name,
target_type: targetType,
target_value: targetValue,
action: action,
performance_mode: mode,
repeat_rule: repeatRule,
scheduled_for: scheduledDate.toISOString()
};
setScheduledBulkStatus("Creating schedule...", "warn");
await apiPostJson("/api/dashboard/scheduled-bulk-actions", body);
setScheduledBulkStatus("Schedule created.", "ok");
await refreshAll();
} catch (error) {
setScheduledBulkStatus("Error: " + error.message, "bad");
}
}
async function cancelScheduledBulkAction(actionId) {
try {
if (!confirm("Cancel this scheduled action?")) {
return;
}
setScheduledBulkStatus("Cancelling schedule...", "warn");
await apiPostJson("/api/dashboard/scheduled-bulk-actions/" + encodeURIComponent(actionId) + "/cancel", {});
setScheduledBulkStatus("Schedule cancelled.", "ok");
await refreshAll();
} catch (error) {
setScheduledBulkStatus("Error: " + error.message, "bad");
}
}
async function processDueScheduledBulkActionsNow() {
try {
setScheduledBulkStatus("Processing due schedules...", "warn");
await apiPostJson("/api/dashboard/scheduled-bulk-actions/process-due", {});
setScheduledBulkStatus("Due schedules processed.", "ok");
await refreshAll();
} catch (error) {
setScheduledBulkStatus("Error: " + error.message, "bad");
}
}
function ensureM30CommandTestLabPanel() {
let panel = document.getElementById("m30CommandTestLabPanel");
if (panel) {
return panel;
}
panel = document.createElement("div");
panel.id = "m30CommandTestLabPanel";
panel.className = "card";
panel.innerHTML = `
<h2>M30 Command Test Lab</h2>
<div class="small">
Select exactly one miner from the dashboard table, then test one command at a time.
Use this for M30 / M30S / M30S+ compatibility testing.
</div>
<div style="margin-top: 12px;">
<label>Test Action</label>
<select id="m30TestAction">
<option value="reboot">Reboot</option>
<option value="suspend">Suspend</option>
<option value="resume">Resume</option>
<option value="low">Set Low Mode</option>
<option value="normal">Set Normal Mode</option>
<option value="high">Set High Mode - Use Carefully</option>
<option value="legacy_4028_summary">Legacy 4028 Summary - Read Only</option>
<option value="legacy_4028_version">Legacy 4028 Version - Read Only</option>
<option value="legacy_4028_pools">Legacy 4028 Pools - Read Only</option>
<option value="legacy_4028_devs">Legacy 4028 Devs - Read Only</option>
<option value="legacy_4028_stats">Legacy 4028 Stats - Read Only</option>
<option value="legacy_4028_reboot">Legacy 4028 Reboot - Test Carefully</option>
<option value="legacy_4028_restart">Legacy 4028 Restart - Test Carefully</option>
</select>
<button type="button" onclick="runM30CommandTest()">Queue Test Command</button>
<span id="m30CommandTestStatus" class="status">Ready</span>
</div>
<div id="m30SelectedMinerInfo" class="small" style="margin-top: 8px;"></div>
<h3 style="margin-top: 14px;">Recent M30 Command Tests</h3>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>Created</th>
<th>Status</th>
<th>Action</th>
<th>Unit</th>
<th>MAC</th>
<th>IP</th>
<th>Model</th>
<th>User</th>
<th>Error</th>
</tr>
</thead>
<tbody id="m30CommandTestBody"></tbody>
</table>
</div>
`;
const scheduledPanel = document.getElementById("scheduledBulkActionsPanel");
if (scheduledPanel && scheduledPanel.parentNode) {
scheduledPanel.parentNode.insertBefore(panel, scheduledPanel.nextSibling);
} else {
const mainTable = document.getElementById("mainTableBody");
const tableCard = mainTable ? mainTable.closest(".card") : null;
if (tableCard && tableCard.parentNode) {
tableCard.parentNode.insertBefore(panel, tableCard);
} else {
document.body.appendChild(panel);
}
}
return panel;
}
function setM30CommandTestStatus(message, className) {
const element = document.getElementById("m30CommandTestStatus");
if (!element) {
return;
}
element.textContent = message || "Ready";
element.className = "status " + (className || "");
}
function getSelectedMinerRowsForM30Lab() {
const selected = Array.from(selectedMinerIds || []);
return selected.map(id => minerTable.find(row => row.id === id)).filter(Boolean);
}
function formatM30TestAction(row) {
const testAction = row.test_action || row.action || "";
if (testAction === "low") {
return "Set Low Mode";
}
if (testAction === "normal") {
return "Set Normal Mode";
}
if (testAction === "high") {
return "Set High Mode";
}
return testAction;
}
function renderM30CommandTestLab() {
ensureM30CommandTestLabPanel();
const selectedRows = getSelectedMinerRowsForM30Lab();
const info = document.getElementById("m30SelectedMinerInfo");
if (selectedRows.length === 1) {
const row = selectedRows[0];
info.innerHTML = "Selected: "
+ escapeHtml(row.room_name || "") + " | "
+ escapeHtml(row.current_mac || "") + " | "
+ escapeHtml(row.current_ip || "") + " | "
+ escapeHtml(row.miner_type || row.model || "");
} else if (selectedRows.length > 1) {
info.innerHTML = '<span class="pill warn">Select only one miner for this test lab. Currently selected: ' + escapeHtml(selectedRows.length) + '</span>';
} else {
info.innerHTML = '<span class="pill warn">No miner selected.</span>';
}
const rows = (m30CommandTestLab && m30CommandTestLab.recent_tests) ? m30CommandTestLab.recent_tests : [];
document.getElementById("m30CommandTestBody").innerHTML = rows.map(row => {
const status = row.command_status || "";
const statusClass = status === "completed" ? "online" : (status === "failed" ? "hot" : "warn");
return `
<tr>
<td>${escapeHtml(row.created_at || "")}</td>
<td><span class="pill ${statusClass}">${escapeHtml(status)}</span></td>
<td>${escapeHtml(formatM30TestAction(row))}</td>
<td>${escapeHtml(row.room_name || "")}</td>
<td>${escapeHtml(row.current_mac || "")}</td>
<td>${escapeHtml(row.current_ip || "")}</td>
<td>${escapeHtml(row.model || "")}</td>
<td>${escapeHtml(row.requested_by_username || "")}</td>
<td>${escapeHtml(row.error_message || "")}</td>
</tr>
`;
}).join("");
}
async function runM30CommandTest() {
try {
const selectedRows = getSelectedMinerRowsForM30Lab();
if (selectedRows.length !== 1) {
setM30CommandTestStatus("Select exactly one miner first.", "warn");
return;
}
const action = document.getElementById("m30TestAction")?.value || "reboot";
if (action === "high") {
const highOk = prompt("High Mode can increase heat and power draw. Type HIGH to continue.");
if (highOk !== "HIGH") {
setM30CommandTestStatus("High Mode test cancelled.", "warn");
return;
}
}
const ok = confirm("Queue M30 command test '" + action + "' for " + (selectedRows[0].current_mac || selectedRows[0].current_ip) + "?");
if (!ok) {
setM30CommandTestStatus("Test cancelled.", "warn");
return;
}
setM30CommandTestStatus("Queueing test command...", "warn");
await apiPostJson("/api/dashboard/m30-command-test-lab/run", {
miner_id: selectedRows[0].id,
test_action: action
});
setM30CommandTestStatus("Test command queued.", "ok");
await refreshAll();
} catch (error) {
setM30CommandTestStatus("Error: " + error.message, "bad");
}
}
function renderBreakdowns() {
const models = dashboard.by_model || [];
const firmware = dashboard.by_firmware || [];
document.getElementById("modelBody").innerHTML = models.map(row => `
<tr>
<td>${escapeHtml(row.model)}</td>
<td>${escapeHtml(row.miner_count)}</td>
</tr>
`).join("");
document.getElementById("firmwareBody").innerHTML = firmware.map(row => `
<tr>
<td>${escapeHtml(row.firmware_version)}</td>
<td>${escapeHtml(row.miner_count)}</td>
</tr>
`).join("");
}
function setActiveTab(name) {
for (const tab of ["All", "Hot", "Unstable", "Stale"]) {
document.getElementById("tab" + tab).classList.remove("active");
}
const id = "tab" + name.charAt(0).toUpperCase() + name.slice(1);
document.getElementById(id).classList.add("active");
}
function showTable(name) {
currentTable = name;
setActiveTab(name);
renderCurrentTable();
}
function getCurrentRows() {
if (currentTable === "hot") {
return hotMiners;
}
if (currentTable === "unstable") {
return unstableMiners;
}
if (currentTable === "stale") {
return staleMiners;
}
return minerTable;
}
function rowMatchesFilters(row) {
const search = document.getElementById("searchBox").value.trim().toLowerCase();
const unit = document.getElementById("unitFilter").value;
const mode = document.getElementById("modeFilter")?.value || "";
if (unit && row.room_name !== unit) {
return false;
}
if (mode) {
const rowMode = row.performance_mode ? String(row.performance_mode).trim().toLowerCase() : "unknown";
if (rowMode !== mode) {
return false;
}
}
if (!search) {
return true;
}
const haystack = [
row.room_name,
row.current_ip,
row.current_mac,
row.hostname,
row.miner_sn,
row.power_sn,
row.model,
row.miner_type,
row.firmware_version,
row.status
].join(" ").toLowerCase();
return haystack.includes(search);
}
function updateBulkActionStatus(message, className) {
const element = document.getElementById("bulkActionStatus");
if (!element) {
return;
}
const suffix = message ? " - " + message : "";
element.textContent = "Selected: " + selectedMinerIds.size + suffix;
element.className = "status " + (className || "");
}
function getVisibleRowsForBulkActions() {
return getCurrentRows().filter(rowMatchesFilters);
}
function toggleMinerSelection(minerId, checked) {
if (!minerId) {
return;
}
if (checked) {
selectedMinerIds.add(minerId);
} else {
selectedMinerIds.delete(minerId);
}
updateBulkActionStatus("", "");
}
function selectVisibleMiners() {
const rows = getVisibleRowsForBulkActions();
for (const row of rows) {
if (row.id) {
selectedMinerIds.add(row.id);
}
}
renderCurrentTable();
updateBulkActionStatus("visible miners selected", "ok");
}
function clearSelectedMiners() {
selectedMinerIds.clear();
renderCurrentTable();
updateBulkActionStatus("selection cleared", "warn");
}
async function postBulkMinerControl(minerId, action, performanceMode) {
const body = {
action: action,
dry_run: false
};
if (performanceMode) {
body.performance_mode = performanceMode;
}
return apiPostJson("/api/miners/" + encodeURIComponent(minerId) + "/agent-control", body);
}
async function apiPostJson(path, bodyData) {
if (!token) {
throw new Error("Missing token. Please log in.");
}
const response = await fetch(path, {
method: "POST",
headers: {
"Authorization": "Bearer " + token,
"Content-Type": "application/json"
},
body: JSON.stringify(bodyData || {})
});
const rawText = await response.text();
let result = {};
try {
result = rawText ? JSON.parse(rawText) : {};
} catch (err) {
result = {
detail: rawText || "Non-JSON API response"
};
}
if (!response.ok) {
throw new Error(result.detail || result.message || rawText || "API error");
}
return result.data || result;
}
async function runBulkMinerAction() {
const actionValue = document.getElementById("bulkActionSelect")?.value || "";
const selected = Array.from(selectedMinerIds);
if (!actionValue) {
updateBulkActionStatus("choose a bulk action first", "warn");
return;
}
if (selected.length === 0) {
updateBulkActionStatus("no miners selected", "warn");
return;
}
let action = actionValue;
let performanceMode = "";
let label = actionValue;
if (actionValue === "performance_low") {
action = "performance_mode";
performanceMode = "low";
label = "set Low Mode";
} else if (actionValue === "performance_normal") {
action = "performance_mode";
performanceMode = "normal";
label = "set Normal Mode";
} else if (actionValue === "performance_high") {
action = "performance_mode";
performanceMode = "high";
label = "set High Mode";
}
if (actionValue === "performance_high") {
const highOk = prompt("High Mode can increase heat and power draw. Type HIGH to continue.");
if (highOk !== "HIGH") {
updateBulkActionStatus("High Mode cancelled", "warn");
return;
}
}
const confirmed = confirm("Run bulk action '" + label + "' on " + selected.length + " selected miners?");
if (!confirmed) {
updateBulkActionStatus("bulk action cancelled", "warn");
return;
}
updateBulkActionStatus("queueing " + selected.length + " commands...", "warn");
let ok = 0;
let failed = 0;
for (const minerId of selected) {
try {
await postBulkMinerControl(minerId, action, performanceMode);
ok += 1;
} catch (err) {
failed += 1;
console.error("Bulk command failed for miner", minerId, err);
}
}
updateBulkActionStatus("queued " + ok + ", failed " + failed, failed ? "warn" : "ok");
await refreshAll();
}
function renderCurrentTable() {
const rows = getCurrentRows().filter(rowMatchesFilters);
if (currentTable === "stale") {
renderStaleTable(rows);
return;
}
renderMinerTable(rows);
}
function renderMinerTable(rows) {
document.getElementById("mainTableHead").innerHTML = `
<tr>
<th>Select</th>
<th>Unit</th>
<th>MAC</th>
<th>IP</th>
<th>Model</th>
<th>Firmware</th>
<th>App Status</th>
<th>Configured Mode</th>
<th>TH/s</th>
<th>Power W</th>
<th>W/TH</th>
<th>Max C</th>
<th>Avg C</th>
<th>Hash Stable</th>
<th>Rejected %</th>
<th>Last Seen</th>
</tr>
`;
document.getElementById("mainTableBody").innerHTML = rows.map(row => {
const tempClass = Number(row.chip_temp_max_c) >= 90 ? "pill hot" : "";
const stableText = row.hash_stable === false ? '<span class="pill warn">No</span>' : "Yes";
const status = '<span class="pill online">' + escapeHtml(row.status) + '</span>';
const modeRaw = row.performance_mode ? String(row.performance_mode) : "Unknown";
const modeLower = modeRaw.toLowerCase();
const runtimePowerState = row.runtime_power_state ? String(row.runtime_power_state) : "Unknown";
const modeTitle = "Configured Mode: " + modeRaw
+ "\\nSource: " + (row.configured_performance_mode_source || "none")
+ "\\nUpdated: " + (row.configured_performance_mode_updated_at || "never")
+ "\\nRuntime Power State: " + runtimePowerState;
let modeStyle = "background:#e5e7eb;color:#111827;";
if (modeLower === "low") {
modeStyle = "background:#dcfce7;color:#166534;";
} else if (modeLower === "normal") {
modeStyle = "background:#dbeafe;color:#1e40af;";
} else if (modeLower === "high") {
modeStyle = "background:#fee2e2;color:#991b1b;";
}
const modeBadge = '<span class="pill" title="' + escapeHtml(modeTitle) + '" style="' + modeStyle + '">' + escapeHtml(modeRaw) + '</span>';
return `
<tr>
<td><input type="checkbox" class="bulkMinerCheckbox" onchange="toggleMinerSelection('${row.id}', this.checked)" ${selectedMinerIds.has(row.id) ? "checked" : ""}></td>
<td>${escapeHtml(row.room_name)}</td>
<td><a href="/miners/${encodeURIComponent(row.id)}/detail" target="_blank">${escapeHtml(row.current_mac)}</a></td>
<td>${escapeHtml(row.current_ip)}</td>
<td>${escapeHtml(row.miner_type || row.model)}</td>
<td>${escapeHtml(row.firmware_version)}</td>
<td>${status}</td>
<td>${modeBadge}</td>
<td>${escapeHtml(row.hashrate_ths)}</td>
<td>${escapeHtml(row.power_w)}</td>
<td>${escapeHtml(row.power_rate_w_per_th)}</td>
<td><span class="${tempClass}">${escapeHtml(fmtTemp(row.chip_temp_max_c))}</span></td>
<td>${escapeHtml(fmtTemp(row.chip_temp_avg_c))}</td>
<td>${stableText}</td>
<td>${escapeHtml(row.pool_rejected_percent)}</td>
<td>${escapeHtml(row.last_seen_at)}</td>
</tr>
`;
}).join("");
updateBulkActionStatus("", "");
}
function renderStaleTable(rows) {
document.getElementById("mainTableHead").innerHTML = `
<tr>
<th>Select</th>
<th>Unit</th>
<th>MAC</th>
<th>IP</th>
<th>Model</th>
<th>Firmware</th>
<th>App Status</th>
<th>Minutes Since Seen</th>
<th>Last Seen</th>
</tr>
`;
document.getElementById("mainTableBody").innerHTML = rows.map(row => `
<tr>
<td>${escapeHtml(row.room_name)}</td>
<td><a href="/miners/${encodeURIComponent(row.id)}/detail" target="_blank">${escapeHtml(row.current_mac)}</a></td>
<td>${escapeHtml(row.current_ip)}</td>
<td>${escapeHtml(row.miner_type || row.model)}</td>
<td>${escapeHtml(row.firmware_version)}</td>
<td>${escapeHtml(row.status)}</td>
<td>${escapeHtml(row.minutes_since_seen)}</td>
<td>${escapeHtml(row.last_seen_at)}</td>
</tr>
`).join("");
}
if (token) {
setLoginStatus("Saved token found in this browser.", "ok");
refreshAll();
}
if (typeof selectVisibleMiners === "function") {
window.selectVisibleMiners = selectVisibleMiners;
}
if (typeof clearSelectedMiners === "function") {
window.clearSelectedMiners = clearSelectedMiners;
}
if (typeof runBulkMinerAction === "function") {
window.runBulkMinerAction = runBulkMinerAction;
}
if (typeof toggleMinerSelection === "function") {
window.toggleMinerSelection = toggleMinerSelection;
}
</script>
<div id="dashboardLocationMapLink" style="position: fixed; top: 12px; right: 12px; z-index: 9999;">
<a href="/locations" style="display: inline-block; padding: 10px 14px; background: #0b63ce; color: white; border-radius: 8px; text-decoration: none; font-weight: bold; box-shadow: 0 2px 6px rgba(15, 23, 42, 0.25);">
Location Map
</a>
</div>
<script src="/assets/minerops-miner-detail-extra.js?v=1"></script>
</body>
</html>
"""
@app.get("/miners/{miner_id}/detail", response_class=HTMLResponse)
def miner_detail_page(miner_id: str):
return f"""
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>MinerOps - Miner Detail</title>
<style>
body {{
margin: 0;
font-family: Arial, sans-serif;
background: #f4f6f8;
color: #1f2933;
}}
header {{
background: #0f172a;
color: white;
padding: 18px 28px;
}}
header h1 {{
margin: 0;
font-size: 24px;
}}
header .sub {{
color: #cbd5e1;
margin-top: 4px;
}}
a {{
color: #2563eb;
text-decoration: none;
font-weight: bold;
}}
.container {{
padding: 24px;
max-width: 1400px;
margin: auto;
}}
.card {{
background: white;
border: 1px solid #d9e2ec;
border-radius: 8px;
padding: 18px;
margin-bottom: 18px;
box-shadow: 0 2px 5px rgba(0,0,0,0.04);
}}
.grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}}
.field {{
background: #f8fafc;
border: 1px solid #e2e8f0;
padding: 12px;
border-radius: 7px;
}}
.field .label {{
font-size: 12px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.03em;
}}
.field .value {{
font-size: 16px;
font-weight: bold;
margin-top: 5px;
overflow-wrap: anywhere;
}}
.metric .value {{
font-size: 24px;
}}
button {{
padding: 10px 14px;
border: none;
border-radius: 5px;
background: #2563eb;
color: white;
font-weight: bold;
cursor: pointer;
}}
input {{
padding: 9px;
border: 1px solid #bcccdc;
border-radius: 5px;
}}
.login-row {{
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: end;
}}
label {{
font-size: 13px;
font-weight: bold;
display: block;
margin-bottom: 4px;
}}
.status {{
margin-top: 10px;
padding: 9px;
border-radius: 5px;
}}
.ok {{
background: #dcfce7;
border: 1px solid #86efac;
}}
.warn {{
background: #fef9c3;
border: 1px solid #fde047;
}}
.bad {{
background: #fee2e2;
border: 1px solid #fca5a5;
}}
.two-col {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(460px, 1fr));
gap: 18px;
}}
.table-wrap {{
overflow-x: auto;
max-height: 520px;
overflow-y: auto;
border: 1px solid #e2e8f0;
border-radius: 7px;
}}
table {{
border-collapse: collapse;
width: 100%;
font-size: 13px;
background: white;
}}
th, td {{
padding: 8px 10px;
border-bottom: 1px solid #e2e8f0;
text-align: left;
white-space: nowrap;
}}
th {{
background: #f1f5f9;
position: sticky;
top: 0;
z-index: 1;
}}
tr:hover {{
background: #f8fafc;
}}
.pill {{
display: inline-block;
padding: 3px 7px;
border-radius: 999px;
font-size: 12px;
font-weight: bold;
}}
.online {{
background: #dcfce7;
color: #166534;
}}
.hot {{
background: #fee2e2;
color: #991b1b;
}}
.warn-pill {{
background: #fef9c3;
color: #854d0e;
}}
pre {{
background: #111827;
color: #f9fafb;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
}}
.top-actions {{
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}}
</style>
<link rel="stylesheet" href="/assets/minerops-unified.css">
</head>
<body>
<header class="minerops-shell-header">
<div>
<div class="minerops-kicker">MinerOps Console</div>
<h1>Miner Detail</h1>
<div class="minerops-subtitle">Miner ID: {miner_id}</div>
</div>
<nav class="minerops-nav">
<a class="minerops-nav-link" href="/dashboard">Dashboard</a>
<a class="minerops-nav-link" href="/locations">Location Map</a>
<a class="minerops-nav-link active" href="javascript:window.location.reload()">Refresh</a>
</nav>
</header>
<div id="minerDetailBackBar" class="card" style="display: flex; flex-wrap: wrap; gap: 8px; align-items: center;">
<a class="minerops-nav-link" style="background: #0b63ce; color: white !important;" href="javascript:history.back()">Back to Previous Page</a>
<a class="minerops-nav-link" style="background: #64748b; color: white !important;" href="/dashboard">Back to Dashboard</a>
<a class="minerops-nav-link" style="background: #0f766e; color: white !important;" href="/locations">Back to Location Map</a>
<a class="minerops-nav-link" style="background: #334155; color: white !important;" href="/miners/{miner_id}/detail">Refresh Miner</a>
</div>
<div id="minerControlCard" class="card" data-miner-id="{miner_id}">
<h2>Miner Controls</h2>
<div class="small">
</div>
<div style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px;">
<button id="realRebootButton" class="danger" type="button">Reboot</button>
<button id="powerOffButton" class="danger" type="button">Suspend</button>
<button id="powerOnButton" type="button">Resume</button>
<select id="performanceModeSelect" style="margin-left:8px;">
<option value="normal">Normal Mode</option>
<option value="low">Low Mode</option>
<option value="high">High Mode</option>
</select>
<span id="currentPerformanceModeBadge" style="margin-left:10px;padding:6px 10px;border-radius:999px;background:#e5e7eb;color:#111827;font-weight:700;">Current Mode: Loading...</span>
<button id="performanceModeButton" type="button" onclick="setPerformanceMode()">Set Performance Mode</button>
<button type="button" onclick="loadMinerCommandHistory()">Refresh Command History</button>
</div>
<div id="minerControlStatus" class="status warn">
</div>
<div id="minerCommandHistoryBody" style="margin-top: 12px;"></div>
</div>
<header>
<h1>Miner Detail</h1>
<div class="sub">Miner ID: {miner_id}</div>
</header>
<div class="container">
<div class="card top-actions">
<div>
<a href="/dashboard">&larr; Back to Dashboard</a>
</div>
<div>
<button onclick="refreshAll()">Refresh Miner</button>
</div>
</div>
<div class="card" id="loginCard">
<h2>Login</h2>
<div class="login-row">
<div>
<label>Username</label>
<input id="username" type="text" value="admin">
</div>
<div>
<label>Password</label>
<input id="password" type="password">
</div>
<div>
<button onclick="login()">Login</button>
</div>
</div>
<div id="loginStatus" class="status warn">Not logged in.</div>
</div>
<div class="card">
<h2>Identity</h2>
<div class="grid">
<div class="field"><div class="label">Unit</div><div class="value" id="roomName">-</div></div>
<div class="field"><div class="label">MAC</div><div class="value" id="currentMac">-</div></div>
<div class="field"><div class="label">IP</div><div class="value" id="currentIp">-</div></div>
<div class="field"><div class="label">Hostname</div><div class="value" id="hostname">-</div></div>
<div class="field"><div class="label">Model</div><div class="value" id="model">-</div></div>
<div class="field"><div class="label">Miner Type</div><div class="value" id="minerType">-</div></div>
<div class="field"><div class="label">Firmware</div><div class="value" id="firmware">-</div></div>
<div class="field"><div class="label">API Version</div><div class="value" id="apiVersion">-</div></div>
<div class="field"><div class="label">Miner SN</div><div class="value" id="minerSn">-</div></div>
<div class="field"><div class="label">Power SN</div><div class="value" id="powerSn">-</div></div>
<div class="field"><div class="label">Status</div><div class="value" id="minerStatus">-</div></div>
<div class="field"><div class="label">Last Seen</div><div class="value" id="lastSeen">-</div></div>
</div>
</div>
<div class="card">
<h2>Latest Telemetry</h2>
<div class="grid">
<div class="field metric"><div class="label">TH/s</div><div class="value" id="hashrate">-</div></div>
<div class="field metric"><div class="label">Power W</div><div class="value" id="powerW">-</div></div>
<div class="field metric"><div class="label">W/TH</div><div class="value" id="powerRate">-</div></div>
<div class="field metric"><div class="label">Max Chip C</div><div class="value" id="chipMax">-</div></div>
<div class="field metric"><div class="label">Avg Chip C</div><div class="value" id="chipAvg">-</div></div>
<div class="field metric"><div class="label">Fan In</div><div class="value" id="fanIn">-</div></div>
<div class="field metric"><div class="label">Fan Out</div><div class="value" id="fanOut">-</div></div>
<div class="field metric"><div class="label">Hash Stable</div><div class="value" id="hashStable">-</div></div>
</div>
</div>
<div class="two-col">
<div class="card">
<h2>Hashboards</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Slot</th>
<th>PCB SN</th>
<th>Chip Data</th>
<th>Temp C</th>
<th>Freq</th>
<th>Factory GHS</th>
<th>Chips</th>
</tr>
</thead>
<tbody id="hashboardBody"></tbody>
</table>
</div>
</div>
<div class="card">
<h2>Network Identity History</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>MAC</th>
<th>IP</th>
<th>Hostname</th>
<th>Current</th>
<th>First Seen</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody id="identityBody"></tbody>
</table>
</div>
</div>
</div>
<div class="card">
<h2>Add Repair / Maintenance Note</h2>
<div class="grid">
<div class="field">
<div class="label">Event Type</div>
<select id="maintenanceEventType">
<option value="general_note">General note</option>
<option value="control_board_changed">Control board changed</option>
<option value="mac_changed">MAC changed</option>
<option value="hashboard_replaced">Hashboard replaced</option>
<option value="psu_replaced">PSU replaced</option>
<option value="fan_replaced">Fan replaced</option>
<option value="cleaned">Cleaned</option>
<option value="moved">Moved</option>
<option value="firmware_updated">Firmware updated</option>
<option value="troubleshooting">Troubleshooting</option>
</select>
</div>
<div class="field">
<div class="label">Status</div>
<select id="maintenanceEventStatus">
<option value="completed">Completed</option>
<option value="in_progress">In progress</option>
<option value="needs_followup">Needs follow-up</option>
<option value="failed">Failed</option>
</select>
</div>
<div class="field">
<div class="label">Title</div>
<input id="maintenanceEventTitle" type="text" placeholder="Example: Replaced hashboard in slot 1">
</div>
<div class="field">
<div class="label">Component Type</div>
<input id="maintenanceComponentType" type="text" placeholder="hashboard, PSU, fan, control board">
</div>
<div class="field">
<div class="label">Component Slot</div>
<input id="maintenanceComponentSlot" type="text" placeholder="Example: SM0, SM1, SM2, PSU">
</div>
<div class="field">
<div class="label">Component Serial</div>
<input id="maintenanceComponentSerial" type="text" placeholder="Serial number if available">
</div>
<div class="field">
<div class="label">Old MAC</div>
<input id="maintenanceOldMac" type="text" placeholder="For control board / MAC changes">
</div>
<div class="field">
<div class="label">New MAC</div>
<input id="maintenanceNewMac" type="text" placeholder="For control board / MAC changes">
</div>
<div class="field">
<div class="label">Downtime Minutes</div>
<input id="maintenanceDowntime" type="number" min="0" placeholder="Optional">
</div>
<div class="field">
<div class="label">Cost</div>
<input id="maintenanceCost" type="number" min="0" step="0.01" placeholder="Optional">
</div>
</div>
<div class="field" style="margin-top: 12px;">
<div class="label">Description / Notes</div>
<textarea id="maintenanceDescription" rows="4" style="width: 100%; padding: 9px; border: 1px solid #bcccdc; border-radius: 5px;" placeholder="Write repair details here..."></textarea>
</div>
<button onclick="addMaintenanceEvent()">Add Repair Note</button>
<div id="maintenanceFormStatus" class="status warn">Ready to add a repair note.</div>
</div>
<div class="card">
<h2>Maintenance / Repair History</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Date</th>
<th>Type</th>
<th>App Status</th>
<th>Title</th>
<th>Description</th>
<th>Old MAC</th>
<th>New MAC</th>
<th>Component</th>
<th>Slot</th>
<th>Serial</th>
<th>Downtime</th>
<th>Created By</th>
</tr>
</thead>
<tbody id="maintenanceBody"></tbody>
</table>
</div>
</div>
<div class="card">
<h2>Recent Telemetry Snapshots</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Observed</th>
<th>IP</th>
<th>TH/s</th>
<th>Power W</th>
<th>W/TH</th>
<th>Env C</th>
<th>Max C</th>
<th>Avg C</th>
<th>Fan In</th>
<th>Fan Out</th>
<th>Stable</th>
<th>Rejected %</th>
</tr>
</thead>
<tbody id="snapshotBody"></tbody>
</table>
</div>
</div>
<div class="card">
<h2>Status</h2>
<pre id="rawStatus">Ready.</pre>
</div>
</div>
<script>
const minerId = "{miner_id}";
let token = localStorage.getItem("minerops_token") || "";
function fmt(value) {{
if (value === null || value === undefined || value === "") {{
return "-";
}}
return value;
}}
function fmtNum(value, digits = 2) {{
if (value === null || value === undefined || value === "") {{
return "-";
}}
return Number(value).toFixed(digits);
}}
function fmtHashrate(mhs) {{
if (mhs === null || mhs === undefined || mhs === "") {{
return "-";
}}
return (Number(mhs) / 1000000).toFixed(2);
}}
function escapeHtml(value) {{
return String(fmt(value))
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}}
function setLoginStatus(message, className) {{
const element = document.getElementById("loginStatus");
element.textContent = message;
element.className = "status " + className;
}}
function setRaw(message) {{
document.getElementById("rawStatus").textContent = message;
}}
async function apiPost(path, bodyData) {{
if (!token) {{
throw new Error("Missing token. Please log in.");
}}
const response = await fetch(path, {{
method: "POST",
headers: {{
"Authorization": "Bearer " + token,
"Content-Type": "application/json"
}},
body: JSON.stringify(bodyData)
}});
const result = await response.json();
if (!response.ok) {{
throw new Error(result.detail || "API error");
}}
return result.data;
}}
function readOptionalValue(id) {{
const element = document.getElementById(id);
if (!element) {{
return null;
}}
const value = element.value.trim();
if (!value) {{
return null;
}}
return value;
}}
function readOptionalNumber(id) {{
const value = readOptionalValue(id);
if (value === null) {{
return null;
}}
const numberValue = Number(value);
if (Number.isNaN(numberValue)) {{
return null;
}}
return numberValue;
}}
function setMaintenanceFormStatus(message, className) {{
const element = document.getElementById("maintenanceFormStatus");
if (!element) {{
return;
}}
element.textContent = message;
element.className = "status " + className;
}}
async function addMaintenanceEvent() {{
try {{
const eventTitle = readOptionalValue("maintenanceEventTitle");
if (!eventTitle) {{
setMaintenanceFormStatus("Please enter a title.", "bad");
return;
}}
const bodyData = {{
event_type: document.getElementById("maintenanceEventType").value,
event_status: document.getElementById("maintenanceEventStatus").value,
event_title: eventTitle,
description: readOptionalValue("maintenanceDescription"),
old_mac: readOptionalValue("maintenanceOldMac"),
new_mac: readOptionalValue("maintenanceNewMac"),
component_type: readOptionalValue("maintenanceComponentType"),
component_slot: readOptionalValue("maintenanceComponentSlot"),
component_serial: readOptionalValue("maintenanceComponentSerial"),
downtime_minutes: readOptionalNumber("maintenanceDowntime"),
cost_amount: readOptionalNumber("maintenanceCost")
}};
setMaintenanceFormStatus("Saving repair note...", "warn");
await apiPost("/api/miners/" + minerId + "/maintenance-events", bodyData);
document.getElementById("maintenanceEventTitle").value = "";
document.getElementById("maintenanceDescription").value = "";
document.getElementById("maintenanceOldMac").value = "";
document.getElementById("maintenanceNewMac").value = "";
document.getElementById("maintenanceComponentType").value = "";
document.getElementById("maintenanceComponentSlot").value = "";
document.getElementById("maintenanceComponentSerial").value = "";
document.getElementById("maintenanceDowntime").value = "";
document.getElementById("maintenanceCost").value = "";
setMaintenanceFormStatus("Repair note saved.", "ok");
await refreshAll();
}} catch (error) {{
setMaintenanceFormStatus("Error saving repair note: " + error.message, "bad");
}}
}}
function setMacChangeStatus(message, className) {{
const element = document.getElementById("macChangeStatus");
if (!element) {{
return;
}}
element.textContent = message;
element.className = "status " + className;
}}
async function changeMinerMac() {{
try {{
const oldMac = readOptionalValue("macChangeOldMac");
const newMac = readOptionalValue("macChangeNewMac");
const newIp = readOptionalValue("macChangeNewIp");
if (!newMac) {{
setMacChangeStatus("Please enter the new MAC address.", "bad");
return;
}}
if (oldMac && oldMac.trim().toLowerCase() === newMac.trim().toLowerCase()) {{
setMacChangeStatus("New MAC is the same as the old MAC.", "bad");
return;
}}
const confirmed = confirm(
"This will update the current MAC address for this miner and create a maintenance history entry. Continue?"
);
if (!confirmed) {{
return;
}}
const bodyData = {{
old_mac: oldMac,
new_mac: newMac,
new_ip: newIp,
event_title: "Control board replaced / MAC changed",
description: readOptionalValue("macChangeDescription"),
component_serial: readOptionalValue("macChangeSerial"),
downtime_minutes: readOptionalNumber("macChangeDowntime")
}};
setMacChangeStatus("Saving MAC change...", "warn");
await apiPost("/api/miners/" + minerId + "/change-mac", bodyData);
document.getElementById("macChangeNewMac").value = "";
document.getElementById("macChangeSerial").value = "";
document.getElementById("macChangeDowntime").value = "";
document.getElementById("macChangeDescription").value = "";
setMacChangeStatus("MAC change saved. Miner identity and history updated.", "ok");
await refreshAll();
}} catch (error) {{
setMacChangeStatus("Error saving MAC change: " + error.message, "bad");
}}
}}
async function login() {{
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
const response = await fetch("/api/auth/login", {{
method: "POST",
headers: {{
"Content-Type": "application/json"
}},
body: JSON.stringify({{
username: username,
password: password
}})
}});
const result = await response.json();
if (!response.ok) {{
setLoginStatus("Login failed: " + (result.detail || "Unknown error"), "bad");
return;
}}
token = result.data.access_token;
localStorage.setItem("minerops_token", token);
setLoginStatus("Logged in. Token expires in " + result.data.expires_in + " seconds.", "ok");
await refreshAll();
}}
async function apiGet(path) {{
if (!token) {{
throw new Error("Missing token. Please log in.");
}}
const response = await fetch(path, {{
headers: {{
"Authorization": "Bearer " + token
}}
}});
const result = await response.json();
if (!response.ok) {{
throw new Error(result.detail || "API error");
}}
return result.data;
}}
async function refreshAll() {{
try {{
setRaw("Refreshing miner detail...");
const miner = await apiGet("/api/miners/" + minerId);
const hashboards = await apiGet("/api/miners/" + minerId + "/hashboards");
const identities = await apiGet("/api/miners/" + minerId + "/network-identities");
const snapshots = await apiGet("/api/miners/" + minerId + "/status-snapshots");
const maintenanceEvents = await apiGet("/api/miners/" + minerId + "/maintenance-events");
renderMiner(miner);
renderHashboards(hashboards);
renderIdentities(identities);
renderMaintenanceEvents(maintenanceEvents);
renderSnapshots(snapshots);
setRaw("Miner detail refreshed successfully.");
}} catch (error) {{
setRaw("Error: " + error.message);
if (String(error.message).toLowerCase().includes("token")) {{
setLoginStatus("Please log in again.", "bad");
}}
}}
}}
function renderMiner(miner) {{
document.getElementById("roomName").textContent = fmt(miner.room_name);
document.getElementById("currentIp").textContent = fmt(miner.current_ip);
document.getElementById("currentMac").textContent = fmt(miner.current_mac);
document.getElementById("hostname").textContent = fmt(miner.hostname);
document.getElementById("model").textContent = fmt(miner.model);
document.getElementById("minerType").textContent = fmt(miner.miner_type);
document.getElementById("firmware").textContent = fmt(miner.firmware_version);
document.getElementById("apiVersion").textContent = fmt(miner.api_version);
document.getElementById("minerSn").textContent = fmt(miner.miner_sn);
document.getElementById("powerSn").textContent = fmt(miner.power_sn);
document.getElementById("minerStatus").innerHTML = '<span class="pill online">' + escapeHtml(miner.status) + '</span>';
document.getElementById("lastSeen").textContent = fmt(miner.last_seen_at);
const oldMacField = document.getElementById("macChangeOldMac");
if (oldMacField && !oldMacField.value) {{
oldMacField.value = miner.current_mac || "";
}}
const newIpField = document.getElementById("macChangeNewIp");
if (newIpField && !newIpField.value) {{
newIpField.value = miner.current_ip || "";
}}
}}
function renderHashboards(rows) {{
document.getElementById("hashboardBody").innerHTML = rows.map(row => {{
const tempClass = Number(row.temperature_c) >= 90 ? "pill hot" : "";
return `
<tr>
<td>${{escapeHtml(row.slot)}}</td>
<td>${{escapeHtml(row.pcb_sn)}}</td>
<td>${{escapeHtml(row.chip_data)}}</td>
<td><span class="${{tempClass}}">${{escapeHtml(fmtNum(row.temperature_c, 1))}}</span></td>
<td>${{escapeHtml(row.chip_frequency)}}</td>
<td>${{escapeHtml(row.factory_ghs)}}</td>
<td>${{escapeHtml(row.effective_chips)}}</td>
</tr>
`;
}}).join("");
}}
function renderIdentities(rows) {{
document.getElementById("identityBody").innerHTML = rows.map(row => `
<tr>
<td>${{escapeHtml(row.mac_address)}}</td>
<td>${{escapeHtml(row.ip_address)}}</td>
<td>${{escapeHtml(row.hostname)}}</td>
<td>${{row.is_current ? "Yes" : "No"}}</td>
<td>${{escapeHtml(row.first_seen_at)}}</td>
<td>${{escapeHtml(row.last_seen_at)}}</td>
</tr>
`).join("");
}}
function renderMaintenanceEvents(rows) {{
const body = document.getElementById("maintenanceBody");
if (!body) {{
return;
}}
if (!rows || rows.length === 0) {{
body.innerHTML = `
<tr>
<td colspan="12">No maintenance history recorded for this miner.</td>
</tr>
`;
return;
}}
body.innerHTML = rows.map(row => `
<tr>
<td>${{escapeHtml(row.event_at)}}</td>
<td>${{escapeHtml(row.event_type)}}</td>
<td>${{escapeHtml(row.event_status)}}</td>
<td>${{escapeHtml(row.event_title)}}</td>
<td>${{escapeHtml(row.description)}}</td>
<td>${{escapeHtml(row.old_mac)}}</td>
<td>${{escapeHtml(row.new_mac)}}</td>
<td>${{escapeHtml(row.component_type)}}</td>
<td>${{escapeHtml(row.component_slot)}}</td>
<td>${{escapeHtml(row.component_serial)}}</td>
<td>${{escapeHtml(row.downtime_minutes)}}</td>
<td>${{escapeHtml(row.created_by_username)}}</td>
</tr>
`).join("");
}}
function renderSnapshots(rows) {{
document.getElementById("snapshotBody").innerHTML = rows.map(row => {{
const tempClass = Number(row.chip_temp_max_c) >= 90 ? "pill hot" : "";
const stable = row.hash_stable === false ? '<span class="pill warn-pill">No</span>' : "Yes";
return `
<tr>
<td>${{escapeHtml(row.observed_at)}}</td>
<td>${{escapeHtml(row.observed_ip)}}</td>
<td>${{escapeHtml(fmtHashrate(row.mhs_av))}}</td>
<td>${{escapeHtml(fmtNum(row.power_w, 1))}}</td>
<td>${{escapeHtml(fmtNum(row.power_rate_w_per_th, 2))}}</td>
<td>${{escapeHtml(fmtNum(row.env_temp_c, 1))}}</td>
<td><span class="${{tempClass}}">${{escapeHtml(fmtNum(row.chip_temp_max_c, 1))}}</span></td>
<td>${{escapeHtml(fmtNum(row.chip_temp_avg_c, 1))}}</td>
<td>${{escapeHtml(row.fan_speed_in_rpm)}}</td>
<td>${{escapeHtml(row.fan_speed_out_rpm)}}</td>
<td>${{stable}}</td>
<td>${{escapeHtml(fmtNum(row.pool_rejected_percent, 4))}}</td>
</tr>
`;
}}).join("");
}}
if (token) {{
setLoginStatus("Saved token found in this browser.", "ok");
refreshAll();
}}
</script>
<div id="dashboardLocationMapLink" style="position: fixed; top: 12px; right: 12px; z-index: 9999;">
<a href="/locations" style="display: inline-block; padding: 10px 14px; background: #0b63ce; color: white; border-radius: 8px; text-decoration: none; font-weight: bold; box-shadow: 0 2px 6px rgba(15, 23, 42, 0.25);">
Location Map
</a>
</div>
<script src="/assets/minerops-shell-auth.js"></script>
<script src="/assets/minerops-global-search.js"></script>
<script src="/assets/minerops-header-scan.js"></script>
<script src="/assets/minerops-hub-mode.js"></script>
<script src="/assets/minerops-miner-control.js"></script>
<script src="/assets/minerops-api3-credentials.js"></script>
<script src="/assets/minerops-miner-detail-extra.js?v=1"></script>
</body>
</html>
"""
import asyncio
import inspect
import os
import threading
import time
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
def minerops_env_true(name: str) -> bool:
import os
value = str(os.getenv(name, "") or "").strip().lower()
return value in {"1", "true", "yes", "on", "enabled"}
def minerops_hub_mode_enabled() -> bool:
return minerops_env_true("MINEROPS_HUB_MODE") or minerops_env_true("MINEROPS_DISABLE_LOCAL_SCANNER")
SCHEDULED_SCANNER_STATE = {
"enabled": os.getenv("SCHEDULED_SCAN_ENABLED", "true").lower() == "true",
"interval_seconds": int(os.getenv("SCHEDULED_SCAN_INTERVAL_SECONDS", "120")),
"max_workers": int(os.getenv("SCHEDULED_SCAN_MAX_WORKERS", "40")),
"running": False,
"thread_started": False,
"last_started_at": None,
"last_finished_at": None,
"next_run_at": None,
"last_duration_seconds": None,
"last_reason": None,
"last_total_miners": 0,
"last_success_count": 0,
"last_failure_count": 0,
"last_errors": [],
"total_runs": 0,
}
SCHEDULED_SCANNER_LOCK = threading.Lock()
SCHEDULED_SCANNER_STOP = threading.Event()
def scheduled_now_iso():
return datetime.now(timezone.utc).isoformat()
def get_known_miners_for_scheduled_scan():
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
miners.id::text AS miner_id,
host(miners.current_ip) AS ip_address,
miners.room_id::text AS room_id,
COALESCE(rooms.name, 'Unassigned') AS room_name,
miners.current_mac
FROM miners
LEFT JOIN rooms ON rooms.id = miners.room_id
WHERE miners.current_ip IS NOT NULL
AND miners.room_id IN (
SELECT DISTINCT room_id
FROM scan_ranges
WHERE enabled = true
AND room_id IS NOT NULL
)
ORDER BY rooms.sort_order, miners.current_ip;
"""
)
return [dict(row) for row in cur.fetchall()]
finally:
if conn is not None:
conn.close()
def call_discover_one_miner_flexible(ip_address: str, room_id: str | None = None):
func = globals().get("discover_one_miner")
if func is None:
raise RuntimeError("discover_one_miner function was not found")
signature = inspect.signature(func)
params = signature.parameters
candidate_kwargs = {
"ip_address": ip_address,
"ip": ip_address,
"host": ip_address,
"room_id": room_id,
"source": "scheduled_known_miner_scan",
}
kwargs = {}
for name, param in params.items():
if name in candidate_kwargs:
kwargs[name] = candidate_kwargs[name]
missing_required = []
for name, param in params.items():
if param.default is inspect._empty and name not in kwargs:
if param.kind in (
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
):
missing_required.append(name)
if missing_required:
raise RuntimeError(
"discover_one_miner requires unsupported argument(s): "
+ ", ".join(missing_required)
)
result = func(**kwargs)
if inspect.iscoroutine(result):
result = asyncio.run(result)
return result
def refresh_one_known_miner_for_schedule(miner_row: dict):
ip_address = miner_row.get("ip_address")
room_id = miner_row.get("room_id")
if not ip_address:
raise RuntimeError("Miner has no IP address")
call_discover_one_miner_flexible(ip_address=ip_address, room_id=room_id)
return {
"miner_id": miner_row.get("miner_id"),
"ip_address": ip_address,
"mac": miner_row.get("current_mac"),
"room_name": miner_row.get("room_name"),
}
def run_scheduled_known_miner_scan(reason: str = "scheduled"):
if not SCHEDULED_SCANNER_LOCK.acquire(blocking=False):
SCHEDULED_SCANNER_STATE["last_reason"] = "skipped_overlap"
return
started = time.time()
SCHEDULED_SCANNER_STATE["running"] = True
SCHEDULED_SCANNER_STATE["last_started_at"] = scheduled_now_iso()
SCHEDULED_SCANNER_STATE["last_reason"] = reason
SCHEDULED_SCANNER_STATE["last_errors"] = []
success_count = 0
failure_count = 0
errors = []
try:
miners = get_known_miners_for_scheduled_scan()
SCHEDULED_SCANNER_STATE["last_total_miners"] = len(miners)
max_workers = max(1, int(SCHEDULED_SCANNER_STATE["max_workers"]))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_map = {
executor.submit(refresh_one_known_miner_for_schedule, miner): miner
for miner in miners
}
for future in as_completed(future_map):
miner = future_map[future]
try:
future.result()
success_count += 1
except Exception as exc:
failure_count += 1
if len(errors) < 25:
errors.append(
{
"ip_address": miner.get("ip_address"),
"mac": miner.get("current_mac"),
"room_name": miner.get("room_name"),
"error": str(exc),
}
)
SCHEDULED_SCANNER_STATE["last_success_count"] = success_count
SCHEDULED_SCANNER_STATE["last_failure_count"] = failure_count
SCHEDULED_SCANNER_STATE["last_errors"] = errors
SCHEDULED_SCANNER_STATE["total_runs"] += 1
except Exception as exc:
SCHEDULED_SCANNER_STATE["last_errors"] = [
{
"error": str(exc),
"traceback": traceback.format_exc(limit=3),
}
]
SCHEDULED_SCANNER_STATE["last_failure_count"] = failure_count
finally:
finished = time.time()
SCHEDULED_SCANNER_STATE["running"] = False
SCHEDULED_SCANNER_STATE["last_finished_at"] = scheduled_now_iso()
SCHEDULED_SCANNER_STATE["last_duration_seconds"] = round(finished - started, 2)
SCHEDULED_SCANNER_STATE["next_run_at"] = datetime.fromtimestamp(
finished + int(SCHEDULED_SCANNER_STATE["interval_seconds"]),
tz=timezone.utc,
).isoformat()
SCHEDULED_SCANNER_LOCK.release()
def scheduled_scanner_loop():
interval = max(60, int(SCHEDULED_SCANNER_STATE["interval_seconds"]))
SCHEDULED_SCANNER_STATE["next_run_at"] = datetime.fromtimestamp(
time.time() + 10,
tz=timezone.utc,
).isoformat()
SCHEDULED_SCANNER_STOP.wait(10)
while not SCHEDULED_SCANNER_STOP.is_set():
if SCHEDULED_SCANNER_STATE["enabled"]:
run_scheduled_known_miner_scan(reason="scheduled")
SCHEDULED_SCANNER_STOP.wait(interval)
@app.on_event("startup")
def start_scheduled_scanner():
if SCHEDULED_SCANNER_STATE["thread_started"]:
return
if minerops_hub_mode_enabled():
SCHEDULED_SCANNER_STATE["enabled"] = False
SCHEDULED_SCANNER_STATE["running"] = False
SCHEDULED_SCANNER_STATE["thread_started"] = False
SCHEDULED_SCANNER_STATE["last_reason"] = "hub_mode"
SCHEDULED_SCANNER_STATE["last_errors"] = [
{
"message": "Hub Mode: local scanner disabled. Waiting for Windows site agent updates."
}
]
SCHEDULED_SCANNER_STATE["next_run_at"] = None
return
SCHEDULED_SCANNER_STATE["thread_started"] = True
thread = threading.Thread(
target=scheduled_scanner_loop,
name="minerops-scheduled-scanner",
daemon=True,
)
thread.start()
@app.get("/api/scanner/status")
def scanner_status(current_user: dict = Depends(get_current_user)):
status = dict(SCHEDULED_SCANNER_STATE)
if minerops_hub_mode_enabled():
status["enabled"] = False
status["running"] = False
status["hub_mode"] = True
status["scanner_disabled"] = True
status["last_reason"] = "hub_mode"
status["last_errors"] = [
{
"message": "Hub Mode: local scanner disabled. Waiting for Windows site agent updates."
}
]
status["next_run_at"] = None
else:
status["hub_mode"] = False
status["scanner_disabled"] = False
return api_response(data=status)
@app.post("/api/scanner/run-known-now")
def scanner_run_known_now(current_user: dict = Depends(get_current_user)):
if minerops_hub_mode_enabled():
status = dict(SCHEDULED_SCANNER_STATE)
status["enabled"] = False
status["running"] = False
status["hub_mode"] = True
status["scanner_disabled"] = True
status["last_reason"] = "hub_mode_manual_scan_blocked"
status["last_errors"] = [
{
"message": "Hub Mode: manual local scan disabled. Use the Windows site agent to poll miners."
}
]
status["next_run_at"] = None
return api_response(
data=status,
meta={"message": "Hub Mode: local scanner disabled. Use the Windows site agent."},
)
if SCHEDULED_SCANNER_STATE["running"]:
return api_response(
data=SCHEDULED_SCANNER_STATE,
meta={"message": "Scanner is already running."},
)
thread = threading.Thread(
target=run_scheduled_known_miner_scan,
kwargs={"reason": "manual"},
name="minerops-manual-known-scan",
daemon=True,
)
thread.start()
return api_response(
data=SCHEDULED_SCANNER_STATE,
meta={"message": "Known-miner scan started."},
)
# Patch: allow the scheduled scanner to call discover_one_miner when it expects FastAPI request=None.
def call_discover_one_miner_flexible(ip_address: str, room_id: str | None = None):
func = globals().get("discover_one_miner")
if func is None:
raise RuntimeError("discover_one_miner function was not found")
signature = inspect.signature(func)
params = signature.parameters
candidate_kwargs = {
"request": None,
"ip_address": ip_address,
"ip": ip_address,
"host": ip_address,
"room_id": room_id,
"source": "scheduled_known_miner_scan",
}
kwargs = {}
for name, param in params.items():
if name in candidate_kwargs:
kwargs[name] = candidate_kwargs[name]
missing_required = []
for name, param in params.items():
if param.default is inspect._empty and name not in kwargs:
if param.kind in (
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
):
missing_required.append(name)
if missing_required:
raise RuntimeError(
"discover_one_miner requires unsupported argument(s): "
+ ", ".join(missing_required)
)
result = func(**kwargs)
if inspect.iscoroutine(result):
result = asyncio.run(result)
return result
# Patch 2: scheduled scanner passes a body-like request object to discover_one_miner.
from types import SimpleNamespace
def call_discover_one_miner_flexible(ip_address: str, room_id: str | None = None):
func = globals().get("discover_one_miner")
if func is None:
raise RuntimeError("discover_one_miner function was not found")
signature = inspect.signature(func)
params = signature.parameters
request_body = SimpleNamespace(
ip_address=ip_address,
room_id=room_id,
source="scheduled_known_miner_scan",
update_existing=True,
api_port=4028,
)
candidate_kwargs = {
"request": request_body,
"body": request_body,
"payload": request_body,
"ip_address": ip_address,
"ip": ip_address,
"host": ip_address,
"room_id": room_id,
"source": "scheduled_known_miner_scan",
}
kwargs = {}
for name, param in params.items():
if name in candidate_kwargs:
kwargs[name] = candidate_kwargs[name]
missing_required = []
for name, param in params.items():
if param.default is inspect._empty and name not in kwargs:
if param.kind in (
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
):
missing_required.append(name)
if missing_required:
raise RuntimeError(
"discover_one_miner requires unsupported argument(s): "
+ ", ".join(missing_required)
)
result = func(**kwargs)
if inspect.iscoroutine(result):
result = asyncio.run(result)
return result
# Patch 3: stronger fake request object for scheduled known-miner scans.
class ScheduledDiscoverRequest:
def __init__(self, ip_address: str, room_id: str | None = None):
self.ip_address = ip_address
self.port = 4028
self.api_port = 4028
self.room_id = room_id
self.source = "scheduled_known_miner_scan"
self.update_existing = True
self.timeout_seconds = 5
self.connect_timeout_seconds = 3
self.read_timeout_seconds = 5
self.scan_range_id = None
self.max_ips = None
self.specific_ips = None
def model_dump(self):
return self.__dict__
def dict(self):
return self.__dict__
def __getattr__(self, name):
return None
def call_discover_one_miner_flexible(ip_address: str, room_id: str | None = None):
func = globals().get("discover_one_miner")
if func is None:
raise RuntimeError("discover_one_miner function was not found")
signature = inspect.signature(func)
params = signature.parameters
request_body = ScheduledDiscoverRequest(
ip_address=ip_address,
room_id=room_id,
)
candidate_kwargs = {
"request": request_body,
"body": request_body,
"payload": request_body,
"ip_address": ip_address,
"ip": ip_address,
"host": ip_address,
"port": 4028,
"api_port": 4028,
"room_id": room_id,
"source": "scheduled_known_miner_scan",
}
kwargs = {}
for name, param in params.items():
if name in candidate_kwargs:
kwargs[name] = candidate_kwargs[name]
missing_required = []
for name, param in params.items():
if param.default is inspect._empty and name not in kwargs:
if param.kind in (
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
):
missing_required.append(name)
if missing_required:
raise RuntimeError(
"discover_one_miner requires unsupported argument(s): "
+ ", ".join(missing_required)
)
result = func(**kwargs)
if inspect.iscoroutine(result):
result = asyncio.run(result)
return result
# Patch 4: scheduled scanner request object with timeout_ms fields.
class ScheduledDiscoverRequestV2:
def __init__(self, ip_address: str, room_id: str | None = None):
self.ip_address = ip_address
self.host = ip_address
self.ip = ip_address
self.port = 4028
self.api_port = 4028
self.room_id = room_id
self.source = "scheduled_known_miner_scan"
self.update_existing = True
# The existing Whatsminer read code expects millisecond timeout fields.
self.timeout_ms = 5000
self.connect_timeout_ms = 3000
self.read_timeout_ms = 5000
self.command_timeout_ms = 5000
# Also include second-based versions in case other code uses them.
self.timeout_seconds = 5
self.connect_timeout_seconds = 3
self.read_timeout_seconds = 5
self.retries = 1
self.retry_count = 1
self.scan_range_id = None
self.max_ips = None
self.specific_ips = None
def model_dump(self):
return self.__dict__
def dict(self):
return self.__dict__
def __getattr__(self, name):
if "timeout_ms" in name:
return 5000
if "timeout_seconds" in name:
return 5
if name in ("port", "api_port"):
return 4028
if name in ("retries", "retry_count"):
return 1
return None
def call_discover_one_miner_flexible(ip_address: str, room_id: str | None = None):
func = globals().get("discover_one_miner")
if func is None:
raise RuntimeError("discover_one_miner function was not found")
signature = inspect.signature(func)
params = signature.parameters
request_body = ScheduledDiscoverRequestV2(
ip_address=ip_address,
room_id=room_id,
)
candidate_kwargs = {
"request": request_body,
"body": request_body,
"payload": request_body,
"ip_address": ip_address,
"ip": ip_address,
"host": ip_address,
"port": 4028,
"api_port": 4028,
"room_id": room_id,
"source": "scheduled_known_miner_scan",
"timeout_ms": 5000,
"connect_timeout_ms": 3000,
"read_timeout_ms": 5000,
}
kwargs = {}
for name, param in params.items():
if name in candidate_kwargs:
kwargs[name] = candidate_kwargs[name]
missing_required = []
for name, param in params.items():
if param.default is inspect._empty and name not in kwargs:
if param.kind in (
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
):
missing_required.append(name)
if missing_required:
raise RuntimeError(
"discover_one_miner requires unsupported argument(s): "
+ ", ".join(missing_required)
)
result = func(**kwargs)
if inspect.iscoroutine(result):
result = asyncio.run(result)
return result
# Patch 5: scheduled scanner passes an internal system user so FastAPI Depends is not used directly.
def call_discover_one_miner_flexible(ip_address: str, room_id: str | None = None):
func = globals().get("discover_one_miner")
if func is None:
raise RuntimeError("discover_one_miner function was not found")
signature = inspect.signature(func)
params = signature.parameters
request_body = ScheduledDiscoverRequestV2(
ip_address=ip_address,
room_id=room_id,
)
system_user = {
"id": "00000000-0000-0000-0000-000000000000",
"user_id": "00000000-0000-0000-0000-000000000000",
"username": "system-scanner",
"role": "system",
"is_admin": True,
}
candidate_kwargs = {
"request": request_body,
"body": request_body,
"payload": request_body,
"ip_address": ip_address,
"ip": ip_address,
"host": ip_address,
"port": 4028,
"api_port": 4028,
"room_id": room_id,
"source": "scheduled_known_miner_scan",
"timeout_ms": 5000,
"connect_timeout_ms": 3000,
"read_timeout_ms": 5000,
"current_user": system_user,
"user": system_user,
"auth_user": system_user,
}
kwargs = {}
for name, param in params.items():
if name in candidate_kwargs:
kwargs[name] = candidate_kwargs[name]
missing_required = []
for name, param in params.items():
if param.default is inspect._empty and name not in kwargs:
if param.kind in (
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
):
missing_required.append(name)
if missing_required:
raise RuntimeError(
"discover_one_miner requires unsupported argument(s): "
+ ", ".join(missing_required)
)
result = func(**kwargs)
if inspect.iscoroutine(result):
result = asyncio.run(result)
return result
# Patch 6: scheduled scanner uses a real user from the users table for audit logs.
def get_scheduled_scanner_user():
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
id::text AS id,
username
FROM users
ORDER BY
CASE WHEN username = 'admin' THEN 0 ELSE 1 END,
username
LIMIT 1;
"""
)
user = cur.fetchone()
if not user:
raise RuntimeError("No users exist. Cannot run scheduled scanner audit logs.")
return {
"id": user["id"],
"user_id": user["id"],
"username": user["username"],
"role": "system-scanner",
"is_admin": True,
}
finally:
if conn is not None:
conn.close()
def call_discover_one_miner_flexible(ip_address: str, room_id: str | None = None):
func = globals().get("discover_one_miner")
if func is None:
raise RuntimeError("discover_one_miner function was not found")
request_body = ScheduledDiscoverRequestV2(
ip_address=ip_address,
room_id=room_id,
)
system_user = get_scheduled_scanner_user()
result = func(
request=request_body,
current_user=system_user,
)
if inspect.iscoroutine(result):
result = asyncio.run(result)
return result
@app.get("/api/dashboard/issues")
def dashboard_issues(
min_temp_c: float = 90,
stale_minutes: int = 30,
rejected_percent: float = 1,
current_user: dict = Depends(get_current_user),
):
from collections import Counter, defaultdict
def to_float(value):
if value is None:
return None
try:
return float(value)
except Exception:
return None
def is_blank(value):
return value is None or str(value).strip() == ""
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
WITH latest AS (
SELECT DISTINCT ON (miner_id)
miner_id,
observed_at,
observed_ip,
mhs_av,
power_w,
power_rate_w_per_th,
chip_temp_max_c,
chip_temp_avg_c,
hash_stable,
pool_rejected_percent
FROM miner_status_snapshots
ORDER BY miner_id, observed_at DESC
),
hb AS (
SELECT
miner_id,
COUNT(*) AS hashboard_count,
COUNT(*) FILTER (
WHERE pcb_sn IS NULL
OR trim(pcb_sn) = ''
OR lower(trim(pcb_sn)) = 'none'
OR lower(trim(pcb_sn)) = 'unknown'
) AS missing_hashboard_sn_count
FROM miner_hashboards
GROUP BY miner_id
)
SELECT
miners.id::text AS miner_id,
COALESCE(rooms.name, 'Unassigned') AS room_name,
host(miners.current_ip) AS current_ip,
miners.current_mac,
miners.hostname,
miners.miner_sn,
miners.power_sn,
miners.model,
miners.miner_type,
miners.firmware_version,
miners.status,
miners.last_seen_at,
latest.observed_at,
ROUND((latest.mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths,
ROUND(latest.power_w::numeric, 2)::float AS power_w,
ROUND(latest.power_rate_w_per_th::numeric, 2)::float AS power_rate_w_per_th,
ROUND(latest.chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c,
ROUND(latest.chip_temp_avg_c::numeric, 2)::float AS chip_temp_avg_c,
latest.hash_stable,
ROUND(latest.pool_rejected_percent::numeric, 4)::float AS pool_rejected_percent,
COALESCE(hb.hashboard_count, 0) AS hashboard_count,
COALESCE(hb.missing_hashboard_sn_count, 0) AS missing_hashboard_sn_count,
CASE
WHEN miners.last_seen_at IS NULL THEN true
WHEN miners.last_seen_at < now() - make_interval(mins => %s) THEN true
ELSE false
END AS is_stale,
ROUND((EXTRACT(EPOCH FROM (now() - miners.last_seen_at)) / 60.0)::numeric, 1)::float AS minutes_since_seen
FROM miners
LEFT JOIN rooms ON rooms.id = miners.room_id
LEFT JOIN latest ON latest.miner_id = miners.id
LEFT JOIN hb ON hb.miner_id = miners.id
ORDER BY rooms.sort_order, miners.current_mac;
""",
(stale_minutes,),
)
rows = [dict(row) for row in cur.fetchall()]
firmware_counts_by_model = defaultdict(Counter)
for row in rows:
model_key = row.get("miner_type") or row.get("model")
firmware = row.get("firmware_version")
if not is_blank(model_key) and not is_blank(firmware):
firmware_counts_by_model[model_key][firmware] += 1
common_firmware_by_model = {}
for model_key, counter in firmware_counts_by_model.items():
total = sum(counter.values())
if total < 5:
continue
firmware, count = counter.most_common(1)[0]
if count / total >= 0.7:
common_firmware_by_model[model_key] = firmware
issues = []
def add_issue(row, issue_type, severity, title, detail, value=None):
issues.append(
{
"issue_type": issue_type,
"severity": severity,
"title": title,
"detail": detail,
"value": value,
"miner_id": row.get("miner_id"),
"room_name": row.get("room_name"),
"current_mac": row.get("current_mac"),
"current_ip": row.get("current_ip"),
"model": row.get("miner_type") or row.get("model"),
"firmware_version": row.get("firmware_version"),
"last_seen_at": row.get("last_seen_at"),
"observed_at": row.get("observed_at"),
}
)
for row in rows:
chip_temp_max_c = to_float(row.get("chip_temp_max_c"))
pool_rejected_percent = to_float(row.get("pool_rejected_percent"))
minutes_since_seen = to_float(row.get("minutes_since_seen"))
if chip_temp_max_c is not None and chip_temp_max_c >= min_temp_c:
severity = "critical" if chip_temp_max_c >= 95 else "warning"
add_issue(
row,
"HOT_MINER",
severity,
"Hot miner",
f"Max chip temperature is {chip_temp_max_c:.1f} C.",
chip_temp_max_c,
)
if row.get("hash_stable") is False:
add_issue(
row,
"HASH_UNSTABLE",
"warning",
"Hash unstable",
"Miner reports hash_stable=false.",
None,
)
if pool_rejected_percent is not None and pool_rejected_percent >= rejected_percent:
add_issue(
row,
"HIGH_REJECTED",
"warning",
"High rejected pool percentage",
f"Pool rejected percentage is {pool_rejected_percent:.4f}%.",
pool_rejected_percent,
)
if row.get("is_stale") is True:
value_text = "unknown" if minutes_since_seen is None else f"{minutes_since_seen:.1f} minutes"
add_issue(
row,
"STALE_MINER",
"critical",
"Stale miner",
f"Miner has not been seen in {value_text}.",
minutes_since_seen,
)
if is_blank(row.get("current_mac")):
add_issue(
row,
"MISSING_MAC",
"critical",
"Missing MAC address",
"Miner does not have a current MAC address recorded.",
None,
)
if is_blank(row.get("miner_sn")):
add_issue(
row,
"MISSING_MINER_SN",
"warning",
"Missing miner serial number",
"Miner serial number is not recorded.",
None,
)
if is_blank(row.get("power_sn")):
add_issue(
row,
"MISSING_POWER_SN",
"warning",
"Missing power supply serial number",
"Power supply serial number is not recorded.",
None,
)
missing_hashboard_sn_count = int(row.get("missing_hashboard_sn_count") or 0)
hashboard_count = int(row.get("hashboard_count") or 0)
if hashboard_count > 0 and missing_hashboard_sn_count > 0:
add_issue(
row,
"MISSING_HASHBOARD_SN",
"warning",
"Missing hashboard serials",
f"{missing_hashboard_sn_count} of {hashboard_count} hashboards are missing PCB serial numbers.",
missing_hashboard_sn_count,
)
model_key = row.get("miner_type") or row.get("model")
firmware = row.get("firmware_version")
expected_firmware = common_firmware_by_model.get(model_key)
if (
not is_blank(model_key)
and not is_blank(firmware)
and not is_blank(expected_firmware)
and firmware != expected_firmware
):
add_issue(
row,
"FIRMWARE_MINOR_VERSION",
"info",
"Firmware differs from model majority",
f"{model_key} is running {firmware}; most miners of this model are running {expected_firmware}.",
firmware,
)
severity_rank = {
"critical": 0,
"warning": 1,
"info": 2,
}
issues.sort(
key=lambda issue: (
severity_rank.get(issue["severity"], 99),
issue["issue_type"],
issue.get("room_name") or "",
issue.get("current_mac") or "",
)
)
severity_counts = Counter(issue["severity"] for issue in issues)
type_counts = Counter(issue["issue_type"] for issue in issues)
return api_response(
data=issues,
meta={
"total_issues": len(issues),
"severity_counts": dict(severity_counts),
"type_counts": dict(type_counts),
"min_temp_c": min_temp_c,
"stale_minutes": stale_minutes,
"rejected_percent": rejected_percent,
},
)
finally:
if conn is not None:
conn.close()
# -----------------------------
# Phase 2: Maintenance / repair history
# -----------------------------
from pydantic import BaseModel
from uuid import uuid4
class MaintenanceEventCreate(BaseModel):
event_type: str
event_status: str = "completed"
event_title: str
description: str | None = None
event_at: str | None = None
old_mac: str | None = None
new_mac: str | None = None
old_ip: str | None = None
new_ip: str | None = None
component_type: str | None = None
component_slot: str | None = None
component_serial: str | None = None
cost_amount: float | None = None
downtime_minutes: int | None = None
class MaintenanceEventUpdate(BaseModel):
event_type: str | None = None
event_status: str | None = None
event_title: str | None = None
description: str | None = None
event_at: str | None = None
old_mac: str | None = None
new_mac: str | None = None
old_ip: str | None = None
new_ip: str | None = None
component_type: str | None = None
component_slot: str | None = None
component_serial: str | None = None
cost_amount: float | None = None
downtime_minutes: int | None = None
def get_current_user_id_for_maintenance(current_user: dict):
if isinstance(current_user, dict):
return current_user.get("id") or current_user.get("user_id")
return None
def ensure_maintenance_tables():
conn = None
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS miner_maintenance_events (
id uuid PRIMARY KEY,
miner_id uuid NOT NULL REFERENCES miners(id) ON DELETE CASCADE,
event_type text NOT NULL,
event_status text NOT NULL DEFAULT 'completed',
event_title text NOT NULL,
description text,
event_at timestamptz NOT NULL DEFAULT now(),
old_mac text,
new_mac text,
old_ip inet,
new_ip inet,
component_type text,
component_slot text,
component_serial text,
cost_amount numeric(12, 2),
downtime_minutes integer,
created_by_user_id uuid NULL REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_miner_id
ON miner_maintenance_events(miner_id);
CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_event_at
ON miner_maintenance_events(event_at DESC);
CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_event_type
ON miner_maintenance_events(event_type);
"""
)
conn.commit()
finally:
if conn is not None:
conn.close()
@app.on_event("startup")
def startup_ensure_maintenance_tables():
ensure_maintenance_tables()
@app.get("/api/maintenance/event-types")
def maintenance_event_types(current_user: dict = Depends(get_current_user)):
return api_response(
data=[
{"value": "general_note", "label": "General note"},
{"value": "control_board_changed", "label": "Control board changed"},
{"value": "mac_changed", "label": "MAC changed"},
{"value": "hashboard_replaced", "label": "Hashboard replaced"},
{"value": "psu_replaced", "label": "PSU replaced"},
{"value": "fan_replaced", "label": "Fan replaced"},
{"value": "cleaned", "label": "Cleaned"},
{"value": "moved", "label": "Moved"},
{"value": "firmware_updated", "label": "Firmware updated"},
{"value": "troubleshooting", "label": "Troubleshooting"},
]
)
@app.get("/api/miners/{miner_id}/maintenance-events")
def list_miner_maintenance_events(
miner_id: str,
current_user: dict = Depends(get_current_user),
):
ensure_maintenance_tables()
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
e.id::text,
e.miner_id::text,
e.event_type,
e.event_status,
e.event_title,
e.description,
e.event_at,
e.old_mac,
e.new_mac,
host(e.old_ip) AS old_ip,
host(e.new_ip) AS new_ip,
e.component_type,
e.component_slot,
e.component_serial,
e.cost_amount::float,
e.downtime_minutes,
e.created_by_user_id::text,
users.username AS created_by_username,
e.created_at,
e.updated_at
FROM miner_maintenance_events e
LEFT JOIN users ON users.id = e.created_by_user_id
WHERE e.miner_id = %s::uuid
ORDER BY e.event_at DESC, e.created_at DESC;
""",
(miner_id,),
)
rows = [dict(row) for row in cur.fetchall()]
return api_response(data=rows)
finally:
if conn is not None:
conn.close()
@app.post("/api/miners/{miner_id}/maintenance-events")
def create_miner_maintenance_event(
miner_id: str,
request: MaintenanceEventCreate,
current_user: dict = Depends(get_current_user),
):
ensure_maintenance_tables()
conn = None
try:
conn = get_db_connection()
event_id = str(uuid4())
user_id = get_current_user_id_for_maintenance(current_user)
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT id::text
FROM miners
WHERE id = %s::uuid;
""",
(miner_id,),
)
miner = cur.fetchone()
if not miner:
raise HTTPException(status_code=404, detail="Miner not found")
cur.execute(
"""
INSERT INTO miner_maintenance_events (
id,
miner_id,
event_type,
event_status,
event_title,
description,
event_at,
old_mac,
new_mac,
old_ip,
new_ip,
component_type,
component_slot,
component_serial,
cost_amount,
downtime_minutes,
created_by_user_id
)
VALUES (
%s::uuid,
%s::uuid,
%s,
%s,
%s,
%s,
COALESCE(%s::timestamptz, now()),
%s,
%s,
NULLIF(%s, '')::inet,
NULLIF(%s, '')::inet,
%s,
%s,
%s,
%s,
%s,
%s::uuid
)
RETURNING
id::text,
miner_id::text,
event_type,
event_status,
event_title,
description,
event_at,
old_mac,
new_mac,
host(old_ip) AS old_ip,
host(new_ip) AS new_ip,
component_type,
component_slot,
component_serial,
cost_amount::float,
downtime_minutes,
created_by_user_id::text,
created_at,
updated_at;
""",
(
event_id,
miner_id,
request.event_type,
request.event_status,
request.event_title,
request.description,
request.event_at,
request.old_mac,
request.new_mac,
request.old_ip,
request.new_ip,
request.component_type,
request.component_slot,
request.component_serial,
request.cost_amount,
request.downtime_minutes,
user_id,
),
)
row = dict(cur.fetchone())
conn.commit()
return api_response(data=row)
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
@app.patch("/api/miners/{miner_id}/maintenance-events/{event_id}")
def update_miner_maintenance_event(
miner_id: str,
event_id: str,
request: MaintenanceEventUpdate,
current_user: dict = Depends(get_current_user),
):
ensure_maintenance_tables()
update_data = request.model_dump(exclude_unset=True)
if not update_data:
raise HTTPException(status_code=400, detail="No fields supplied for update")
allowed_fields = {
"event_type",
"event_status",
"event_title",
"description",
"event_at",
"old_mac",
"new_mac",
"old_ip",
"new_ip",
"component_type",
"component_slot",
"component_serial",
"cost_amount",
"downtime_minutes",
}
set_parts = []
values = []
for key, value in update_data.items():
if key not in allowed_fields:
continue
if key in ("old_ip", "new_ip"):
set_parts.append(f"{key} = NULLIF(%s, '')::inet")
elif key == "event_at":
set_parts.append(f"{key} = %s::timestamptz")
else:
set_parts.append(f"{key} = %s")
values.append(value)
if not set_parts:
raise HTTPException(status_code=400, detail="No valid fields supplied for update")
set_parts.append("updated_at = now()")
values.append(miner_id)
values.append(event_id)
sql = f"""
UPDATE miner_maintenance_events
SET {", ".join(set_parts)}
WHERE miner_id = %s::uuid
AND id = %s::uuid
RETURNING
id::text,
miner_id::text,
event_type,
event_status,
event_title,
description,
event_at,
old_mac,
new_mac,
host(old_ip) AS old_ip,
host(new_ip) AS new_ip,
component_type,
component_slot,
component_serial,
cost_amount::float,
downtime_minutes,
created_by_user_id::text,
created_at,
updated_at;
"""
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(sql, values)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Maintenance event not found")
row = dict(row)
conn.commit()
return api_response(data=row)
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
@app.delete("/api/miners/{miner_id}/maintenance-events/{event_id}")
def delete_miner_maintenance_event(
miner_id: str,
event_id: str,
current_user: dict = Depends(get_current_user),
):
ensure_maintenance_tables()
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
DELETE FROM miner_maintenance_events
WHERE miner_id = %s::uuid
AND id = %s::uuid
RETURNING id::text;
""",
(miner_id, event_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Maintenance event not found")
conn.commit()
return api_response(data={"deleted": True, "id": event_id})
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
class MinerMacChangeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
new_mac: str = Field(min_length=1, max_length=64)
old_mac: str | None = None
new_ip: str | None = None
event_title: str | None = None
description: str | None = Field(default=None, max_length=4000)
component_serial: str | None = Field(default=None, max_length=255)
downtime_minutes: int | None = Field(default=None, ge=0)
removed_board_disposition: RemovedBoardDisposition | None = None
replacement_reason: ControlBoardReplacementReason | None = None
@app.post("/api/miners/{miner_id}/change-mac")
def change_miner_mac_address(
miner_id: str,
request: MinerMacChangeRequest,
current_user: dict = Depends(get_current_user),
):
"""Compatibility alias for the repair-scoped replacement workflow."""
if request.replacement_reason is None or request.removed_board_disposition is None:
raise HTTPException(
status_code=422,
detail=(
"The compatibility route requires replacement_reason and "
"removed_board_disposition; use the repair-scoped replacement fields"
),
)
if request.new_ip is not None or request.downtime_minutes is not None:
raise HTTPException(
status_code=422,
detail=(
"The repair-scoped compatibility route does not change IP addresses "
"or record mutable downtime values"
),
)
try:
parsed_miner_id = UUID(miner_id)
except ValueError as exc:
raise HTTPException(status_code=422, detail="Invalid miner UUID") from exc
try:
replacement = ControlBoardReplacementRequest(
new_mac_address=request.new_mac,
new_control_board_serial=request.component_serial,
removed_board_disposition=request.removed_board_disposition,
replacement_reason=request.replacement_reason,
notes=request.description,
)
except ValidationError as exc:
raise HTTPException(
status_code=422, detail="Legacy change-mac request is invalid"
) from exc
try:
result = RepairService(RepairRepository()).replace_control_board(
parsed_miner_id,
replacement,
actor_user_id=UUID(str(current_user["id"])),
)
except RepairMinerNotFound as exc:
raise HTTPException(status_code=404, detail="Miner not found") from exc
except RepairConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except (InvalidMacAddress, ControlBoardReplacementActorRequired) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
replacement_data = result.model_dump(mode="json")
return api_response(
data={
"miner": {
"id": replacement_data["miner_id"],
"current_mac": replacement_data["current_mac"],
"control_board_sn": replacement_data["control_board_serial"],
},
"control_board_replacement": replacement_data,
}
)
@app.get("/api/maintenance/events")
def list_all_maintenance_events(
limit: int = 100,
event_status: str | None = None,
event_type: str | None = None,
q: str | None = None,
current_user: dict = Depends(get_current_user),
):
from collections import Counter
ensure_maintenance_tables()
safe_limit = max(1, min(int(limit or 100), 500))
where_parts = ["1 = 1"]
values = []
if event_status:
where_parts.append("e.event_status = %s")
values.append(event_status)
if event_type:
where_parts.append("e.event_type = %s")
values.append(event_type)
if q:
search_value = f"%{q.strip()}%"
where_parts.append(
"""
(
e.event_title ILIKE %s
OR e.description ILIKE %s
OR e.event_type ILIKE %s
OR e.event_status ILIKE %s
OR e.old_mac ILIKE %s
OR e.new_mac ILIKE %s
OR e.component_type ILIKE %s
OR e.component_slot ILIKE %s
OR e.component_serial ILIKE %s
OR miners.current_mac ILIKE %s
OR host(miners.current_ip) ILIKE %s
OR miners.miner_sn ILIKE %s
OR miners.power_sn ILIKE %s
OR rooms.name ILIKE %s
)
"""
)
values.extend([search_value] * 14)
values.append(safe_limit)
sql = f"""
SELECT
e.id::text AS event_id,
e.miner_id::text AS miner_id,
COALESCE(rooms.name, 'Unassigned') AS room_name,
miners.current_mac,
host(miners.current_ip) AS current_ip,
miners.hostname,
miners.miner_sn,
miners.power_sn,
miners.model,
miners.miner_type,
miners.firmware_version,
e.event_type,
e.event_status,
e.event_title,
e.description,
e.event_at,
e.old_mac,
e.new_mac,
host(e.old_ip) AS old_ip,
host(e.new_ip) AS new_ip,
e.component_type,
e.component_slot,
e.component_serial,
e.cost_amount::float,
e.downtime_minutes,
e.created_by_user_id::text,
users.username AS created_by_username,
e.created_at,
e.updated_at
FROM miner_maintenance_events e
INNER JOIN miners ON miners.id = e.miner_id
LEFT JOIN rooms ON rooms.id = miners.room_id
LEFT JOIN users ON users.id = e.created_by_user_id
WHERE {" AND ".join(where_parts)}
ORDER BY e.event_at DESC, e.created_at DESC
LIMIT %s;
"""
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(sql, values)
rows = [dict(row) for row in cur.fetchall()]
status_counts = Counter(row.get("event_status") for row in rows)
type_counts = Counter(row.get("event_type") for row in rows)
room_counts = Counter(row.get("room_name") for row in rows)
return api_response(
data=rows,
meta={
"returned": len(rows),
"limit": safe_limit,
"status_counts": dict(status_counts),
"type_counts": dict(type_counts),
"room_counts": dict(room_counts),
"filters": {
"event_status": event_status,
"event_type": event_type,
"q": q,
},
},
)
finally:
if conn is not None:
conn.close()
@app.get("/api/maintenance/events/export.csv")
def export_all_maintenance_events_csv(
limit: int = 5000,
event_status: str | None = None,
event_type: str | None = None,
q: str | None = None,
current_user: dict = Depends(get_current_user),
):
import csv
import io
from datetime import datetime, timezone
from fastapi.responses import StreamingResponse
ensure_maintenance_tables()
safe_limit = max(1, min(int(limit or 5000), 20000))
where_parts = ["1 = 1"]
values = []
if event_status:
where_parts.append("e.event_status = %s")
values.append(event_status)
if event_type:
where_parts.append("e.event_type = %s")
values.append(event_type)
if q:
search_value = f"%{q.strip()}%"
where_parts.append(
"""
(
e.event_title ILIKE %s
OR e.description ILIKE %s
OR e.event_type ILIKE %s
OR e.event_status ILIKE %s
OR e.old_mac ILIKE %s
OR e.new_mac ILIKE %s
OR e.component_type ILIKE %s
OR e.component_slot ILIKE %s
OR e.component_serial ILIKE %s
OR miners.current_mac ILIKE %s
OR host(miners.current_ip) ILIKE %s
OR miners.miner_sn ILIKE %s
OR miners.power_sn ILIKE %s
OR rooms.name ILIKE %s
)
"""
)
values.extend([search_value] * 14)
values.append(safe_limit)
sql = f"""
SELECT
e.event_at,
e.event_status,
e.event_type,
e.event_title,
e.description,
COALESCE(rooms.name, 'Unassigned') AS room_name,
miners.current_mac,
host(miners.current_ip) AS current_ip,
miners.hostname,
miners.miner_sn,
miners.power_sn,
miners.model,
miners.miner_type,
miners.firmware_version,
e.old_mac,
e.new_mac,
host(e.old_ip) AS old_ip,
host(e.new_ip) AS new_ip,
e.component_type,
e.component_slot,
e.component_serial,
e.cost_amount::float,
e.downtime_minutes,
users.username AS created_by_username,
e.created_at,
e.updated_at,
e.id::text AS event_id,
e.miner_id::text AS miner_id
FROM miner_maintenance_events e
INNER JOIN miners ON miners.id = e.miner_id
LEFT JOIN rooms ON rooms.id = miners.room_id
LEFT JOIN users ON users.id = e.created_by_user_id
WHERE {" AND ".join(where_parts)}
ORDER BY e.event_at DESC, e.created_at DESC
LIMIT %s;
"""
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(sql, values)
rows = [dict(row) for row in cur.fetchall()]
output = io.StringIO()
fieldnames = [
"event_at",
"event_status",
"event_type",
"event_title",
"description",
"room_name",
"current_mac",
"current_ip",
"hostname",
"miner_sn",
"power_sn",
"model",
"miner_type",
"firmware_version",
"old_mac",
"new_mac",
"old_ip",
"new_ip",
"component_type",
"component_slot",
"component_serial",
"cost_amount",
"downtime_minutes",
"created_by_username",
"created_at",
"updated_at",
"event_id",
"miner_id",
]
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
for row in rows:
cleaned = {}
for field in fieldnames:
value = row.get(field)
if value is None:
cleaned[field] = ""
else:
cleaned[field] = str(value)
writer.writerow(cleaned)
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
filename = f"minerops-maintenance-events-{timestamp}.csv"
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
},
)
finally:
if conn is not None:
conn.close()
# -----------------------------
# Phase 3: Physical location mapping
# -----------------------------
class LocationSeedImportRequest(BaseModel):
schema_version: int = 1
generated_from: list[str] | None = None
notes: str | None = None
virtual_states: list[str] | None = None
summary: dict | None = None
positions: list[dict]
def normalize_mac_for_location(mac: str | None):
if mac is None:
return None
cleaned = "".join(ch for ch in str(mac).strip().lower() if ch in "0123456789abcdef")
if len(cleaned) != 12:
return None
return ":".join(cleaned[i:i + 2] for i in range(0, 12, 2)).upper()
def normalize_mac_key_for_location(mac: str | None):
normalized = normalize_mac_for_location(mac)
if normalized is None:
return None
return normalized.replace(":", "").lower()
def get_current_user_id_for_location(current_user: dict):
if isinstance(current_user, dict):
return current_user.get("id") or current_user.get("user_id")
return None
def ensure_location_tables():
conn = None
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS location_positions (
id uuid PRIMARY KEY,
room_id uuid NULL REFERENCES rooms(id) ON DELETE SET NULL,
room_name text NOT NULL,
rack_name text NOT NULL,
shelf_name text NOT NULL,
shelf_index integer,
position_index integer NOT NULL,
position_label text,
position_kind text NOT NULL DEFAULT 'miner_slot',
position_status text NOT NULL DEFAULT 'available',
expected_mac text,
miner_id uuid NULL REFERENCES miners(id) ON DELETE SET NULL,
equipment_label text,
notes text,
source_file text,
source_sheet text,
source_cell text,
raw_value text,
conflict_reason text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
DO $$
BEGIN
ALTER TABLE location_positions
ADD CONSTRAINT uq_location_positions_slot
UNIQUE (room_name, rack_name, shelf_name, position_index);
EXCEPTION
WHEN duplicate_object OR duplicate_table THEN NULL;
END $$;
CREATE INDEX IF NOT EXISTS idx_location_positions_room
ON location_positions(room_name);
CREATE INDEX IF NOT EXISTS idx_location_positions_rack
ON location_positions(room_name, rack_name);
CREATE INDEX IF NOT EXISTS idx_location_positions_expected_mac
ON location_positions(expected_mac);
CREATE INDEX IF NOT EXISTS idx_location_positions_miner_id
ON location_positions(miner_id);
CREATE TABLE IF NOT EXISTS miner_location_states (
miner_id uuid PRIMARY KEY REFERENCES miners(id) ON DELETE CASCADE,
asset_state text NOT NULL DEFAULT 'waiting_placement',
location_position_id uuid NULL REFERENCES location_positions(id) ON DELETE SET NULL,
state_notes text,
updated_by_user_id uuid NULL REFERENCES users(id) ON DELETE SET NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_miner_location_states_asset_state
ON miner_location_states(asset_state);
CREATE TABLE IF NOT EXISTS location_import_issues (
id uuid PRIMARY KEY,
import_batch_id uuid NOT NULL,
issue_type text NOT NULL,
severity text NOT NULL DEFAULT 'warning',
message text NOT NULL,
mac_address text,
room_name text,
rack_name text,
shelf_name text,
position_index integer,
source_file text,
source_cell text,
created_at timestamptz NOT NULL DEFAULT now(),
resolved_at timestamptz NULL
);
CREATE INDEX IF NOT EXISTS idx_location_import_issues_batch
ON location_import_issues(import_batch_id);
CREATE INDEX IF NOT EXISTS idx_location_import_issues_mac
ON location_import_issues(mac_address);
"""
)
conn.commit()
finally:
if conn is not None:
conn.close()
@app.on_event("startup")
def startup_ensure_location_tables():
ensure_location_tables()
def get_room_id_by_name(cur, room_name: str):
cur.execute(
"""
SELECT id::text
FROM rooms
WHERE lower(name) = lower(%s)
LIMIT 1;
""",
(room_name,),
)
row = cur.fetchone()
if row:
return row["id"] if isinstance(row, dict) else row[0]
return None
def find_or_create_placeholder_miner_for_location(cur, mac_address: str, room_id: str | None):
mac_key = normalize_mac_key_for_location(mac_address)
if not mac_key:
return None, False
cur.execute(
"""
SELECT id::text
FROM miners
WHERE regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g') = %s
LIMIT 1;
""",
(mac_key,),
)
row = cur.fetchone()
if row:
miner_id = row["id"] if isinstance(row, dict) else row[0]
if room_id:
cur.execute(
"""
UPDATE miners
SET room_id = COALESCE(%s::uuid, room_id)
WHERE id = %s::uuid;
""",
(room_id, miner_id),
)
return miner_id, False
miner_id = str(uuid4())
cur.execute(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'miners';
"""
)
miner_columns = {row["column_name"] if isinstance(row, dict) else row[0] for row in cur.fetchall()}
insert_columns = []
insert_values_sql = []
values = []
def add_value(column_name, value, sql_cast=None):
if column_name not in miner_columns:
return
insert_columns.append(column_name)
if sql_cast:
insert_values_sql.append(f"%s::{sql_cast}")
else:
insert_values_sql.append("%s")
values.append(value)
def add_now(column_name):
if column_name not in miner_columns:
return
insert_columns.append(column_name)
insert_values_sql.append("now()")
add_value("id", miner_id, "uuid")
add_value("room_id", room_id, "uuid")
add_value("current_mac", mac_address)
add_value("status", "offline")
add_value("hostname", None)
add_value("model", None)
add_value("miner_type", None)
add_value("firmware_version", None)
add_value("miner_sn", None)
add_value("power_sn", None)
add_now("created_at")
add_now("updated_at")
sql = f"""
INSERT INTO miners ({", ".join(insert_columns)})
VALUES ({", ".join(insert_values_sql)})
RETURNING id::text;
"""
cur.execute(sql, values)
created = cur.fetchone()
if created:
miner_id = created["id"] if isinstance(created, dict) else created[0]
return miner_id, True
@app.post("/api/location/import-layout-seed")
def import_location_layout_seed(
request: LocationSeedImportRequest,
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
if not request.positions:
raise HTTPException(status_code=400, detail="No positions found in layout seed")
import_batch_id = str(uuid4())
user_id = get_current_user_id_for_location(current_user)
conn = None
counts = {
"positions_seen": 0,
"positions_upserted": 0,
"miner_positions": 0,
"switch_positions": 0,
"blocked_positions": 0,
"empty_positions": 0,
"placeholder_miners_created": 0,
"existing_miners_matched": 0,
"duplicate_conflicts": 0,
"miners_marked_waiting_placement": 0,
}
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
DELETE FROM location_import_issues
WHERE resolved_at IS NULL;
"""
)
mac_counts = {}
for position in request.positions:
mac = normalize_mac_for_location(position.get("mac_address"))
if mac:
mac_counts[mac] = mac_counts.get(mac, 0) + 1
duplicate_macs = {mac for mac, count in mac_counts.items() if count > 1}
for position in request.positions:
counts["positions_seen"] += 1
room_name = str(position.get("room_name") or "").strip()
rack_name = str(position.get("rack_name") or "").strip()
shelf_name = str(position.get("shelf_name") or "").strip()
position_index = int(position.get("position_index") or 0)
if not room_name or not rack_name or not shelf_name or position_index <= 0:
continue
item_type = str(position.get("item_type") or "empty").strip().lower()
mac_address = normalize_mac_for_location(position.get("mac_address"))
room_id = get_room_id_by_name(cur, room_name)
position_kind = "miner_slot"
position_status = str(position.get("position_status") or "available").strip().lower()
equipment_label = position.get("label")
expected_mac = None
miner_id = None
conflict_reason = position.get("conflict_reason")
if item_type == "switch":
position_kind = "switch"
position_status = "switch"
counts["switch_positions"] += 1
elif item_type == "blocked":
position_kind = "blocked"
position_status = "blocked"
counts["blocked_positions"] += 1
elif item_type == "miner" and mac_address:
position_kind = "miner_slot"
expected_mac = mac_address
counts["miner_positions"] += 1
miner_id, was_created = find_or_create_placeholder_miner_for_location(cur, mac_address, room_id)
if was_created:
counts["placeholder_miners_created"] += 1
else:
counts["existing_miners_matched"] += 1
if mac_address in duplicate_macs:
position_status = "conflict"
conflict_reason = "duplicate_mac_in_layout"
counts["duplicate_conflicts"] += 1
cur.execute(
"""
INSERT INTO location_import_issues (
id,
import_batch_id,
issue_type,
severity,
message,
mac_address,
room_name,
rack_name,
shelf_name,
position_index,
source_file,
source_cell
)
VALUES (
%s::uuid,
%s::uuid,
'duplicate_mac_in_layout',
'warning',
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s
);
""",
(
str(uuid4()),
import_batch_id,
f"Duplicate MAC {mac_address} appears in more than one physical map position.",
mac_address,
room_name,
rack_name,
shelf_name,
position_index,
position.get("source_file"),
position.get("source_cell"),
),
)
else:
position_status = "occupied"
else:
position_kind = "miner_slot"
position_status = "available"
counts["empty_positions"] += 1
cur.execute(
"""
INSERT INTO location_positions (
id,
room_id,
room_name,
rack_name,
shelf_name,
shelf_index,
position_index,
position_label,
position_kind,
position_status,
expected_mac,
miner_id,
equipment_label,
notes,
source_file,
source_sheet,
source_cell,
raw_value,
conflict_reason,
updated_at
)
VALUES (
%s::uuid,
%s::uuid,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s::uuid,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
now()
)
ON CONFLICT (room_name, rack_name, shelf_name, position_index)
DO UPDATE SET
room_id = EXCLUDED.room_id,
position_label = EXCLUDED.position_label,
position_kind = EXCLUDED.position_kind,
position_status = EXCLUDED.position_status,
expected_mac = EXCLUDED.expected_mac,
miner_id = EXCLUDED.miner_id,
equipment_label = EXCLUDED.equipment_label,
notes = EXCLUDED.notes,
source_file = EXCLUDED.source_file,
source_sheet = EXCLUDED.source_sheet,
source_cell = EXCLUDED.source_cell,
raw_value = EXCLUDED.raw_value,
conflict_reason = EXCLUDED.conflict_reason,
updated_at = now()
RETURNING id::text;
""",
(
str(uuid4()),
room_id,
room_name,
rack_name,
shelf_name,
position.get("shelf_index"),
position_index,
position.get("position_label"),
position_kind,
position_status,
expected_mac,
miner_id,
equipment_label,
position.get("notes"),
position.get("source_file"),
position.get("source_sheet"),
position.get("source_cell"),
position.get("raw_value"),
conflict_reason,
),
)
position_row = cur.fetchone()
location_position_id = position_row["id"]
counts["positions_upserted"] += 1
if miner_id:
cur.execute(
"""
INSERT INTO miner_location_states (
miner_id,
asset_state,
location_position_id,
updated_by_user_id,
updated_at
)
VALUES (
%s::uuid,
'shelved',
%s::uuid,
%s::uuid,
now()
)
ON CONFLICT (miner_id)
DO UPDATE SET
asset_state = 'shelved',
location_position_id = EXCLUDED.location_position_id,
updated_by_user_id = EXCLUDED.updated_by_user_id,
updated_at = now();
""",
(
miner_id,
location_position_id,
user_id,
),
)
cur.execute(
"""
INSERT INTO miner_location_states (
miner_id,
asset_state,
location_position_id,
updated_by_user_id,
updated_at
)
SELECT
miners.id,
'waiting_placement',
NULL,
%s::uuid,
now()
FROM miners
WHERE NOT EXISTS (
SELECT 1
FROM location_positions
WHERE location_positions.miner_id = miners.id
)
ON CONFLICT (miner_id)
DO UPDATE SET
asset_state = CASE
WHEN miner_location_states.asset_state IN ('storage', 'repair', 'decommissioned')
THEN miner_location_states.asset_state
ELSE 'waiting_placement'
END,
location_position_id = CASE
WHEN miner_location_states.asset_state IN ('storage', 'repair', 'decommissioned')
THEN miner_location_states.location_position_id
ELSE NULL
END,
updated_by_user_id = EXCLUDED.updated_by_user_id,
updated_at = now();
""",
(user_id,),
)
counts["miners_marked_waiting_placement"] = cur.rowcount
conn.commit()
return api_response(
data={
"import_batch_id": import_batch_id,
"counts": counts,
"duplicate_macs": sorted(list(duplicate_macs)),
}
)
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
@app.get("/api/location/import-issues")
def list_location_import_issues(
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
id::text,
import_batch_id::text,
issue_type,
severity,
message,
mac_address,
room_name,
rack_name,
shelf_name,
position_index,
source_file,
source_cell,
created_at,
resolved_at
FROM location_import_issues
WHERE resolved_at IS NULL
ORDER BY created_at DESC, mac_address, room_name, rack_name, shelf_name, position_index;
"""
)
rows = [dict(row) for row in cur.fetchall()]
return api_response(data=rows)
finally:
if conn is not None:
conn.close()
def get_legacy_location_map(
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
WITH latest AS (
SELECT DISTINCT ON (miner_id)
miner_id,
observed_at,
ROUND((mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths,
ROUND(power_w::numeric, 2)::float AS power_w,
ROUND(chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c,
ROUND(chip_temp_avg_c::numeric, 2)::float AS chip_temp_avg_c,
hash_stable,
pool_rejected_percent
FROM miner_status_snapshots
ORDER BY miner_id, observed_at DESC
)
SELECT
lp.id::text AS location_position_id,
lp.room_name,
lp.rack_name,
lp.shelf_name,
lp.shelf_index,
lp.position_index,
lp.position_label,
lp.position_kind,
lp.position_status,
lp.expected_mac,
lp.equipment_label,
lp.notes,
lp.source_file,
lp.source_cell,
lp.conflict_reason,
miners.id::text AS miner_id,
miners.current_mac,
host(miners.current_ip) AS current_ip,
miners.status AS miner_status,
miners.hostname,
miners.miner_sn,
miners.power_sn,
miners.model,
miners.miner_type,
miners.firmware_version,
miners.last_seen_at,
COALESCE(mls.asset_state, 'unassigned') AS asset_state,
latest.observed_at,
latest.hashrate_ths,
latest.power_w,
latest.chip_temp_max_c,
latest.chip_temp_avg_c,
latest.hash_stable,
latest.pool_rejected_percent
FROM location_positions lp
LEFT JOIN rooms room_order ON lower(room_order.name) = lower(lp.room_name)
LEFT JOIN miners ON miners.id = lp.miner_id
LEFT JOIN miner_location_states mls ON mls.miner_id = miners.id
LEFT JOIN latest ON latest.miner_id = miners.id
ORDER BY
COALESCE(room_order.sort_order, 999999),
lp.room_name,
lp.rack_name,
lp.shelf_index,
lp.position_index;
"""
)
positions = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT
miners.id::text AS miner_id,
COALESCE(mls.asset_state, 'waiting_placement') AS asset_state,
miners.current_mac,
host(miners.current_ip) AS current_ip,
miners.status,
miners.hostname,
miners.miner_sn,
miners.power_sn,
miners.model,
miners.miner_type,
miners.firmware_version,
miners.last_seen_at
FROM miners
LEFT JOIN miner_location_states mls ON mls.miner_id = miners.id
WHERE COALESCE(mls.asset_state, 'waiting_placement') IN (
'waiting_placement',
'storage',
'repair',
'decommissioned'
)
ORDER BY asset_state, miners.current_mac;
"""
)
virtual_miners = [dict(row) for row in cur.fetchall()]
return api_response(
data={
"positions": positions,
"virtual_miners": virtual_miners,
},
meta={
"position_count": len(positions),
"virtual_miner_count": len(virtual_miners),
},
)
finally:
if conn is not None:
conn.close()
@app.get("/locations", response_class=HTMLResponse)
def location_map_page():
return """
<!DOCTYPE html>
<html>
<head>
<title>MinerOps Location Map</title>
<style>
body {
font-family: Arial, sans-serif;
background: #f4f7fb;
margin: 0;
padding: 20px;
color: #1f2933;
}
h1, h2, h3 {
margin-top: 0;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.topbar a {
color: #0b63ce;
text-decoration: none;
font-weight: bold;
}
.card {
background: white;
border: 1px solid #d9e2ec;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: end;
}
.field {
min-width: 180px;
}
.label {
font-size: 12px;
color: #52606d;
margin-bottom: 4px;
font-weight: bold;
}
input, select {
width: 100%;
padding: 8px;
border: 1px solid #bcccdc;
border-radius: 5px;
box-sizing: border-box;
}
button {
padding: 9px 12px;
border: 1px solid #829ab1;
background: #f8fafc;
border-radius: 5px;
cursor: pointer;
font-weight: bold;
}
button:hover {
background: #e6f0ff;
}
.status {
margin-top: 10px;
padding: 9px;
border-radius: 5px;
font-size: 13px;
}
.ok {
background: #e3fcec;
color: #0f5132;
}
.warn {
background: #fff8e1;
color: #7a4d00;
}
.bad {
background: #fdecea;
color: #842029;
}
.small {
font-size: 12px;
color: #627d98;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.legend-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
}
.legend-box {
width: 18px;
height: 18px;
border-radius: 4px;
border: 1px solid #52606d;
}
.map-scroll {
overflow-x: auto;
padding-bottom: 8px;
}
.room-title {
margin-top: 24px;
margin-bottom: 10px;
border-bottom: 2px solid #bcccdc;
padding-bottom: 5px;
}
.rack {
background: #f8fafc;
border: 1px solid #bcccdc;
border-radius: 8px;
padding: 12px;
margin-bottom: 16px;
min-width: max-content;
}
.rack-title {
font-weight: bold;
margin-bottom: 10px;
font-size: 18px;
}
.shelf {
display: flex;
align-items: stretch;
gap: 5px;
margin-bottom: 5px;
}
.shelf-label {
width: 78px;
min-width: 78px;
background: #d9e2ec;
border: 1px solid #9fb3c8;
border-radius: 5px;
padding: 8px 5px;
font-size: 12px;
font-weight: bold;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
}
.tile {
width: 122px;
min-width: 122px;
min-height: 68px;
border-radius: 6px;
border: 1px solid #334e68;
padding: 6px;
box-sizing: border-box;
font-size: 11px;
overflow-wrap: anywhere;
cursor: default;
color: #102a43;
}
.tile.clickable {
cursor: pointer;
}
.tile.clickable:hover {
outline: 3px solid #0b63ce;
}
.move-mode-on .tile {
cursor: crosshair;
}
.move-mode-on .tile.clickable {
cursor: grab;
}
.tile.move-selected {
outline: 4px solid #7c3aed;
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.25);
}
.tile.move-target-ok {
outline: 3px dashed #16a34a;
}
.tile.move-target-bad {
outline: 3px dashed #dc2626;
}
.danger {
border-color: #b91c1c;
color: #991b1b;
background: #fee2e2;
}
.tile .mac {
font-weight: bold;
font-size: 11px;
line-height: 1.2;
}
.tile .sub {
font-size: 10px;
margin-top: 4px;
line-height: 1.2;
}
.tile-empty {
background: #edf2f7;
color: #52606d;
border-color: #cbd5e1;
}
.tile-switch {
background: #dbeafe;
color: #1e3a8a;
border-color: #2563eb;
font-weight: bold;
}
.tile-blocked {
background: #334155;
color: white;
border-color: #111827;
}
.tile-conflict {
background: #e9d5ff;
color: #581c87;
border-color: #7e22ce;
}
.tile-offline {
background: #fecaca;
border-color: #dc2626;
}
.tile-not-hashing {
background: #fed7aa;
border-color: #ea580c;
}
.tile-error-hashing {
background: #fef08a;
border-color: #ca8a04;
}
.tile-good {
background: #bbf7d0;
border-color: #16a34a;
}
.tile-temp-cool {
background: #bbf7d0;
border-color: #16a34a;
}
.tile-temp-warm {
background: #fef08a;
border-color: #ca8a04;
}
.tile-temp-hot {
background: #fed7aa;
border-color: #ea580c;
}
.tile-temp-critical {
background: #fecaca;
border-color: #dc2626;
}
.virtual-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 10px;
}
.virtual-card {
border: 1px solid #bcccdc;
background: #f8fafc;
border-radius: 6px;
padding: 10px;
font-size: 12px;
}
.virtual-card a {
font-weight: bold;
color: #0b63ce;
text-decoration: none;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th, td {
border-bottom: 1px solid #d9e2ec;
padding: 7px;
text-align: left;
vertical-align: top;
}
th {
background: #f1f5f9;
}
/* Step 71.2: stronger compact tile mode */
body.compact-tiles .tile {
width: 74px !important;
min-width: 74px !important;
min-height: 42px !important;
padding: 3px !important;
font-size: 8px !important;
}
body.compact-tiles .tile .mac {
font-size: 8px !important;
line-height: 1.05 !important;
}
body.compact-tiles .tile .sub {
font-size: 7px !important;
margin-top: 1px !important;
line-height: 1.05 !important;
}
body.compact-tiles .shelf-label {
width: 52px !important;
min-width: 52px !important;
padding: 4px 2px !important;
font-size: 8px !important;
}
body.compact-tiles .shelf {
gap: 2px !important;
margin-bottom: 2px !important;
}
body.compact-tiles .rack {
padding: 8px !important;
}
/* Step 72: Rack / Room Heatmap Summary */
.heatmap-table th {
position: sticky;
top: 0;
z-index: 1;
}
.heatmap-rank-good {
background: #dcfce7;
}
.heatmap-rank-warn {
background: #fef9c3;
}
.heatmap-rank-hot {
background: #ffedd5;
}
.heatmap-rank-bad {
background: #fee2e2;
}
.heatmap-score {
font-weight: bold;
}
.heatmap-pill {
display: inline-block;
padding: 2px 6px;
border-radius: 999px;
background: #e2e8f0;
font-size: 12px;
font-weight: bold;
}
/* Step 73: interactive heatmap */
.heatmap-summary-row {
cursor: pointer;
}
.heatmap-summary-row:hover {
outline: 3px solid #2563eb;
}
.heatmap-selected-row {
outline: 4px solid #7c3aed !important;
box-shadow: inset 0 0 0 2px #7c3aed;
}
.tile.heatmap-selected-tile {
box-shadow: 0 0 0 4px rgba(124, 58, 237, 0.35);
}
.heatmap-action-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-top: 10px;
}
.heatmap-filter-status {
font-size: 13px;
font-weight: bold;
color: #334155;
}
/* Step 74: Issue Drill-Down */
.issue-pill {
display: inline-block;
padding: 2px 6px;
border-radius: 999px;
font-size: 11px;
font-weight: bold;
margin: 1px;
background: #e2e8f0;
}
.issue-hot {
background: #fed7aa;
color: #7c2d12;
}
.issue-offline {
background: #fecaca;
color: #7f1d1d;
}
.issue-not-hashing {
background: #fde68a;
color: #78350f;
}
.issue-error-hashing {
background: #fef08a;
color: #713f12;
}
.issue-duplicate {
background: #e9d5ff;
color: #581c87;
}
.issue-table tr:hover {
background: #eff6ff;
}
.issue-action-button {
padding: 5px 7px;
font-size: 12px;
margin: 1px;
}
/* Step 75: Collapsible cards */
.card.collapsible > h2 {
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 0;
}
.card.collapsible > h2:hover {
color: #0b63ce;
}
.card.collapsible > h2::after {
content: "Collapse";
font-size: 12px;
font-weight: normal;
color: #52606d;
border: 1px solid #bcccdc;
border-radius: 999px;
padding: 3px 8px;
background: #f8fafc;
}
.card.collapsible.collapsed > h2::after {
content: "Expand";
}
.card.collapsible.collapsed > *:not(h2) {
display: none !important;
}
.card.always-expanded > h2::after {
content: "Always expanded";
font-size: 12px;
font-weight: normal;
color: #52606d;
border: 1px solid #bcccdc;
border-radius: 999px;
padding: 3px 8px;
background: #f8fafc;
}
.card-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
/* Step 76: Sticky mini toolbar */
.sticky-mini-toolbar {
position: sticky;
top: 0;
z-index: 50;
background: rgba(244, 247, 251, 0.96);
border: 1px solid #d9e2ec;
border-radius: 8px;
padding: 8px;
margin-bottom: 16px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.12);
backdrop-filter: blur(4px);
}
.sticky-mini-toolbar button {
padding: 7px 9px;
font-size: 12px;
}
.sticky-mini-toolbar .toolbar-label {
font-size: 12px;
font-weight: bold;
color: #334155;
margin-right: 4px;
}
/* Step 79: Unified app look */
:root {{
--app-bg: #f4f7fb;
--app-card: #ffffff;
--app-border: #d9e2ec;
--app-text: #102a43;
--app-muted: #52606d;
--app-blue: #0b63ce;
--app-blue-dark: #084b9a;
--app-green: #0f766e;
--app-yellow-bg: #fff7ed;
--app-yellow-border: #fdba74;
--app-red-bg: #fef2f2;
--app-red-border: #fca5a5;
--app-green-bg: #ecfdf5;
--app-green-border: #6ee7b7;
}}
body {{
background: var(--app-bg) !important;
color: var(--app-text);
font-family: Segoe UI, Arial, sans-serif;
margin: 0;
padding: 24px;
}}
.topbar {{
display: none !important;
}}
#dashboardLocationMapLink {{
display: none !important;
}}
.app-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);
}}
.app-brand h1 {{
margin: 0;
color: white;
font-size: 28px;
line-height: 1.1;
}}
.app-kicker {{
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.85;
margin-bottom: 4px;
}}
.app-nav {{
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
align-items: center;
}}
.app-nav-link,
.app-nav-button {{
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: 700;
font-size: 13px;
text-decoration: none;
cursor: pointer;
}}
.app-nav-link:hover,
.app-nav-button:hover {{
background: rgba(255, 255, 255, 0.24);
}}
.app-nav-link.active {{
background: white;
color: var(--app-blue) !important;
}}
.card {{
background: var(--app-card) !important;
border: 1px solid var(--app-border) !important;
border-radius: 14px !important;
padding: 16px !important;
margin-bottom: 16px !important;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
}}
.card h2 {{
color: var(--app-text);
margin-top: 0;
}}
.small,
.muted {{
color: var(--app-muted);
}}
button,
input[type="button"],
input[type="submit"] {{
background: var(--app-blue);
color: white;
border: 0;
border-radius: 8px;
padding: 8px 11px;
font-weight: 700;
cursor: pointer;
}}
button:hover,
input[type="button"]:hover,
input[type="submit"]:hover {{
background: var(--app-blue-dark);
}}
button.secondary {{
background: #64748b;
}}
button.danger,
.danger {{
background: #dc2626 !important;
color: white !important;
}}
input,
select,
textarea {{
border: 1px solid #bcccdc;
border-radius: 8px;
padding: 8px;
background: white;
color: var(--app-text);
}}
table {{
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 10px;
overflow: hidden;
}}
th {{
background: #eef2ff;
color: #1e293b;
text-align: left;
font-size: 13px;
}}
th,
td {{
border-bottom: 1px solid #e2e8f0;
padding: 8px;
vertical-align: top;
}}
tr:hover td {{
background: #f8fafc;
}}
.status {{
border-radius: 10px;
padding: 10px 12px;
margin-top: 10px;
border: 1px solid var(--app-border);
background: #f8fafc;
}}
.status.ok {{
background: var(--app-green-bg);
border-color: var(--app-green-border);
}}
.status.warn {{
background: var(--app-yellow-bg);
border-color: var(--app-yellow-border);
}}
.status.bad {{
background: var(--app-red-bg);
border-color: var(--app-red-border);
}}
.sticky-mini-toolbar {{
border-radius: 14px !important;
border: 1px solid var(--app-border) !important;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12) !important;
}}
@media (max-width: 800px) {{
body {{
padding: 12px;
}}
.app-header {{
align-items: flex-start;
flex-direction: column;
}}
.app-nav {{
justify-content: flex-start;
}}
}}
</style>
<link rel="stylesheet" href="/assets/minerops-unified.css">
</head>
<body>
<header class="minerops-shell-header">
<div>
<div class="minerops-kicker">MinerOps Console</div>
<h1>MinerOps Location Map</h1>
<div class="minerops-subtitle">Physical rack, shelf, position, switch, blocked slot, and miner state map.</div>
</div>
<nav class="minerops-nav">
<a class="minerops-nav-link" href="/dashboard">Dashboard</a>
<a class="minerops-nav-link active" href="/locations">Location Map</a>
<a class="minerops-nav-link" href="javascript:window.location.reload()">Refresh</a>
</nav>
</header>
<div class="topbar">
<div>
<h1>MinerOps Location Map</h1>
<div class="small">Physical rack, shelf, position, switch, blocked slot, and miner state map.</div>
</div>
<div>
<a href="/dashboard">Dashboard</a>
</div>
</div>
<div class="card-toolbar">
<button onclick="expandAllCards()">Expand All</button>
<button onclick="collapseAllCards()">Collapse All Except Map</button>
<button onclick="expandTroubleshootingCards()">Troubleshooting View</button>
<button onclick="expandManagementCards()">Management View</button>
</div>
<div class="card">
<h2>Login / Refresh</h2>
<div class="controls">
<div class="field">
<div class="label">Username</div>
<input id="username" value="admin">
</div>
<div class="field">
<div class="label">Password</div>
<input id="password" type="password">
</div>
<div>
<button onclick="login()">Login</button>
<button onclick="refreshAll()">Refresh Map</button>
<button onclick="clearToken()">Clear Token</button>
</div>
</div>
<div id="loginStatus" class="status warn">Saved token will be used if available.</div>
</div>
<div class="card">
<h2>Map Controls</h2>
<div class="controls">
<div class="field">
<div class="label">Unit / Area</div>
<select id="roomFilter" onchange="renderAll()">
<option value="">All Units</option>
</select>
</div>
<div class="field">
<div class="label">View Mode</div>
<select id="viewMode" onchange="renderAll()">
<option value="health">Health status</option>
<option value="temperature">Temperature</option>
<option value="plain">Plain layout</option>
</select>
</div>
<div class="field">
<div class="label">Search</div>
<input id="searchBox" type="text" placeholder="MAC, IP, rack, serial..." oninput="renderAll()">
</div>
<div class="field">
<div class="label">Status Filter</div>
<select id="statusFilter" onchange="renderAll()">
<option value="all">All positions</option>
<option value="problems">Problems only</option>
<option value="hot">Hot miners</option>
<option value="offline">Offline / stale</option>
<option value="not_hashing">Not hashing</option>
<option value="error_hashing">Errored but hashing</option>
<option value="duplicate">Duplicate / conflict</option>
<option value="empty">Empty slots</option>
</select>
</div>
<div class="field">
<div class="label">Tile Size</div>
<select id="tileSizeMode" onchange="setTileSizeMode();
setupCollapsibleCards(); renderAll();">
<option value="comfortable">Comfortable</option>
<option value="compact">Compact</option>
</select>
</div>
<div class="field">
<div class="label">Problem Toggle</div>
<label class="small">
<input id="problemOnlyToggle" type="checkbox" style="width: auto;" onchange="renderAll()">
Show only problem miners
</label>
</div>
</div>
<div id="mapResultCount" class="small" style="margin-top: 10px;">No map results loaded yet.</div>
<div class="legend" id="legend"></div>
<div id="mapStatus" class="status warn">Map not loaded yet.</div>
<div style="margin-top: 12px; border-top: 1px solid #d9e2ec; padding-top: 12px;">
<h3>Direct Map Move Mode</h3>
<div class="controls">
<div class="field">
<div class="label">Move Mode</div>
<label class="small">
<input id="directMoveMode" type="checkbox" style="width: auto;" onchange="toggleDirectMoveMode()">
Enable click-to-move on map
</label>
</div>
<div class="field">
<div class="label">Selected Miner</div>
<input id="directMoveSelectedLabel" type="text" value="None selected" readonly>
</div>
<div>
<button onclick="clearDirectMoveSelection()">Clear Selection</button>
</div>
</div>
<div id="directMoveStatus" class="status warn">Move Mode is off. Normal clicks open miner detail.</div>
</div>
</div>
<div class="card">
<h2>Location Import Issues</h2>
<div id="importIssuesBody"></div>
</div>
<div class="card" id="heatmapSummaryCard">
<h2>Rack / Room Heatmap Summary</h2>
<div class="small">Quickly find the hottest racks, offline clusters, not-hashing miners, and lowest efficiency areas.</div>
<div class="controls" style="margin-top: 10px;">
<div class="field">
<div class="label">Group By</div>
<select id="heatmapGroupBy" onchange="renderHeatmapSummary()">
<option value="rack">Rack</option>
<option value="room">Room / Unit</option>
</select>
</div>
<div class="field">
<div class="label">Sort By</div>
<select id="heatmapSortBy" onchange="renderHeatmapSummary()">
<option value="problem_score">Problem score</option>
<option value="max_temp">Max temp</option>
<option value="avg_temp">Average temp</option>
<option value="hot_count">Hot count</option>
<option value="offline_count">Offline/stale count</option>
<option value="not_hashing_count">Not hashing count</option>
<option value="total_hashrate">Total TH/s</option>
<option value="total_power">Total watts</option>
<option value="efficiency">Efficiency W/TH</option>
</select>
</div>
<div class="field">
<div class="label">Scope</div>
<label class="small">
<input id="heatmapRespectFilters" type="checkbox" style="width: auto;" onchange="renderHeatmapSummary()">
Respect current map filters/search
</label>
</div>
</div>
<div class="heatmap-action-row">
<button onclick="showWorstHeatmapGroup()">Show Worst Rack/Room Only</button>
<button onclick="clearHeatmapFilter()">Clear Heatmap Filter</button>
<button onclick="setHeatmapStatusFilter('hot')">Show Hot</button>
<button onclick="setHeatmapStatusFilter('offline')">Show Offline/Stale</button>
<button onclick="setHeatmapStatusFilter('not_hashing')">Show Not Hashing</button>
<span id="heatmapActiveFilter" class="heatmap-filter-status">Showing all racks/rooms.</span>
</div>
<div id="heatmapSummaryBody" style="margin-top: 12px;"></div>
</div>
<div class="card" id="issueDrilldownCard">
<h2>Issue Drill-Down</h2>
<div class="small">Actionable list of miners matching heat, offline, hashing, and duplicate/conflict issues.</div>
<div class="controls" style="margin-top: 10px;">
<div class="field">
<div class="label">Issue Type</div>
<select id="issueDrilldownType" onchange="renderIssueDrilldown()">
<option value="all">All issues</option>
<option value="hot">Hot miners</option>
<option value="offline">Offline / stale</option>
<option value="not_hashing">Not hashing</option>
<option value="error_hashing">Errored but hashing</option>
<option value="duplicate">Duplicate / conflict</option>
</select>
</div>
<div class="field">
<div class="label">Scope</div>
<label class="small">
<input id="issueRespectFilters" type="checkbox" checked style="width: auto;" onchange="renderIssueDrilldown()">
Respect current map filters/search/heatmap
</label>
</div>
<div>
<button onclick="copyIssueDrilldownCsv()">Copy CSV</button>
</div>
</div>
<div id="issueDrilldownStatus" class="status warn">Issue list not loaded yet.</div>
<div id="issueDrilldownBody" style="margin-top: 12px;"></div>
</div>
<div class="card">
<h2>Physical Map</h2>
<div id="mapBody" class="map-scroll"></div>
</div>
<div class="card">
<h2>Waiting / Storage / Repair / Decommissioned</h2>
<div id="virtualBody"></div>
</div>
<div class="card">
<h2>Map Management</h2>
<div class="small">Add rooms, racks, shelves, and mark positions as miner slots, switches, or blocked spots.</div>
<div id="mapManagementStatus" class="status warn">Map management controls ready.</div>
<h3>Import Layout Spreadsheet</h3>
<div class="small">
Upload an .xlsx file laid out like your Unit 4 / Unit 6 / Unit 12 spreadsheets.
MAC addresses can be entered without colons.
</div>
<div class="controls" style="margin-top: 10px;">
<div class="field">
<div class="label">Spreadsheet File</div>
<input id="layoutSpreadsheetFile" type="file" accept=".xlsx">
</div>
<div class="field">
<div class="label">Room Override</div>
<select id="layoutSpreadsheetRoomOverride">
<option value="">Use room name from spreadsheet</option>
</select>
</div>
<div>
<button onclick="importLayoutSpreadsheet()">Import Spreadsheet</button>
</div>
</div>
<h3>Add Unit / Room</h3>
<div class="controls">
<div class="field">
<div class="label">New Unit / Room Name</div>
<input id="newRoomName" type="text" placeholder="Example: Unit 14">
</div>
<div>
<button onclick="createLocationRoom()">Add Unit / Room</button>
</div>
</div>
<h3>Add Rack With Shelves</h3>
<div class="controls">
<div class="field">
<div class="label">Unit / Room</div>
<select id="rackRoomName" onchange="populateManagementSelects()"></select>
</div>
<div class="field">
<div class="label">Rack Name</div>
<input id="newRackName" type="text" placeholder="Example: Rack 12E">
</div>
<div class="field">
<div class="label">Shelf Count</div>
<input id="rackShelfCount" type="number" min="1" max="20" value="5">
</div>
<div class="field">
<div class="label">Positions Per Shelf</div>
<input id="rackPositionsPerShelf" type="number" min="1" max="50" value="9">
</div>
<div class="field">
<div class="label">Switch Shelf #</div>
<input id="rackSwitchShelfIndex" type="number" min="1" placeholder="Optional">
</div>
<div class="field">
<div class="label">Switch Position #</div>
<input id="rackSwitchPositionIndex" type="number" min="1" placeholder="Optional">
</div>
<div>
<button onclick="createLocationRack()">Add Rack</button>
</div>
</div>
<h3>Add Shelf to Existing Rack</h3>
<div class="controls">
<div class="field">
<div class="label">Unit / Room</div>
<select id="shelfRoomName" onchange="populateManagementSelects()"></select>
</div>
<div class="field">
<div class="label">Rack</div>
<select id="shelfRackName"></select>
</div>
<div class="field">
<div class="label">Shelf Name</div>
<input id="newShelfName" type="text" placeholder="Leave blank for shelf#">
</div>
<div class="field">
<div class="label">Shelf Index</div>
<input id="newShelfIndex" type="number" min="1" placeholder="Optional">
</div>
<div class="field">
<div class="label">Positions Per Shelf</div>
<input id="shelfPositionsPerShelf" type="number" min="1" max="50" value="9">
</div>
<div>
<button onclick="createLocationShelf()">Add Shelf</button>
</div>
</div>
<h3>Set Position Type</h3>
<div class="controls">
<div class="field">
<div class="label">Unit / Room</div>
<select id="positionRoomName" onchange="populateManagementSelects()"></select>
</div>
<div class="field">
<div class="label">Rack</div>
<select id="positionRackName" onchange="populateManagementSelects()"></select>
</div>
<div class="field">
<div class="label">Shelf</div>
<select id="positionShelfName"></select>
</div>
<div class="field">
<div class="label">Position #</div>
<input id="positionIndex" type="number" min="1" value="1">
</div>
<div class="field">
<div class="label">Type</div>
<select id="positionKind">
<option value="miner_slot">Miner slot / unblocked</option>
<option value="switch">Switch</option>
<option value="blocked">Blocked / bad spot</option>
</select>
</div>
<div class="field">
<div class="label">Label</div>
<input id="positionEquipmentLabel" type="text" placeholder="Example: Switch 12A">
</div>
<div class="field">
<div class="label">Notes</div>
<input id="positionNotes" type="text" placeholder="Optional notes">
</div>
<div>
<button onclick="setLocationPositionType()">Save Position Type</button>
</div>
</div>
<h3>Move Miner / Change Location</h3>
<div class="small">
Move a miner to a physical slot or to Waiting, Storage, Repair, or Decommissioned.
Occupied, switch, and blocked target positions are protected.
</div>
<div class="controls" style="margin-top: 10px;">
<div class="field">
<div class="label">Miner</div>
<select id="moveMinerId"></select>
</div>
<div class="field">
<div class="label">Destination</div>
<select id="moveDestinationType" onchange="populateMoveSelects()">
<option value="physical">Physical rack position</option>
<option value="waiting_placement">Waiting placement</option>
<option value="storage">Storage</option>
<option value="repair">Repair</option>
<option value="decommissioned">Decommissioned</option>
</select>
</div>
<div class="field">
<div class="label">Target Unit / Room</div>
<select id="moveRoomName" onchange="populateMoveSelects()"></select>
</div>
<div class="field">
<div class="label">Target Rack</div>
<select id="moveRackName" onchange="populateMoveSelects()"></select>
</div>
<div class="field">
<div class="label">Target Shelf</div>
<select id="moveShelfName" onchange="populateMoveSelects()"></select>
</div>
<div class="field">
<div class="label">Target Position</div>
<select id="movePositionIndex"></select>
</div>
<div class="field">
<div class="label">Move Notes</div>
<input id="moveNotes" type="text" placeholder="Optional move notes">
</div>
<div>
<button onclick="moveMinerLocation()">Move Miner</button>
</div>
</div>
<h3>Delete Rack / Room</h3>
<div class="small">Delete is blocked if miners are still assigned. Move miners first, then delete the rack or room.</div>
<div class="controls" style="margin-top: 10px;">
<div class="field">
<div class="label">Room / Unit</div>
<select id="deleteRackRoomName" onchange="populateManagementSelects()"></select>
</div>
<div class="field">
<div class="label">Rack</div>
<select id="deleteRackName"></select>
</div>
<div>
<button class="danger" onclick="deleteLocationRack()">Delete Rack</button>
</div>
</div>
<div class="controls" style="margin-top: 10px;">
<div class="field">
<div class="label">Room / Unit</div>
<select id="deleteRoomName"></select>
</div>
<div class="field">
<div class="label">Room Record</div>
<label class="small">
<input id="deleteRoomRecord" type="checkbox" checked style="width: auto;">
Delete room record if unused
</label>
</div>
<div>
<button class="danger" onclick="deleteLocationRoom()">Delete Room Map</button>
</div>
</div>
</div>
<div class="card">
<h2>Unit / Room Order</h2>
<div class="small">Use these controls to choose the order rooms appear on the location map.</div>
<div id="roomOrderStatus" class="status warn">Room order controls ready.</div>
<div id="roomOrderBody"></div>
</div>
<script>
let token = localStorage.getItem("minerops_token");
let locationMap = {
positions: [],
virtual_miners: []
};
let importIssues = [];
let locationRooms = [];
let directMoveSelectedMinerId = null;
let directMoveSelectedLabel = null;
let heatmapSelectedGroupKey = null;
let heatmapSelectedGroupBy = null;
function escapeHtml(value) {
if (value === null || value === undefined) {
return "";
}
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function fmt(value) {
if (value === null || value === undefined || value === "") {
return "-";
}
return value;
}
function normalizeText(value) {
if (value === null || value === undefined) {
return "";
}
return String(value).toLowerCase();
}
function setLoginStatus(message, className) {
const element = document.getElementById("loginStatus");
element.textContent = message;
element.className = "status " + className;
}
function setMapStatus(message, className) {
const element = document.getElementById("mapStatus");
element.textContent = message;
element.className = "status " + className;
}
async function login() {
try {
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
username: username,
password: password
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || "Login failed");
}
token = result.data.access_token;
localStorage.setItem("minerops_token", token);
setLoginStatus("Login successful.", "ok");
await refreshAll();
} catch (error) {
setLoginStatus("Login error: " + error.message, "bad");
}
}
function clearToken() {
token = null;
localStorage.removeItem("minerops_token");
setLoginStatus("Saved token cleared.", "warn");
}
async function apiGet(path) {
if (!token) {
throw new Error("Missing token. Please log in.");
}
const response = await fetch(path, {
headers: {
"Authorization": "Bearer " + token
}
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || result.error || "API error");
}
return result.data;
}
async function apiPost(path, bodyData) {
if (!token) {
throw new Error("Missing token. Please log in.");
}
const response = await fetch(path, {
method: "POST",
headers: {
"Authorization": "Bearer " + token,
"Content-Type": "application/json"
},
body: JSON.stringify(bodyData)
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || result.error || "API error");
}
return result.data;
}
async function refreshAll() {
try {
setMapStatus("Loading location map...", "warn");
locationRooms = await apiGet("/api/location/rooms-order");
locationMap = await apiGet("/api/location/map");
importIssues = await apiGet("/api/location/import-issues");
populateRoomFilter();
populateManagementSelects();
renderAll();
setupCollapsibleCards();
setMapStatus(
"Loaded " + locationMap.positions.length + " positions and " + locationMap.virtual_miners.length + " virtual/unplaced miners.",
"ok"
);
} catch (error) {
setMapStatus("Map load error: " + error.message, "bad");
}
}
function populateRoomFilter() {
const select = document.getElementById("roomFilter");
const currentValue = select.value;
const rooms = locationRooms.length > 0
? locationRooms.map(room => room.name).filter(Boolean)
: Array.from(new Set(locationMap.positions.map(row => row.room_name).filter(Boolean))).sort();
select.innerHTML = '<option value="">All Units</option>' + rooms.map(room => {
return `<option value="${escapeHtml(room)}">${escapeHtml(room)}</option>`;
}).join("");
if (rooms.includes(currentValue)) {
select.value = currentValue;
}
}
function readValue(id) {
const element = document.getElementById(id);
if (!element) {
return null;
}
const value = element.value.trim();
if (!value) {
return null;
}
return value;
}
function readNumber(id) {
const value = readValue(id);
if (value === null) {
return null;
}
const numberValue = Number(value);
if (Number.isNaN(numberValue)) {
return null;
}
return numberValue;
}
function setMapManagementStatus(message, className) {
const element = document.getElementById("mapManagementStatus");
if (!element) {
return;
}
element.textContent = message;
element.className = "status " + className;
}
function fillSelectPreserveWithBlank(id, values, blankLabel) {
const select = document.getElementById(id);
if (!select) {
return;
}
const currentValue = select.value;
select.innerHTML = `<option value="">${escapeHtml(blankLabel)}</option>` + values.map(value => {
return `<option value="${escapeHtml(value)}">${escapeHtml(value)}</option>`;
}).join("");
if (values.includes(currentValue) || currentValue === "") {
select.value = currentValue;
}
}
function fillSelectPreserve(id, values) {
const select = document.getElementById(id);
if (!select) {
return;
}
const currentValue = select.value;
select.innerHTML = values.map(value => {
return `<option value="${escapeHtml(value)}">${escapeHtml(value)}</option>`;
}).join("");
if (values.includes(currentValue)) {
select.value = currentValue;
}
}
function uniqueSorted(values) {
return Array.from(new Set(values.filter(Boolean))).sort();
}
function fillSelectOptionsPreserve(id, options) {
const select = document.getElementById(id);
if (!select) {
return;
}
const currentValue = select.value;
select.innerHTML = options.map(option => {
return `<option value="${escapeHtml(option.value)}">${escapeHtml(option.label)}</option>`;
}).join("");
if (options.some(option => String(option.value) === String(currentValue))) {
select.value = currentValue;
}
}
function buildMoveMinerOptions() {
const minersById = {};
for (const position of locationMap.positions || []) {
if (!position.miner_id) {
continue;
}
minersById[position.miner_id] = {
value: position.miner_id,
label: [
position.current_mac || position.expected_mac || "No MAC",
position.current_ip || "-",
position.room_name + " / " + position.rack_name + " / " + position.shelf_name + " / Pos " + position.position_index
].join(" | ")
};
}
for (const miner of locationMap.virtual_miners || []) {
if (!miner.miner_id) {
continue;
}
if (!minersById[miner.miner_id]) {
minersById[miner.miner_id] = {
value: miner.miner_id,
label: [
miner.current_mac || "No MAC",
miner.current_ip || "-",
miner.asset_state || "waiting_placement"
].join(" | ")
};
}
}
return Object.values(minersById).sort((a, b) => a.label.localeCompare(b.label));
}
function populateMoveSelects() {
const minerOptions = buildMoveMinerOptions();
fillSelectOptionsPreserve("moveMinerId", minerOptions);
const roomNames = locationRooms.length > 0
? locationRooms.map(room => room.name).filter(Boolean)
: uniqueSorted(locationMap.positions.map(position => position.room_name));
fillSelectPreserve("moveRoomName", roomNames);
const moveRoomName = document.getElementById("moveRoomName")?.value || roomNames[0] || "";
const rackNames = uniqueSorted(
locationMap.positions
.filter(position => position.room_name === moveRoomName)
.map(position => position.rack_name)
);
fillSelectPreserve("moveRackName", rackNames);
const moveRackName = document.getElementById("moveRackName")?.value || rackNames[0] || "";
const shelfObjects = locationMap.positions
.filter(position => position.room_name === moveRoomName && position.rack_name === moveRackName)
.sort((a, b) => Number(a.shelf_index || 0) - Number(b.shelf_index || 0));
const shelfNames = [];
for (const position of shelfObjects) {
if (position.shelf_name && !shelfNames.includes(position.shelf_name)) {
shelfNames.push(position.shelf_name);
}
}
fillSelectPreserve("moveShelfName", shelfNames);
const moveShelfName = document.getElementById("moveShelfName")?.value || shelfNames[0] || "";
const destinationChoice = document.getElementById("moveDestinationType")?.value || "physical";
const physicalFields = [
"moveRoomName",
"moveRackName",
"moveShelfName",
"movePositionIndex"
];
for (const id of physicalFields) {
const element = document.getElementById(id);
if (element) {
element.disabled = destinationChoice !== "physical";
}
}
const positionOptions = locationMap.positions
.filter(position =>
position.room_name === moveRoomName &&
position.rack_name === moveRackName &&
position.shelf_name === moveShelfName &&
position.position_kind === "miner_slot"
)
.sort((a, b) => Number(a.position_index || 0) - Number(b.position_index || 0))
.map(position => {
let label = "Pos " + position.position_index;
if (position.position_status === "blocked") {
label += " - BLOCKED";
} else if (position.position_status === "switch") {
label += " - SWITCH";
} else if (position.miner_id) {
label += " - occupied by " + (position.current_mac || position.expected_mac || "miner");
} else if (position.expected_mac) {
label += " - expected " + position.expected_mac;
} else {
label += " - empty";
}
return {
value: String(position.position_index),
label: label
};
});
fillSelectOptionsPreserve("movePositionIndex", positionOptions);
}
function populateManagementSelects() {
const roomNames = locationRooms.length > 0
? locationRooms.map(room => room.name).filter(Boolean)
: uniqueSorted(locationMap.positions.map(position => position.room_name));
fillSelectPreserveWithBlank("layoutSpreadsheetRoomOverride", roomNames, "Use room name from spreadsheet");
fillSelectPreserve("rackRoomName", roomNames);
fillSelectPreserve("shelfRoomName", roomNames);
fillSelectPreserve("positionRoomName", roomNames);
fillSelectPreserve("deleteRackRoomName", roomNames);
fillSelectPreserve("deleteRoomName", roomNames);
const shelfRoomName = document.getElementById("shelfRoomName")?.value || roomNames[0] || "";
const positionRoomName = document.getElementById("positionRoomName")?.value || roomNames[0] || "";
const shelfRackNames = uniqueSorted(
locationMap.positions
.filter(position => position.room_name === shelfRoomName)
.map(position => position.rack_name)
);
const positionRackNames = uniqueSorted(
locationMap.positions
.filter(position => position.room_name === positionRoomName)
.map(position => position.rack_name)
);
fillSelectPreserve("shelfRackName", shelfRackNames);
fillSelectPreserve("positionRackName", positionRackNames);
const deleteRackRoomName = document.getElementById("deleteRackRoomName")?.value || roomNames[0] || "";
const deleteRackNames = uniqueSorted(
locationMap.positions
.filter(position => position.room_name === deleteRackRoomName)
.map(position => position.rack_name)
);
fillSelectPreserve("deleteRackName", deleteRackNames);
populateMoveSelects();
const positionRackName = document.getElementById("positionRackName")?.value || positionRackNames[0] || "";
const shelfNames = locationMap.positions
.filter(position => position.room_name === positionRoomName && position.rack_name === positionRackName)
.sort((a, b) => Number(a.shelf_index || 0) - Number(b.shelf_index || 0))
.map(position => position.shelf_name);
fillSelectPreserve("positionShelfName", uniqueSorted(shelfNames));
}
function readFileAsBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = String(reader.result || "");
const base64 = result.includes(",") ? result.split(",")[1] : result;
resolve(base64);
};
reader.onerror = () => {
reject(new Error("Could not read file."));
};
reader.readAsDataURL(file);
});
}
async function importLayoutSpreadsheet() {
try {
const fileInput = document.getElementById("layoutSpreadsheetFile");
if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
setMapManagementStatus("Choose an .xlsx spreadsheet first.", "bad");
return;
}
const file = fileInput.files[0];
if (!file.name.toLowerCase().endsWith(".xlsx")) {
setMapManagementStatus("Only .xlsx files are supported.", "bad");
return;
}
const confirmed = confirm(
"Import " + file.name + " into the location map? Existing matching positions will be updated."
);
if (!confirmed) {
return;
}
setMapManagementStatus("Reading spreadsheet...", "warn");
const fileBase64 = await readFileAsBase64(file);
const roomOverride = readValue("layoutSpreadsheetRoomOverride");
setMapManagementStatus("Importing spreadsheet layout...", "warn");
const result = await apiPost("/api/location/import-spreadsheet", {
file_name: file.name,
file_base64: fileBase64,
room_name_override: roomOverride
});
let message = "Spreadsheet imported. ";
if (result.counts) {
message +=
"Positions: " + result.counts.positions_upserted +
", miners: " + result.counts.miner_positions +
", placeholders created: " + result.counts.placeholder_miners_created +
", duplicate conflicts: " + result.counts.duplicate_conflicts + ".";
}
if (result.spreadsheet_parse && result.spreadsheet_parse.parse_warnings && result.spreadsheet_parse.parse_warnings.length > 0) {
message += " Warnings: " + result.spreadsheet_parse.parse_warnings.length + ".";
}
setMapManagementStatus(message, "ok");
fileInput.value = "";
await refreshAll();
} catch (error) {
setMapManagementStatus("Spreadsheet import error: " + error.message, "bad");
}
}
async function createLocationRoom() {
try {
const name = readValue("newRoomName");
if (!name) {
setMapManagementStatus("Enter a room/unit name.", "bad");
return;
}
setMapManagementStatus("Creating room/unit...", "warn");
const result = await apiPost("/api/location/rooms/create", {
name: name
});
document.getElementById("newRoomName").value = "";
setMapManagementStatus(
result.created ? "Room/unit created." : "Room/unit already existed.",
"ok"
);
await refreshAll();
} catch (error) {
setMapManagementStatus("Create room error: " + error.message, "bad");
}
}
async function createLocationRack() {
try {
const roomName = readValue("rackRoomName");
const rackName = readValue("newRackName");
if (!roomName || !rackName) {
setMapManagementStatus("Select a room and enter a rack name.", "bad");
return;
}
setMapManagementStatus("Creating rack...", "warn");
const result = await apiPost("/api/location/racks/create", {
room_name: roomName,
rack_name: rackName,
shelf_count: readNumber("rackShelfCount") || 5,
positions_per_shelf: readNumber("rackPositionsPerShelf") || 9,
switch_shelf_index: readNumber("rackSwitchShelfIndex"),
switch_position_index: readNumber("rackSwitchPositionIndex")
});
setMapManagementStatus(
"Rack saved. Created " + result.created_positions + " positions. Skipped " + result.skipped_existing_positions + " existing positions.",
"ok"
);
document.getElementById("newRackName").value = "";
document.getElementById("rackSwitchShelfIndex").value = "";
document.getElementById("rackSwitchPositionIndex").value = "";
await refreshAll();
} catch (error) {
setMapManagementStatus("Create rack error: " + error.message, "bad");
}
}
async function createLocationShelf() {
try {
const roomName = readValue("shelfRoomName");
const rackName = readValue("shelfRackName");
if (!roomName || !rackName) {
setMapManagementStatus("Select a room and rack.", "bad");
return;
}
setMapManagementStatus("Creating shelf...", "warn");
const result = await apiPost("/api/location/shelves/create", {
room_name: roomName,
rack_name: rackName,
shelf_name: readValue("newShelfName"),
shelf_index: readNumber("newShelfIndex"),
positions_per_shelf: readNumber("shelfPositionsPerShelf") || 9
});
setMapManagementStatus(
"Shelf saved. Created " + result.created_positions + " positions. Skipped " + result.skipped_existing_positions + " existing positions.",
"ok"
);
document.getElementById("newShelfName").value = "";
document.getElementById("newShelfIndex").value = "";
await refreshAll();
} catch (error) {
setMapManagementStatus("Create shelf error: " + error.message, "bad");
}
}
async function setLocationPositionType() {
try {
const roomName = readValue("positionRoomName");
const rackName = readValue("positionRackName");
const shelfName = readValue("positionShelfName");
const positionIndex = readNumber("positionIndex");
const positionKind = readValue("positionKind");
if (!roomName || !rackName || !shelfName || !positionIndex || !positionKind) {
setMapManagementStatus("Select room, rack, shelf, position, and type.", "bad");
return;
}
setMapManagementStatus("Saving position type...", "warn");
const result = await apiPost("/api/location/positions/set-type", {
room_name: roomName,
rack_name: rackName,
shelf_name: shelfName,
position_index: positionIndex,
position_kind: positionKind,
equipment_label: readValue("positionEquipmentLabel"),
notes: readValue("positionNotes")
});
setMapManagementStatus(
"Position saved as " + result.position_kind + " / " + result.position_status + ".",
"ok"
);
await refreshAll();
} catch (error) {
setMapManagementStatus("Position type error: " + error.message, "bad");
}
}
async function moveMinerLocation() {
try {
const minerId = readValue("moveMinerId");
const destinationChoice = readValue("moveDestinationType") || "physical";
if (!minerId) {
setMapManagementStatus("Select a miner to move.", "bad");
return;
}
const bodyData = {
miner_id: minerId,
notes: readValue("moveNotes")
};
let confirmationText = "";
if (destinationChoice === "physical") {
const roomName = readValue("moveRoomName");
const rackName = readValue("moveRackName");
const shelfName = readValue("moveShelfName");
const positionIndex = readNumber("movePositionIndex");
if (!roomName || !rackName || !shelfName || !positionIndex) {
setMapManagementStatus("Select target room, rack, shelf, and position.", "bad");
return;
}
bodyData.destination_type = "physical";
bodyData.room_name = roomName;
bodyData.rack_name = rackName;
bodyData.shelf_name = shelfName;
bodyData.position_index = positionIndex;
confirmationText = "Move selected miner to " + roomName + " / " + rackName + " / " + shelfName + " / Pos " + positionIndex + "?";
} else {
bodyData.destination_type = "virtual";
bodyData.asset_state = destinationChoice;
confirmationText = "Move selected miner to " + destinationChoice + "?";
}
const confirmed = confirm(confirmationText);
if (!confirmed) {
return;
}
setMapManagementStatus("Moving miner...", "warn");
const result = await apiPost("/api/location/miners/move", bodyData);
setMapManagementStatus(
"Moved " + (result.current_mac || result.miner_id) + " to " + result.new_location + ".",
"ok"
);
document.getElementById("moveNotes").value = "";
await refreshAll();
} catch (error) {
setMapManagementStatus("Move miner error: " + error.message, "bad");
}
}
async function deleteLocationRack() {
try {
const roomName = readValue("deleteRackRoomName");
const rackName = readValue("deleteRackName");
if (!roomName || !rackName) {
setMapManagementStatus("Select a room and rack to delete.", "bad");
return;
}
const confirmed = confirm(
"Delete rack " + rackName + " from " + roomName + "? This is blocked if miners are still assigned."
);
if (!confirmed) {
return;
}
setMapManagementStatus("Deleting rack...", "warn");
const result = await apiPost("/api/location/racks/delete", {
room_name: roomName,
rack_name: rackName,
allow_delete_occupied: false
});
setMapManagementStatus(
"Deleted rack " + result.rack_name + ". Removed " + result.deleted_positions + " positions.",
"ok"
);
await refreshAll();
} catch (error) {
setMapManagementStatus("Delete rack error: " + error.message, "bad");
}
}
async function deleteLocationRoom() {
try {
const roomName = readValue("deleteRoomName");
if (!roomName) {
setMapManagementStatus("Select a room to delete.", "bad");
return;
}
const confirmed = confirm(
"Delete the map for " + roomName + "? This is blocked if miners are still assigned."
);
if (!confirmed) {
return;
}
setMapManagementStatus("Deleting room map...", "warn");
const result = await apiPost("/api/location/rooms/delete", {
room_name: roomName,
allow_delete_occupied: false,
delete_room_record: document.getElementById("deleteRoomRecord")?.checked === true
});
let message = "Deleted room map for " + result.room_name + ". Removed " + result.deleted_positions + " positions.";
if (result.room_record_deleted) {
message += " Room record was also deleted.";
}
if (result.room_delete_reason) {
message += " " + result.room_delete_reason;
}
setMapManagementStatus(message, "ok");
await refreshAll();
} catch (error) {
setMapManagementStatus("Delete room error: " + error.message, "bad");
}
}
function renderHeavyLocationSectionsDeferred() {
setTimeout(() => {
try {
if (typeof renderHeatmapSummary === "function") {
renderHeatmapSummary();
}
if (typeof renderIssueDrilldown === "function") {
renderIssueDrilldown();
}
if (typeof renderVirtualMiners === "function") {
renderVirtualMiners();
}
if (typeof renderImportIssues === "function") {
renderImportIssues();
}
if (typeof renderRoomOrder === "function") {
renderRoomOrder();
}
if (typeof populateMoveSelects === "function") {
populateMoveSelects();
}
} catch (error) {
console.warn("Deferred location render warning:", error);
}
}, 50);
}
function renderAll() {
// Fast first paint: draw the physical map immediately.
if (typeof renderLegend === "function") {
renderLegend();
}
if (typeof renderMap === "function") {
renderMap();
}
// Heavy cards render after the map is visible.
renderHeavyLocationSectionsDeferred();
if (typeof updateHeatmapActiveFilter === "function") {
updateHeatmapActiveFilter();
}
if (typeof setTileSizeMode === "function") {
setTileSizeMode();
}
}
function renderLegend() {
const viewMode = document.getElementById("viewMode").value;
const legend = document.getElementById("legend");
if (viewMode === "temperature") {
legend.innerHTML = `
<div class="legend-item"><span class="legend-box tile-temp-cool"></span>Cool / under 75 C</div>
<div class="legend-item"><span class="legend-box tile-temp-warm"></span>Warm / 75 to 84.9 C</div>
<div class="legend-item"><span class="legend-box tile-temp-hot"></span>Hot / 85 to 89.9 C</div>
<div class="legend-item"><span class="legend-box tile-temp-critical"></span>Very hot / 90 C plus or offline</div>
<div class="legend-item"><span class="legend-box tile-switch"></span>Switch</div>
<div class="legend-item"><span class="legend-box tile-blocked"></span>Blocked</div>
<div class="legend-item"><span class="legend-box tile-conflict"></span>Duplicate/conflict</div>
`;
return;
}
if (viewMode === "plain") {
legend.innerHTML = `
<div class="legend-item"><span class="legend-box tile-good"></span>Miner position</div>
<div class="legend-item"><span class="legend-box tile-empty"></span>Empty</div>
<div class="legend-item"><span class="legend-box tile-switch"></span>Switch</div>
<div class="legend-item"><span class="legend-box tile-blocked"></span>Blocked</div>
<div class="legend-item"><span class="legend-box tile-conflict"></span>Duplicate/conflict</div>
`;
return;
}
legend.innerHTML = `
<div class="legend-item"><span class="legend-box tile-good"></span>Online, hashing, no major issue</div>
<div class="legend-item"><span class="legend-box tile-error-hashing"></span>Errored but hashing</div>
<div class="legend-item"><span class="legend-box tile-not-hashing"></span>Online but not hashing</div>
<div class="legend-item"><span class="legend-box tile-offline"></span>Offline / stale</div>
<div class="legend-item"><span class="legend-box tile-empty"></span>Empty</div>
<div class="legend-item"><span class="legend-box tile-switch"></span>Switch</div>
<div class="legend-item"><span class="legend-box tile-blocked"></span>Blocked</div>
<div class="legend-item"><span class="legend-box tile-conflict"></span>Duplicate/conflict</div>
`;
}
function setRoomOrderStatus(message, className) {
const element = document.getElementById("roomOrderStatus");
if (!element) {
return;
}
element.textContent = message;
element.className = "status " + className;
}
async function moveRoom(roomId, direction) {
try {
const index = locationRooms.findIndex(room => room.id === roomId);
if (index < 0) {
return;
}
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= locationRooms.length) {
return;
}
const reordered = [...locationRooms];
const temp = reordered[index];
reordered[index] = reordered[newIndex];
reordered[newIndex] = temp;
setRoomOrderStatus("Saving room order...", "warn");
await apiPost("/api/location/rooms/reorder", {
room_ids: reordered.map(room => room.id)
});
locationRooms = await apiGet("/api/location/rooms-order");
locationMap = await apiGet("/api/location/map");
populateRoomFilter();
populateManagementSelects();
renderAll();
setupCollapsibleCards();
setRoomOrderStatus("Room order saved.", "ok");
} catch (error) {
setRoomOrderStatus("Room order error: " + error.message, "bad");
}
}
function renderRoomOrder() {
const body = document.getElementById("roomOrderBody");
if (!body) {
return;
}
if (!locationRooms || locationRooms.length === 0) {
body.innerHTML = '<div class="status warn">No rooms found.</div>';
return;
}
body.innerHTML = `
<table>
<thead>
<tr>
<th>Order</th>
<th>Room / Unit</th>
<th>Positions</th>
<th>Miner Slots</th>
<th>Assigned Miners</th>
<th>Switches</th>
<th>Blocked</th>
<th>Move</th>
</tr>
</thead>
<tbody>
${locationRooms.map((room, index) => `
<tr>
<td>${index + 1}</td>
<td>${escapeHtml(room.name)}</td>
<td>${escapeHtml(room.position_count)}</td>
<td>${escapeHtml(room.miner_slot_count)}</td>
<td>${escapeHtml(room.assigned_miner_count)}</td>
<td>${escapeHtml(room.switch_count)}</td>
<td>${escapeHtml(room.blocked_count)}</td>
<td>
<button onclick="moveRoom('${room.id}', -1)" ${index === 0 ? "disabled" : ""}>Up</button>
<button onclick="moveRoom('${room.id}', 1)" ${index === locationRooms.length - 1 ? "disabled" : ""}>Down</button>
</td>
</tr>
`).join("")}
</tbody>
</table>
`;
}
function renderImportIssues() {
const body = document.getElementById("importIssuesBody");
if (!importIssues || importIssues.length === 0) {
body.innerHTML = '<div class="status ok">No active location import issues.</div>';
return;
}
body.innerHTML = `
<table>
<thead>
<tr>
<th>Severity</th>
<th>Issue</th>
<th>MAC</th>
<th>Unit</th>
<th>Rack</th>
<th>Shelf</th>
<th>Position</th>
<th>Cell</th>
</tr>
</thead>
<tbody>
${importIssues.map(issue => `
<tr>
<td>${escapeHtml(issue.severity)}</td>
<td>${escapeHtml(issue.message)}</td>
<td>${escapeHtml(issue.mac_address)}</td>
<td>${escapeHtml(issue.room_name)}</td>
<td>${escapeHtml(issue.rack_name)}</td>
<td>${escapeHtml(issue.shelf_name)}</td>
<td>${escapeHtml(issue.position_index)}</td>
<td>${escapeHtml(issue.source_cell)}</td>
</tr>
`).join("")}
</tbody>
</table>
`;
}
function positionMatchesSearch(position, search) {
if (!search) {
return true;
}
const haystack = [
position.room_name,
position.rack_name,
position.shelf_name,
position.position_index,
position.position_kind,
position.position_status,
position.expected_mac,
position.current_mac,
position.current_ip,
position.hostname,
position.miner_sn,
position.power_sn,
position.model,
position.miner_type,
position.firmware_version,
position.asset_state,
position.equipment_label,
position.notes,
position.conflict_reason
].join(" ").toLowerCase();
return haystack.includes(search);
}
function isStaleOrOffline(position) {
if (!position.miner_id) {
return false;
}
const status = normalizeText(position.miner_status);
if (status.includes("offline")) {
return true;
}
if (!position.last_seen_at) {
return true;
}
const lastSeen = new Date(position.last_seen_at);
const minutesOld = (Date.now() - lastSeen.getTime()) / 60000;
return minutesOld > 10;
}
function isHashing(position) {
const hashrate = Number(position.hashrate_ths || 0);
return hashrate > 1;
}
function isErroredButHashing(position) {
const status = normalizeText(position.miner_status);
const rejected = Number(position.pool_rejected_percent || 0);
if (!isHashing(position)) {
return false;
}
if (position.hash_stable === false) {
return true;
}
if (rejected >= 1) {
return true;
}
if (status.includes("error") || status.includes("warn") || status.includes("fault")) {
return true;
}
return false;
}
function setTileSizeMode() {
const mode = document.getElementById("tileSizeMode")?.value || "comfortable";
if (mode === "compact") {
document.body.classList.add("compact-tiles");
} else {
document.body.classList.remove("compact-tiles");
}
}
function isHotPosition(position) {
if (!position.miner_id) {
return false;
}
const temp = Number(position.chip_temp_max_c);
if (!Number.isFinite(temp)) {
return false;
}
return temp >= 90;
}
function isDuplicatePosition(position) {
return position.position_status === "conflict" || Boolean(position.conflict_reason);
}
function isEmptyPosition(position) {
return !position.miner_id && !position.expected_mac && position.position_kind === "miner_slot";
}
function isProblemPosition(position) {
if (isDuplicatePosition(position)) {
return true;
}
if (isStaleOrOffline(position)) {
return true;
}
if (position.miner_id && !isHashing(position)) {
return true;
}
if (isErroredButHashing(position)) {
return true;
}
if (isHotPosition(position)) {
return true;
}
return false;
}
function positionMatchesStatusFilter(position) {
const statusFilter = document.getElementById("statusFilter")?.value || "all";
const problemOnly = document.getElementById("problemOnlyToggle")?.checked === true;
if (problemOnly && !isProblemPosition(position)) {
return false;
}
if (statusFilter === "all") {
return true;
}
if (statusFilter === "problems") {
return isProblemPosition(position);
}
if (statusFilter === "hot") {
return isHotPosition(position);
}
if (statusFilter === "offline") {
return isStaleOrOffline(position);
}
if (statusFilter === "not_hashing") {
return position.miner_id && !isStaleOrOffline(position) && !isHashing(position);
}
if (statusFilter === "error_hashing") {
return isErroredButHashing(position);
}
if (statusFilter === "duplicate") {
return isDuplicatePosition(position);
}
if (statusFilter === "empty") {
return isEmptyPosition(position);
}
return true;
}
function modelTextForPosition(position) {
return position.miner_type || position.model || "-";
}
function ipTextForPosition(position) {
return position.current_ip || "-";
}
function tileClassForPosition(position) {
const viewMode = document.getElementById("viewMode").value;
if (position.position_kind === "switch") {
return "tile-switch";
}
if (position.position_kind === "blocked" || position.position_status === "blocked") {
return "tile-blocked";
}
if (position.position_status === "conflict" || position.conflict_reason) {
return "tile-conflict";
}
if (!position.miner_id && !position.expected_mac) {
return "tile-empty";
}
if (viewMode === "plain") {
return "tile-good";
}
if (viewMode === "temperature") {
if (isStaleOrOffline(position)) {
return "tile-temp-critical";
}
const temp = Number(position.chip_temp_max_c);
if (!Number.isFinite(temp)) {
return "tile-offline";
}
if (temp < 75) {
return "tile-temp-cool";
}
if (temp < 85) {
return "tile-temp-warm";
}
if (temp < 90) {
return "tile-temp-hot";
}
return "tile-temp-critical";
}
if (isStaleOrOffline(position)) {
return "tile-offline";
}
if (!isHashing(position)) {
return "tile-not-hashing";
}
if (isErroredButHashing(position)) {
return "tile-error-hashing";
}
return "tile-good";
}
function tileDetailText(position) {
const viewMode = document.getElementById("viewMode").value;
if (position.position_kind === "switch") {
return escapeHtml(position.equipment_label || "Switch");
}
if (position.position_kind === "blocked" || position.position_status === "blocked") {
return escapeHtml(position.notes || position.raw_value || "Blocked");
}
if (!position.miner_id && !position.expected_mac) {
return "Empty";
}
if (position.position_status === "conflict" || position.conflict_reason) {
return "Duplicate/conflict";
}
const temp = position.chip_temp_max_c === null || position.chip_temp_max_c === undefined
? "-"
: String(position.chip_temp_max_c) + " C";
const hashrate = position.hashrate_ths === null || position.hashrate_ths === undefined
? "-"
: String(position.hashrate_ths) + " TH/s";
if (viewMode === "temperature") {
return "Temp: " + temp + "<br>IP: " + escapeHtml(position.current_ip || "-");
}
if (viewMode === "plain") {
return "IP: " + escapeHtml(position.current_ip || "-");
}
if (isStaleOrOffline(position)) {
return "OFFLINE<br>Temp: " + temp;
}
if (!isHashing(position)) {
return "NOT HASHING<br>Temp: " + temp;
}
if (isErroredButHashing(position)) {
return "ERROR / HASHING<br>" + hashrate + "<br>Temp: " + temp;
}
return "OK<br>" + hashrate + "<br>Temp: " + temp;
}
function safeNumber(value) {
const numberValue = Number(value);
if (!Number.isFinite(numberValue)) {
return null;
}
return numberValue;
}
function roundOne(value) {
if (value === null || value === undefined || !Number.isFinite(Number(value))) {
return "-";
}
return Math.round(Number(value) * 10) / 10;
}
function roundTwo(value) {
if (value === null || value === undefined || !Number.isFinite(Number(value))) {
return "-";
}
return Math.round(Number(value) * 100) / 100;
}
function heatmapRankClass(summary) {
if (summary.problem_score >= 12 || summary.max_temp >= 100 || summary.offline_count >= 5) {
return "heatmap-rank-bad";
}
if (summary.problem_score >= 6 || summary.max_temp >= 90 || summary.hot_count >= 3) {
return "heatmap-rank-hot";
}
if (summary.problem_score >= 2 || summary.max_temp >= 85) {
return "heatmap-rank-warn";
}
return "heatmap-rank-good";
}
function buildHeatmapSourcePositions() {
let positions = locationMap.positions || [];
const respectFilters = document.getElementById("heatmapRespectFilters")?.checked === true;
if (!respectFilters) {
return positions;
}
const roomFilter = document.getElementById("roomFilter")?.value || "";
const search = document.getElementById("searchBox")?.value.trim().toLowerCase() || "";
if (roomFilter) {
positions = positions.filter(row => row.room_name === roomFilter);
}
if (search) {
positions = positions.filter(row => positionMatchesSearch(row, search));
}
positions = positions.filter(row => positionMatchesStatusFilter(row));
positions = positions.filter(row => heatmapSelectionMatchesPosition(row));
return positions;
}
function summarizeHeatmapGroup(groupKey, positions) {
const minerPositions = positions.filter(position => position.miner_id);
const temps = minerPositions
.map(position => safeNumber(position.chip_temp_max_c))
.filter(value => value !== null && value > 0);
const hashrates = minerPositions
.map(position => safeNumber(position.hashrate_ths))
.filter(value => value !== null && value > 0);
const powers = minerPositions
.map(position => safeNumber(position.power_w))
.filter(value => value !== null && value > 0);
const totalHashrate = hashrates.reduce((sum, value) => sum + value, 0);
const totalPower = powers.reduce((sum, value) => sum + value, 0);
const hotCount = minerPositions.filter(position => isHotPosition(position)).length;
const offlineCount = minerPositions.filter(position => isStaleOrOffline(position)).length;
const notHashingCount = minerPositions.filter(position => !isStaleOrOffline(position) && !isHashing(position)).length;
const errorHashingCount = minerPositions.filter(position => isErroredButHashing(position)).length;
const duplicateCount = positions.filter(position => isDuplicatePosition(position)).length;
const emptyCount = positions.filter(position => isEmptyPosition(position)).length;
const avgTemp = temps.length > 0 ? temps.reduce((sum, value) => sum + value, 0) / temps.length : null;
const maxTemp = temps.length > 0 ? Math.max(...temps) : null;
const efficiency = totalHashrate > 0 && totalPower > 0 ? totalPower / totalHashrate : null;
const problemScore =
(hotCount * 3) +
(offlineCount * 3) +
(notHashingCount * 2) +
(errorHashingCount * 2) +
(duplicateCount * 2);
return {
group_key: groupKey,
position_count: positions.length,
miner_count: minerPositions.length,
avg_temp: avgTemp,
max_temp: maxTemp,
hot_count: hotCount,
offline_count: offlineCount,
not_hashing_count: notHashingCount,
error_hashing_count: errorHashingCount,
duplicate_count: duplicateCount,
empty_count: emptyCount,
total_hashrate: totalHashrate,
total_power: totalPower,
efficiency: efficiency,
problem_score: problemScore
};
}
function heatmapGroupKeyForPosition(position, groupBy) {
if (groupBy === "room") {
return position.room_name || "Unknown";
}
return (position.room_name || "Unknown") + " / " + (position.rack_name || "Unknown rack");
}
function heatmapSelectionMatchesPosition(position) {
if (!heatmapSelectedGroupKey) {
return true;
}
return heatmapGroupKeyForPosition(position, heatmapSelectedGroupBy || "rack") === heatmapSelectedGroupKey;
}
function updateHeatmapActiveFilter() {
const element = document.getElementById("heatmapActiveFilter");
if (!element) {
return;
}
if (!heatmapSelectedGroupKey) {
element.textContent = "Showing all racks/rooms.";
return;
}
element.textContent = "Physical map filtered to: " + heatmapSelectedGroupKey;
}
function applyHeatmapGroupFilter(groupKey, groupBy) {
heatmapSelectedGroupKey = groupKey;
heatmapSelectedGroupBy = groupBy || document.getElementById("heatmapGroupBy")?.value || "rack";
updateHeatmapActiveFilter();
renderAll();
}
function clearHeatmapFilter() {
heatmapSelectedGroupKey = null;
heatmapSelectedGroupBy = null;
updateHeatmapActiveFilter();
renderAll();
}
function buildHeatmapSummariesForSelection(groupBy) {
const positions = buildHeatmapSourcePositions();
const groups = {};
for (const position of positions) {
const key = heatmapGroupKeyForPosition(position, groupBy);
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(position);
}
return Object.keys(groups).map(key => summarizeHeatmapGroup(key, groups[key]));
}
function showWorstHeatmapGroup() {
const groupBy = document.getElementById("heatmapGroupBy")?.value || "rack";
const summaries = buildHeatmapSummariesForSelection(groupBy).sort((a, b) => {
const scoreDiff = Number(b.problem_score || 0) - Number(a.problem_score || 0);
if (scoreDiff !== 0) {
return scoreDiff;
}
return Number(b.max_temp || 0) - Number(a.max_temp || 0);
});
if (summaries.length === 0) {
return;
}
applyHeatmapGroupFilter(summaries[0].group_key, groupBy);
}
function setHeatmapStatusFilter(statusValue) {
const statusFilter = document.getElementById("statusFilter");
if (statusFilter) {
statusFilter.value = statusValue;
}
const respectFilters = document.getElementById("heatmapRespectFilters");
if (respectFilters) {
respectFilters.checked = true;
}
renderAll();
}
function mineropsCardIsCollapsedByTitle(title) {
let isCollapsed = false;
document.querySelectorAll(".card").forEach(card => {
const h2 = card.querySelector(":scope > h2");
if (!h2) {
return;
}
if (h2.textContent.trim() === title && card.classList.contains("collapsed")) {
isCollapsed = true;
}
});
return isCollapsed;
}
function renderHeatmapSummary() {
const body = document.getElementById("heatmapSummaryBody");
if (!body) {
return;
}
updateHeatmapActiveFilter();
const groupBy = document.getElementById("heatmapGroupBy")?.value || "rack";
const sortBy = document.getElementById("heatmapSortBy")?.value || "problem_score";
const positions = buildHeatmapSourcePositions();
const groups = {};
for (const position of positions) {
let key = position.room_name || "Unknown";
if (groupBy === "rack") {
key = (position.room_name || "Unknown") + " / " + (position.rack_name || "Unknown rack");
}
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(position);
}
let summaries = Object.keys(groups).map(key => summarizeHeatmapGroup(key, groups[key]));
summaries = summaries.sort((a, b) => {
const aValue = a[sortBy];
const bValue = b[sortBy];
if (aValue === null || aValue === undefined) {
return 1;
}
if (bValue === null || bValue === undefined) {
return -1;
}
return Number(bValue) - Number(aValue);
});
if (summaries.length === 0) {
body.innerHTML = `<div class="status warn">No heatmap summary data matches the current settings.</div>`;
return;
}
const totals = summarizeHeatmapGroup("Totals", positions);
body.innerHTML = `
<div class="small" style="margin-bottom: 8px;">
<span class="heatmap-pill">Total miners: ${totals.miner_count}</span>
<span class="heatmap-pill">Hot: ${totals.hot_count}</span>
<span class="heatmap-pill">Offline/stale: ${totals.offline_count}</span>
<span class="heatmap-pill">Not hashing: ${totals.not_hashing_count}</span>
<span class="heatmap-pill">Total TH/s: ${roundTwo(totals.total_hashrate)}</span>
<span class="heatmap-pill">Total watts: ${roundOne(totals.total_power)}</span>
</div>
<table class="heatmap-table">
<thead>
<tr>
<th>Area</th>
<th>Score</th>
<th>Miners</th>
<th>Avg Temp</th>
<th>Max Temp</th>
<th>Hot</th>
<th>Offline</th>
<th>Not Hashing</th>
<th>Error/Hashing</th>
<th>Duplicate</th>
<th>Empty</th>
<th>Total TH/s</th>
<th>Watts</th>
<th>W/TH</th>
</tr>
</thead>
<tbody>
${summaries.map(summary => `
<tr class="${heatmapRankClass(summary)}${heatmapSelectedGroupKey === summary.group_key && heatmapSelectedGroupBy === groupBy ? " heatmap-selected-row" : ""} heatmap-summary-row" data-heatmap-group-key="${escapeHtml(summary.group_key)}" data-heatmap-group-by="${escapeHtml(groupBy)}">
<td>${escapeHtml(summary.group_key)}</td>
<td class="heatmap-score">${escapeHtml(summary.problem_score)}</td>
<td>${escapeHtml(summary.miner_count)}</td>
<td>${escapeHtml(roundOne(summary.avg_temp))}</td>
<td>${escapeHtml(roundOne(summary.max_temp))}</td>
<td>${escapeHtml(summary.hot_count)}</td>
<td>${escapeHtml(summary.offline_count)}</td>
<td>${escapeHtml(summary.not_hashing_count)}</td>
<td>${escapeHtml(summary.error_hashing_count)}</td>
<td>${escapeHtml(summary.duplicate_count)}</td>
<td>${escapeHtml(summary.empty_count)}</td>
<td>${escapeHtml(roundTwo(summary.total_hashrate))}</td>
<td>${escapeHtml(roundOne(summary.total_power))}</td>
<td>${escapeHtml(roundTwo(summary.efficiency))}</td>
</tr>
`).join("")}
</tbody>
</table>
`;
body.querySelectorAll(".heatmap-summary-row").forEach(row => {
row.onclick = () => {
applyHeatmapGroupFilter(row.dataset.heatmapGroupKey, row.dataset.heatmapGroupBy);
};
});
}
function issueStatusSet(message, className) {
const element = document.getElementById("issueDrilldownStatus");
if (!element) {
return;
}
element.textContent = message;
element.className = "status " + className;
}
function issuesForPosition(position) {
const issues = [];
if (isDuplicatePosition(position)) {
issues.push({ type: "duplicate", label: "Duplicate/conflict", css: "issue-duplicate" });
}
if (isStaleOrOffline(position)) {
issues.push({ type: "offline", label: "Offline/stale", css: "issue-offline" });
}
if (position.miner_id && !isStaleOrOffline(position) && !isHashing(position)) {
issues.push({ type: "not_hashing", label: "Not hashing", css: "issue-not-hashing" });
}
if (isErroredButHashing(position)) {
issues.push({ type: "error_hashing", label: "Errored/hashing", css: "issue-error-hashing" });
}
if (isHotPosition(position)) {
issues.push({ type: "hot", label: "Hot", css: "issue-hot" });
}
return issues;
}
function buildIssueSourcePositions() {
let positions = locationMap.positions || [];
const respectFilters = document.getElementById("issueRespectFilters")?.checked === true;
if (!respectFilters) {
return positions;
}
const roomFilter = document.getElementById("roomFilter")?.value || "";
const search = document.getElementById("searchBox")?.value.trim().toLowerCase() || "";
if (roomFilter) {
positions = positions.filter(row => row.room_name === roomFilter);
}
if (search) {
positions = positions.filter(row => positionMatchesSearch(row, search));
}
positions = positions.filter(row => positionMatchesStatusFilter(row));
positions = positions.filter(row => heatmapSelectionMatchesPosition(row));
return positions;
}
function buildIssueRows() {
const issueType = document.getElementById("issueDrilldownType")?.value || "all";
return buildIssueSourcePositions()
.map(position => {
const issues = issuesForPosition(position);
return {
position: position,
issues: issues
};
})
.filter(row => row.issues.length > 0)
.filter(row => issueType === "all" || row.issues.some(issue => issue.type === issueType))
.sort((a, b) => {
const aTemp = Number(a.position.chip_temp_max_c || 0);
const bTemp = Number(b.position.chip_temp_max_c || 0);
const aOffline = isStaleOrOffline(a.position) ? 1 : 0;
const bOffline = isStaleOrOffline(b.position) ? 1 : 0;
if (bOffline !== aOffline) {
return bOffline - aOffline;
}
return bTemp - aTemp;
});
}
function issuePillsHtml(issues) {
return issues.map(issue => {
return `<span class="issue-pill ${issue.css}">${escapeHtml(issue.label)}</span>`;
}).join(" ");
}
function lastSeenShort(value) {
if (!value) {
return "-";
}
try {
const date = new Date(value);
return date.toLocaleString();
} catch (error) {
return value;
}
}
function focusMoveMiner(minerId) {
const moveSelect = document.getElementById("moveMinerId");
if (moveSelect) {
moveSelect.value = minerId;
}
const mapManagement = document.getElementById("mapManagementStatus");
if (mapManagement) {
mapManagement.scrollIntoView({ behavior: "smooth", block: "center" });
setMapManagementStatus("Miner selected in Move Miner / Change Location.", "ok");
}
}
async function markMinerRepairFromIssue(minerId, macAddress) {
if (!minerId) {
issueStatusSet("Cannot mark repair because this row has no miner ID.", "bad");
return;
}
const confirmed = confirm("Move " + (macAddress || minerId) + " to Repair state?");
if (!confirmed) {
return;
}
try {
issueStatusSet("Marking miner as repair...", "warn");
await apiPost("/api/location/miners/move", {
miner_id: minerId,
destination_type: "virtual",
asset_state: "repair",
notes: "Marked for repair from Issue Drill-Down."
});
issueStatusSet("Miner moved to Repair.", "ok");
await refreshAll();
} catch (error) {
issueStatusSet("Mark repair error: " + error.message, "bad");
}
}
function renderIssueDrilldown() {
const body = document.getElementById("issueDrilldownBody");
if (!body) {
return;
}
const rows = buildIssueRows();
if (rows.length === 0) {
body.innerHTML = `<div class="status ok">No issue rows match the current settings.</div>`;
issueStatusSet("No matching issues.", "ok");
return;
}
issueStatusSet("Showing " + rows.length + " issue rows.", "warn");
body.innerHTML = `
<table class="issue-table">
<thead>
<tr>
<th>MAC</th>
<th>IP</th>
<th>Model</th>
<th>Location</th>
<th>Issues</th>
<th>Temp</th>
<th>TH/s</th>
<th>Watts</th>
<th>Last Seen</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${rows.map(row => {
const position = row.position;
const mac = position.current_mac || position.expected_mac || "-";
const location = [position.room_name, position.rack_name, position.shelf_name, "Pos " + position.position_index].filter(Boolean).join(" / ");
const temp = position.chip_temp_max_c === null || position.chip_temp_max_c === undefined ? "-" : position.chip_temp_max_c + " C";
const hashrate = position.hashrate_ths === null || position.hashrate_ths === undefined ? "-" : position.hashrate_ths;
const watts = position.power_w === null || position.power_w === undefined ? "-" : position.power_w;
const detailButton = position.miner_id
? `<button class="issue-action-button" onclick="openMiner('${position.miner_id}')">Open Detail</button>`
: "";
const moveButton = position.miner_id
? `<button class="issue-action-button" onclick="focusMoveMiner('${position.miner_id}')">Move</button>`
: "";
const repairButton = position.miner_id
? `<button class="issue-action-button danger" onclick="markMinerRepairFromIssue('${position.miner_id}', '${escapeHtml(mac)}')">Mark Repair</button>`
: "";
return `
<tr>
<td>${escapeHtml(mac)}</td>
<td>${escapeHtml(position.current_ip || "-")}</td>
<td>${escapeHtml(modelTextForPosition(position))}</td>
<td>${escapeHtml(location)}</td>
<td>${issuePillsHtml(row.issues)}</td>
<td>${escapeHtml(temp)}</td>
<td>${escapeHtml(hashrate)}</td>
<td>${escapeHtml(watts)}</td>
<td>${escapeHtml(lastSeenShort(position.last_seen_at))}</td>
<td>${detailButton} ${moveButton} ${repairButton}</td>
</tr>
`;
}).join("")}
</tbody>
</table>
`;
}
function copyIssueDrilldownCsv() {
const rows = buildIssueRows();
const headers = ["mac","ip","model","room","rack","shelf","position","issues","temp_c","hashrate_ths","watts","last_seen"];
const csvRows = [headers.join(",")];
for (const row of rows) {
const position = row.position;
const values = [
position.current_mac || position.expected_mac || "",
position.current_ip || "",
modelTextForPosition(position),
position.room_name || "",
position.rack_name || "",
position.shelf_name || "",
position.position_index || "",
row.issues.map(issue => issue.label).join("; "),
position.chip_temp_max_c || "",
position.hashrate_ths || "",
position.power_w || "",
position.last_seen_at || ""
];
csvRows.push(values.map(value => `"${String(value).replaceAll(`"`, `""`)}"`).join(","));
}
navigator.clipboard.writeText(csvRows.join("\\n")).then(() => {
issueStatusSet("Copied " + rows.length + " issue rows to clipboard as CSV.", "ok");
}).catch(error => {
issueStatusSet("Copy CSV failed: " + error.message, "bad");
});
}
function renderMap() {
const body = document.getElementById("mapBody");
const roomFilter = document.getElementById("roomFilter").value;
const search = document.getElementById("searchBox").value.trim().toLowerCase();
let positions = locationMap.positions || [];
if (roomFilter) {
positions = positions.filter(row => row.room_name === roomFilter);
}
if (search) {
positions = positions.filter(row => positionMatchesSearch(row, search));
}
positions = positions.filter(row => positionMatchesStatusFilter(row));
positions = positions.filter(row => heatmapSelectionMatchesPosition(row));
const resultCount = document.getElementById("mapResultCount");
if (resultCount) {
const minerCount = positions.filter(row => row.miner_id).length;
const hotCount = positions.filter(row => isHotPosition(row)).length;
const offlineCount = positions.filter(row => isStaleOrOffline(row)).length;
const problemCount = positions.filter(row => isProblemPosition(row)).length;
resultCount.textContent =
"Showing " + positions.length + " positions, " +
minerCount + " miners, " +
problemCount + " problem positions, " +
hotCount + " hot, " +
offlineCount + " offline/stale.";
}
if (positions.length === 0) {
body.innerHTML = '<div class="status warn">No map positions match the current filter.</div>';
return;
}
const rooms = {};
for (const position of positions) {
if (!rooms[position.room_name]) {
rooms[position.room_name] = {};
}
if (!rooms[position.room_name][position.rack_name]) {
rooms[position.room_name][position.rack_name] = {};
}
if (!rooms[position.room_name][position.rack_name][position.shelf_name]) {
rooms[position.room_name][position.rack_name][position.shelf_name] = [];
}
rooms[position.room_name][position.rack_name][position.shelf_name].push(position);
}
let html = "";
const savedRoomOrder = locationRooms.length > 0
? locationRooms.map(room => room.name).filter(Boolean)
: [];
const unorderedRoomNames = Object.keys(rooms);
const roomNames = [
...savedRoomOrder.filter(roomName => rooms[roomName]),
...unorderedRoomNames.filter(roomName => !savedRoomOrder.includes(roomName)).sort()
];
for (const roomName of roomNames) {
html += `<h2 class="room-title">${escapeHtml(roomName)}</h2>`;
function rackSortValue(rackName) {
const name = String(rackName || "");
if (name.toLowerCase().startsWith("rack ")) {
return "0-" + name;
}
return "9-" + name;
}
const rackNames = Object.keys(rooms[roomName]).sort((a, b) => {
return rackSortValue(a).localeCompare(rackSortValue(b));
});
for (const rackName of rackNames) {
html += `<div class="rack"><div class="rack-title">${escapeHtml(rackName)}</div>`;
const shelfNames = Object.keys(rooms[roomName][rackName]).sort((a, b) => {
const aFirst = rooms[roomName][rackName][a][0];
const bFirst = rooms[roomName][rackName][b][0];
return Number(aFirst.shelf_index || 0) - Number(bFirst.shelf_index || 0);
});
for (const shelfName of shelfNames) {
const shelfPositions = rooms[roomName][rackName][shelfName].sort((a, b) => {
return Number(a.position_index || 0) - Number(b.position_index || 0);
});
html += `<div class="shelf"><div class="shelf-label">${escapeHtml(shelfName)}</div>`;
for (const position of shelfPositions) {
const tileClass = tileClassForPosition(position);
const mac = position.current_mac || position.expected_mac || "";
const clickClass = position.miner_id ? " clickable" : "";
const selectedClass = position.miner_id && position.miner_id === directMoveSelectedMinerId ? " move-selected" : "";
const targetClass = directMoveSelectedMinerId && !position.miner_id && position.position_kind === "miner_slot" && position.position_status !== "blocked" && position.position_status !== "switch" && !position.conflict_reason ? " move-target-ok" : "";
const searchMatchClass = search && positionMatchesSearch(position, search) ? " search-match" : "";
const heatmapSelectedTileClass = heatmapSelectedGroupKey && heatmapSelectionMatchesPosition(position) ? " heatmap-selected-tile" : "";
const minerIdAttr = position.miner_id ? ` data-miner-id="${escapeHtml(position.miner_id)}"` : "";
const positionDataAttrs = `
data-room-name="${escapeHtml(position.room_name || "")}"
data-rack-name="${escapeHtml(position.rack_name || "")}"
data-shelf-name="${escapeHtml(position.shelf_name || "")}"
data-position-index="${escapeHtml(position.position_index || "")}"
data-position-kind="${escapeHtml(position.position_kind || "")}"
data-position-status="${escapeHtml(position.position_status || "")}"
data-conflict-reason="${escapeHtml(position.conflict_reason || "")}"
data-current-mac="${escapeHtml(position.current_mac || position.expected_mac || "")}"
`;
const positionText = "Pos " + escapeHtml(position.position_index);
let mainText = "";
if (position.position_kind === "switch") {
mainText = position.equipment_label || "Switch";
} else if (position.position_kind === "blocked" || position.position_status === "blocked") {
mainText = "Blocked";
} else if (mac) {
mainText = mac;
} else {
mainText = "Empty";
}
html += `
<div class="tile ${tileClass}${clickClass}${selectedClass}${targetClass}${searchMatchClass}${heatmapSelectedTileClass}"${minerIdAttr} ${positionDataAttrs}>
<div class="mac">${escapeHtml(mainText)}</div>
<div class="sub">${positionText}</div>
<div class="sub">${tileDetailText(position)}</div>
</div>
`;
}
html += "</div>";
}
html += "</div>";
}
}
body.innerHTML = html;
document.querySelectorAll(".tile").forEach(tile => {
tile.onclick = () => {
handleMapTileClick(tile);
};
});
}
function renderVirtualMiners() {
const body = document.getElementById("virtualBody");
const search = document.getElementById("searchBox").value.trim().toLowerCase();
let miners = locationMap.virtual_miners || [];
if (search) {
miners = miners.filter(miner => {
const haystack = [
miner.asset_state,
miner.current_mac,
miner.current_ip,
miner.status,
miner.hostname,
miner.miner_sn,
miner.power_sn,
miner.model,
miner.miner_type,
miner.firmware_version
].join(" ").toLowerCase();
return haystack.includes(search);
});
}
if (miners.length === 0) {
body.innerHTML = '<div class="status ok">No waiting/storage/repair/decommissioned miners match this filter.</div>';
return;
}
const groups = {};
for (const miner of miners) {
const state = miner.asset_state || "waiting_placement";
if (!groups[state]) {
groups[state] = [];
}
groups[state].push(miner);
}
let html = "";
for (const state of Object.keys(groups).sort()) {
html += `<h3>${escapeHtml(state)}</h3><div class="virtual-grid">`;
for (const miner of groups[state]) {
html += `
<div class="virtual-card">
<div><a href="/miners/${encodeURIComponent(miner.miner_id)}/detail" target="_blank">${escapeHtml(miner.current_mac || "No MAC")}</a></div>
<div>IP: ${escapeHtml(miner.current_ip || "-")}</div>
<div>Status: ${escapeHtml(miner.status || "-")}</div>
<div>Model: ${escapeHtml(miner.miner_type || miner.model || "-")}</div>
<div>Serial: ${escapeHtml(miner.miner_sn || "-")}</div>
<div>Last seen: ${escapeHtml(miner.last_seen_at || "-")}</div>
</div>
`;
}
html += "</div>";
}
body.innerHTML = html;
}
function isDirectMoveModeEnabled() {
return document.getElementById("directMoveMode")?.checked === true;
}
function setDirectMoveStatus(message, className) {
const element = document.getElementById("directMoveStatus");
if (!element) {
return;
}
element.textContent = message;
element.className = "status " + className;
}
function updateDirectMoveBodyClass() {
if (isDirectMoveModeEnabled()) {
document.body.classList.add("move-mode-on");
} else {
document.body.classList.remove("move-mode-on");
}
}
function toggleDirectMoveMode() {
updateDirectMoveBodyClass();
if (isDirectMoveModeEnabled()) {
setDirectMoveStatus("Move Mode is on. Click a miner tile, then click an empty target slot.", "warn");
} else {
clearDirectMoveSelection();
setDirectMoveStatus("Move Mode is off. Normal clicks open miner detail.", "warn");
}
renderHeatmapSummary();
renderIssueDrilldown();
renderMap();
}
function clearDirectMoveSelection() {
directMoveSelectedMinerId = null;
directMoveSelectedLabel = null;
const label = document.getElementById("directMoveSelectedLabel");
if (label) {
label.value = "None selected";
}
renderHeatmapSummary();
renderIssueDrilldown();
renderMap();
}
function selectDirectMoveMiner(tile) {
directMoveSelectedMinerId = tile.dataset.minerId;
directMoveSelectedLabel = tile.dataset.currentMac || tile.dataset.minerId;
const label = document.getElementById("directMoveSelectedLabel");
if (label) {
label.value = directMoveSelectedLabel;
}
setDirectMoveStatus(
"Selected " + directMoveSelectedLabel + ". Now click an empty miner slot to move it.",
"ok"
);
renderHeatmapSummary();
renderIssueDrilldown();
renderMap();
}
function tileIsValidMoveTarget(tile) {
if (!tile) {
return false;
}
if (tile.dataset.minerId) {
return false;
}
if (tile.dataset.positionKind !== "miner_slot") {
return false;
}
if (tile.dataset.positionStatus === "blocked" || tile.dataset.positionStatus === "switch") {
return false;
}
if (tile.dataset.conflictReason) {
return false;
}
return true;
}
async function moveSelectedMinerToTile(tile) {
if (!directMoveSelectedMinerId) {
return;
}
if (!tileIsValidMoveTarget(tile)) {
setDirectMoveStatus("That is not an available miner slot. Choose an empty, unblocked miner slot.", "bad");
return;
}
const roomName = tile.dataset.roomName;
const rackName = tile.dataset.rackName;
const shelfName = tile.dataset.shelfName;
const positionIndex = Number(tile.dataset.positionIndex);
const confirmed = confirm(
"Move " + directMoveSelectedLabel + " to " +
roomName + " / " + rackName + " / " + shelfName + " / Pos " + positionIndex + "?"
);
if (!confirmed) {
return;
}
try {
setDirectMoveStatus("Moving miner...", "warn");
const result = await apiPost("/api/location/miners/move", {
miner_id: directMoveSelectedMinerId,
destination_type: "physical",
room_name: roomName,
rack_name: rackName,
shelf_name: shelfName,
position_index: positionIndex,
notes: "Moved using direct map move mode."
});
setDirectMoveStatus(
"Moved " + (result.current_mac || result.miner_id) + " to " + result.new_location + ".",
"ok"
);
directMoveSelectedMinerId = null;
directMoveSelectedLabel = null;
const label = document.getElementById("directMoveSelectedLabel");
if (label) {
label.value = "None selected";
}
await refreshAll();
} catch (error) {
setDirectMoveStatus("Move error: " + error.message, "bad");
}
}
function handleMapTileClick(tile) {
if (!isDirectMoveModeEnabled()) {
if (tile.dataset.minerId) {
openMiner(tile.dataset.minerId);
}
return;
}
if (!directMoveSelectedMinerId) {
if (!tile.dataset.minerId) {
setDirectMoveStatus("Click a miner tile first, then click an empty target slot.", "warn");
return;
}
selectDirectMoveMiner(tile);
return;
}
if (tile.dataset.minerId === directMoveSelectedMinerId) {
clearDirectMoveSelection();
setDirectMoveStatus("Selection cleared.", "warn");
return;
}
if (tile.dataset.minerId && tile.dataset.minerId !== directMoveSelectedMinerId) {
selectDirectMoveMiner(tile);
return;
}
moveSelectedMinerToTile(tile);
}
function cardTitle(card) {
const h2 = card.querySelector("h2");
return h2 ? h2.textContent.trim() : "";
}
function cardStorageKey(title) {
return "minerops_location_card_" + title.toLowerCase().replaceAll(" ", "_").replaceAll("/", "_");
}
function isAlwaysExpandedCard(title) {
return title === "Physical Map";
}
function shouldStartExpanded(title) {
if (isAlwaysExpandedCard(title)) {
return true;
}
const saved = localStorage.getItem(cardStorageKey(title));
if (saved === "expanded") {
return true;
}
if (saved === "collapsed") {
return false;
}
return [
"Map Controls",
"Rack / Room Heatmap Summary",
"Issue Drill-Down"
].includes(title);
}
function setupCollapsibleCards() {
document.querySelectorAll(".card").forEach(card => {
const h2 = card.querySelector(":scope > h2");
if (!h2) {
return;
}
const title = h2.textContent.trim();
if (isAlwaysExpandedCard(title)) {
card.classList.add("always-expanded");
card.classList.remove("collapsed");
return;
}
card.classList.add("collapsible");
if (shouldStartExpanded(title)) {
card.classList.remove("collapsed");
} else {
card.classList.add("collapsed");
}
if (h2.dataset.collapseHandlerAttached === "true") {
return;
}
h2.dataset.collapseHandlerAttached = "true";
h2.onclick = () => {
card.classList.toggle("collapsed");
localStorage.setItem(
cardStorageKey(title),
card.classList.contains("collapsed") ? "collapsed" : "expanded"
);
if (!card.classList.contains("collapsed") && typeof renderAll === "function") {
setTimeout(renderAll, 0);
}
};
});
}
function setCardExpandedByTitle(title, expanded) {
document.querySelectorAll(".card").forEach(card => {
const currentTitle = cardTitle(card);
if (currentTitle !== title || isAlwaysExpandedCard(currentTitle)) {
return;
}
card.classList.toggle("collapsed", !expanded);
localStorage.setItem(
cardStorageKey(currentTitle),
expanded ? "expanded" : "collapsed"
);
});
}
function expandAllCards() {
document.querySelectorAll(".card.collapsible").forEach(card => {
const title = cardTitle(card);
card.classList.remove("collapsed");
localStorage.setItem(cardStorageKey(title), "expanded");
});
}
function collapseAllCards() {
document.querySelectorAll(".card.collapsible").forEach(card => {
const title = cardTitle(card);
if (isAlwaysExpandedCard(title)) {
return;
}
card.classList.add("collapsed");
localStorage.setItem(cardStorageKey(title), "collapsed");
});
}
function expandTroubleshootingCards() {
collapseAllCards();
[
"Map Controls",
"Rack / Room Heatmap Summary",
"Issue Drill-Down"
].forEach(title => setCardExpandedByTitle(title, true));
}
function expandManagementCards() {
collapseAllCards();
[
"Map Controls",
"Map Management",
"Unit / Room Order",
"Location Import Issues"
].forEach(title => setCardExpandedByTitle(title, true));
}
function getCardByTitle(title) {
let foundCard = null;
document.querySelectorAll(".card").forEach(card => {
const h2 = card.querySelector(":scope > h2");
if (!h2) {
return;
}
if (h2.textContent.trim() === title) {
foundCard = card;
}
});
return foundCard;
}
function jumpToCardTitle(title) {
const card = getCardByTitle(title);
if (!card) {
return;
}
card.classList.remove("collapsed");
localStorage.setItem(
cardStorageKey(title),
"expanded"
);
card.scrollIntoView({
behavior: "smooth",
block: "start"
});
}
function clearLocationFilters() {
const roomFilter = document.getElementById("roomFilter");
const searchBox = document.getElementById("searchBox");
const statusFilter = document.getElementById("statusFilter");
const problemOnlyToggle = document.getElementById("problemOnlyToggle");
const heatmapRespectFilters = document.getElementById("heatmapRespectFilters");
const issueRespectFilters = document.getElementById("issueRespectFilters");
if (roomFilter) {
roomFilter.value = "";
}
if (searchBox) {
searchBox.value = "";
}
if (statusFilter) {
statusFilter.value = "all";
}
if (problemOnlyToggle) {
problemOnlyToggle.checked = false;
}
if (heatmapRespectFilters) {
heatmapRespectFilters.checked = false;
}
if (issueRespectFilters) {
issueRespectFilters.checked = true;
}
heatmapSelectedGroupKey = null;
heatmapSelectedGroupBy = null;
updateHeatmapActiveFilter();
renderAll();
}
function setToolbarStatusFilter(statusValue) {
const statusFilter = document.getElementById("statusFilter");
if (statusFilter) {
statusFilter.value = statusValue;
}
const problemOnlyToggle = document.getElementById("problemOnlyToggle");
if (problemOnlyToggle) {
problemOnlyToggle.checked = false;
}
const heatmapRespectFilters = document.getElementById("heatmapRespectFilters");
if (heatmapRespectFilters) {
heatmapRespectFilters.checked = true;
}
expandTroubleshootingCards();
renderAll();
const issueCard = getCardByTitle("Issue Drill-Down");
if (issueCard) {
issueCard.scrollIntoView({
behavior: "smooth",
block: "start"
});
}
}
function setSelectValueIfExists(id, value) {
const element = document.getElementById(id);
if (!element) {
return;
}
element.value = value;
}
function setCheckboxIfExists(id, value) {
const element = document.getElementById(id);
if (!element) {
return;
}
element.checked = value === true;
}
function setInputValueIfExists(id, value) {
const element = document.getElementById(id);
if (!element) {
return;
}
element.value = value || "";
}
function resetMapViewControls() {
setSelectValueIfExists("roomFilter", "");
setInputValueIfExists("searchBox", "");
setSelectValueIfExists("statusFilter", "all");
setSelectValueIfExists("issueDrilldownType", "all");
setCheckboxIfExists("problemOnlyToggle", false);
setCheckboxIfExists("heatmapRespectFilters", false);
setCheckboxIfExists("issueRespectFilters", true);
heatmapSelectedGroupKey = null;
heatmapSelectedGroupBy = null;
updateHeatmapActiveFilter();
}
function applySavedMapView(viewName) {
resetMapViewControls();
if (viewName === "hot_miners") {
setSelectValueIfExists("viewMode", "temperature");
setSelectValueIfExists("statusFilter", "hot");
setSelectValueIfExists("issueDrilldownType", "hot");
setSelectValueIfExists("tileSizeMode", "compact");
setCheckboxIfExists("heatmapRespectFilters", true);
setCheckboxIfExists("issueRespectFilters", true);
setTileSizeMode();
expandTroubleshootingCards();
renderAll();
jumpToCardTitle("Issue Drill-Down");
return;
}
if (viewName === "offline_miners") {
setSelectValueIfExists("viewMode", "health");
setSelectValueIfExists("statusFilter", "offline");
setSelectValueIfExists("issueDrilldownType", "offline");
setSelectValueIfExists("tileSizeMode", "compact");
setCheckboxIfExists("heatmapRespectFilters", true);
setCheckboxIfExists("issueRespectFilters", true);
setTileSizeMode();
expandTroubleshootingCards();
renderAll();
jumpToCardTitle("Issue Drill-Down");
return;
}
if (viewName === "unit_6_problems") {
setSelectValueIfExists("roomFilter", "Unit 6");
setSelectValueIfExists("viewMode", "health");
setSelectValueIfExists("statusFilter", "problems");
setSelectValueIfExists("issueDrilldownType", "all");
setSelectValueIfExists("tileSizeMode", "compact");
setCheckboxIfExists("problemOnlyToggle", true);
setCheckboxIfExists("heatmapRespectFilters", true);
setCheckboxIfExists("issueRespectFilters", true);
setTileSizeMode();
expandTroubleshootingCards();
renderAll();
jumpToCardTitle("Issue Drill-Down");
return;
}
if (viewName === "management") {
setSelectValueIfExists("viewMode", "plain");
setSelectValueIfExists("statusFilter", "all");
setSelectValueIfExists("tileSizeMode", "comfortable");
setCheckboxIfExists("problemOnlyToggle", false);
setCheckboxIfExists("heatmapRespectFilters", false);
setCheckboxIfExists("issueRespectFilters", true);
setTileSizeMode();
expandManagementCards();
renderAll();
jumpToCardTitle("Map Management");
return;
}
if (viewName === "compact_all_units") {
setSelectValueIfExists("viewMode", "health");
setSelectValueIfExists("statusFilter", "all");
setSelectValueIfExists("issueDrilldownType", "all");
setSelectValueIfExists("tileSizeMode", "compact");
setCheckboxIfExists("problemOnlyToggle", false);
setCheckboxIfExists("heatmapRespectFilters", false);
setCheckboxIfExists("issueRespectFilters", true);
setTileSizeMode();
collapseAllCards();
setCardExpandedByTitle("Map Controls", true);
renderAll();
jumpToCardTitle("Physical Map");
return;
}
}
function openMiner(minerId) {
if (!minerId) {
return;
}
window.open("/miners/" + encodeURIComponent(minerId) + "/detail", "_blank");
}
setTileSizeMode();
setupCollapsibleCards();
setTileSizeMode();
setupCollapsibleCards();
function setTileSizeMode() {
const mode = document.getElementById("tileSizeMode")?.value || "comfortable";
if (mode === "compact") {
document.body.classList.add("compact-tiles");
document.documentElement.classList.add("compact-tiles");
} else {
document.body.classList.remove("compact-tiles");
document.documentElement.classList.remove("compact-tiles");
}
const resultCount = document.getElementById("mapResultCount");
if (resultCount) {
resultCount.dataset.tileSize = mode;
}
}
if (token) {
setLoginStatus("Saved token found in this browser.", "ok");
refreshAll();
} else {
setLoginStatus("No saved token found. Log in once.", "warn");
}
</script>
<script src="/assets/minerops-shell-auth.js"></script>
<script src="/assets/minerops-global-search.js"></script>
<script src="/assets/minerops-header-scan.js"></script>
<script src="/assets/minerops-hub-mode.js"></script>
<script src="/assets/minerops-miner-detail-extra.js?v=1"></script>
</body>
</html>
"""
# -----------------------------
# Phase 3 Step 65: Legacy WhatsMiner / M30S summary fallback
# -----------------------------
class LegacySummaryRefreshRequest(BaseModel):
miner_id: str | None = None
ip_address: str | None = None
limit: int = 50
timeout_seconds: float = 1.5
only_missing: bool = True
def legacy_summary_to_float(value):
if value is None:
return None
try:
return float(str(value).replace(",", "").strip())
except Exception:
return None
def legacy_summary_to_int(value):
float_value = legacy_summary_to_float(value)
if float_value is None:
return None
return int(float_value)
def legacy_summary_to_bool(value):
if value is None:
return None
if isinstance(value, bool):
return value
text = str(value).strip().lower()
if text in ("true", "1", "yes", "y", "stable"):
return True
if text in ("false", "0", "no", "n", "unstable"):
return False
return None
def legacy_summary_get(summary: dict, *keys):
for key in keys:
if key in summary and summary.get(key) is not None:
return summary.get(key)
return None
def infer_legacy_whatsminer_model(summary: dict):
factory_ghs = legacy_summary_to_float(
legacy_summary_get(summary, "Factory GHS", "FactoryGHS", "factory_ghs")
)
target_mhs = legacy_summary_to_float(
legacy_summary_get(summary, "Target MHS", "TargetMHS", "target_mhs")
)
estimate_ghs = factory_ghs
if estimate_ghs is None and target_mhs is not None:
estimate_ghs = target_mhs / 1000.0
if estimate_ghs is None:
return "WhatsMiner Legacy"
if 70000 <= estimate_ghs <= 115000:
return "M30S+"
if 115000 < estimate_ghs <= 140000:
return "M30S++"
if 140000 < estimate_ghs <= 180000:
return "M50S++"
return "WhatsMiner Legacy"
def read_legacy_whatsminer_summary(ip_address: str, port: int = 4028, timeout_seconds: float = 1.5):
import json
import socket
payloads = [
b'{"command":"summary"}\n',
b'{"command":"summary"}',
]
last_error = None
for payload in payloads:
try:
chunks = []
with socket.create_connection((ip_address, port), timeout=timeout_seconds) as sock:
sock.settimeout(timeout_seconds)
sock.sendall(payload)
while True:
try:
chunk = sock.recv(4096)
except socket.timeout:
break
if not chunk:
break
chunks.append(chunk)
if not chunks:
continue
response_text = b"".join(chunks).decode("utf-8", errors="replace").strip()
parsed = json.loads(response_text)
summary_list = parsed.get("SUMMARY") or parsed.get("summary") or []
if not summary_list:
return {
"success": False,
"error": "No SUMMARY section returned",
"response_text": response_text,
}
return {
"success": True,
"summary": summary_list[0],
"response_text": response_text,
"parsed_json": parsed,
}
except Exception as error:
last_error = str(error)
return {
"success": False,
"error": last_error or "No response",
}
def miner_needs_legacy_summary_patch(miner_id: str):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
WITH latest AS (
SELECT DISTINCT ON (miner_id)
miner_id,
mhs_av,
chip_temp_max_c,
chip_temp_avg_c,
observed_at
FROM miner_status_snapshots
WHERE miner_id = %s::uuid
ORDER BY miner_id, observed_at DESC
)
SELECT
miners.id::text,
miners.model,
miners.miner_type,
latest.mhs_av,
latest.chip_temp_max_c,
latest.chip_temp_avg_c
FROM miners
LEFT JOIN latest ON latest.miner_id = miners.id
WHERE miners.id = %s::uuid;
""",
(miner_id, miner_id),
)
row = cur.fetchone()
if not row:
return False
model_missing = row.get("model") is None or str(row.get("model")).strip() == ""
miner_type_missing = row.get("miner_type") is None or str(row.get("miner_type")).strip() == ""
hashrate_missing = row.get("mhs_av") is None
temp_missing = row.get("chip_temp_max_c") is None and row.get("chip_temp_avg_c") is None
return model_missing or miner_type_missing or hashrate_missing or temp_missing
finally:
if conn is not None:
conn.close()
def apply_legacy_summary_to_miner(miner_id: str, ip_address: str, timeout_seconds: float = 1.5):
read_result = read_legacy_whatsminer_summary(ip_address, 4028, timeout_seconds)
if not read_result.get("success"):
return {
"success": False,
"miner_id": miner_id,
"ip_address": ip_address,
"error": read_result.get("error"),
}
summary = read_result.get("summary") or {}
mhs_av = legacy_summary_to_float(
legacy_summary_get(summary, "MHS av", "MHS Av", "mhs_av", "MHS 5s", "HS RT")
)
power_w = legacy_summary_to_float(
legacy_summary_get(summary, "Power", "power", "Power W")
)
power_rate = legacy_summary_to_float(
legacy_summary_get(summary, "Power Rate", "PowerRate", "power_rate")
)
chip_temp_max = legacy_summary_to_float(
legacy_summary_get(summary, "Chip Temp Max", "ChipTempMax", "chip_temp_max", "Temperature")
)
chip_temp_avg = legacy_summary_to_float(
legacy_summary_get(summary, "Chip Temp Avg", "ChipTempAvg", "chip_temp_avg", "Temperature")
)
fan_in = legacy_summary_to_int(
legacy_summary_get(summary, "Fan Speed In", "FanSpeedIn", "fan_speed_in")
)
fan_out = legacy_summary_to_int(
legacy_summary_get(summary, "Fan Speed Out", "FanSpeedOut", "fan_speed_out")
)
hash_stable = legacy_summary_to_bool(
legacy_summary_get(summary, "Hash Stable", "HashStable", "hash_stable")
)
pool_rejected_percent = legacy_summary_to_float(
legacy_summary_get(summary, "Pool Rejected%", "Pool Rejected", "pool_rejected_percent")
)
inferred_model = infer_legacy_whatsminer_model(summary)
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
UPDATE miners
SET
status = 'online',
last_seen_at = now(),
current_ip = COALESCE(NULLIF(%s, '')::inet, current_ip),
model = COALESCE(NULLIF(model, ''), %s),
miner_type = COALESCE(NULLIF(miner_type, ''), %s)
WHERE id = %s::uuid
RETURNING
id::text,
current_mac,
host(current_ip) AS current_ip,
model,
miner_type;
""",
(
ip_address,
inferred_model,
inferred_model,
miner_id,
),
)
miner_row = cur.fetchone()
if not miner_row:
raise HTTPException(status_code=404, detail="Miner not found")
cur.execute(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'miner_status_snapshots';
"""
)
snapshot_columns = {row["column_name"] for row in cur.fetchall()}
insert_columns = []
insert_values_sql = []
values = []
def add_value(column_name, value, sql_cast=None):
if column_name not in snapshot_columns:
return
insert_columns.append(column_name)
if sql_cast:
insert_values_sql.append(f"%s::{sql_cast}")
else:
insert_values_sql.append("%s")
values.append(value)
def add_now(column_name):
if column_name not in snapshot_columns:
return
insert_columns.append(column_name)
insert_values_sql.append("now()")
add_value("id", str(uuid4()), "uuid")
add_value("miner_id", miner_id, "uuid")
add_now("observed_at")
add_value("observed_ip", ip_address, "inet")
add_value("mhs_av", mhs_av)
add_value("power_w", power_w)
add_value("power_rate_w_per_th", power_rate)
add_value("chip_temp_max_c", chip_temp_max)
add_value("chip_temp_avg_c", chip_temp_avg)
add_value("fan_speed_in_rpm", fan_in)
add_value("fan_speed_out_rpm", fan_out)
add_value("hash_stable", hash_stable)
add_value("pool_rejected_percent", pool_rejected_percent)
add_now("created_at")
if "raw_summary_json" in snapshot_columns:
import json
add_value("raw_summary_json", json.dumps(summary))
if len(insert_columns) > 0:
sql = f"""
INSERT INTO miner_status_snapshots (
{", ".join(insert_columns)}
)
VALUES (
{", ".join(insert_values_sql)}
);
"""
cur.execute(sql, values)
conn.commit()
return {
"success": True,
"miner_id": miner_id,
"ip_address": ip_address,
"inferred_model": inferred_model,
"mhs_av": mhs_av,
"hashrate_ths": None if mhs_av is None else round(mhs_av / 1000000.0, 2),
"power_w": power_w,
"chip_temp_max_c": chip_temp_max,
"chip_temp_avg_c": chip_temp_avg,
"hash_stable": hash_stable,
"pool_rejected_percent": pool_rejected_percent,
}
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
@app.post("/api/tools/legacy-summary-refresh-one")
def legacy_summary_refresh_one(
request: LegacySummaryRefreshRequest,
current_user: dict = Depends(get_current_user),
):
if not request.miner_id and not request.ip_address:
raise HTTPException(status_code=400, detail="Provide miner_id or ip_address")
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
if request.miner_id:
cur.execute(
"""
SELECT id::text, host(current_ip) AS current_ip
FROM miners
WHERE id = %s::uuid;
""",
(request.miner_id,),
)
else:
cur.execute(
"""
SELECT id::text, host(current_ip) AS current_ip
FROM miners
WHERE current_ip = %s::inet
LIMIT 1;
""",
(request.ip_address,),
)
miner = cur.fetchone()
if not miner:
raise HTTPException(status_code=404, detail="Miner not found")
miner_id = miner["id"]
ip_address = request.ip_address or miner["current_ip"]
if not ip_address:
raise HTTPException(status_code=400, detail="Miner has no current IP address")
result = apply_legacy_summary_to_miner(
miner_id,
ip_address,
request.timeout_seconds,
)
return api_response(data=result)
finally:
if conn is not None:
conn.close()
@app.post("/api/tools/legacy-summary-refresh-missing")
def legacy_summary_refresh_missing(
request: LegacySummaryRefreshRequest,
current_user: dict = Depends(get_current_user),
):
safe_limit = max(1, min(int(request.limit or 50), 300))
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
WITH latest AS (
SELECT DISTINCT ON (miner_id)
miner_id,
mhs_av,
chip_temp_max_c,
chip_temp_avg_c,
observed_at
FROM miner_status_snapshots
ORDER BY miner_id, observed_at DESC
)
SELECT
miners.id::text,
host(miners.current_ip) AS current_ip,
miners.current_mac,
miners.model,
miners.miner_type,
latest.mhs_av,
latest.chip_temp_max_c,
latest.chip_temp_avg_c
FROM miners
LEFT JOIN latest ON latest.miner_id = miners.id
WHERE miners.current_ip IS NOT NULL
AND (
miners.model IS NULL
OR trim(miners.model) = ''
OR miners.miner_type IS NULL
OR trim(miners.miner_type) = ''
OR latest.mhs_av IS NULL
OR latest.chip_temp_max_c IS NULL
)
ORDER BY miners.current_ip
LIMIT %s;
""",
(safe_limit,),
)
miners = [dict(row) for row in cur.fetchall()]
results = []
for miner in miners:
result = apply_legacy_summary_to_miner(
miner["id"],
miner["current_ip"],
request.timeout_seconds,
)
results.append(result)
return api_response(
data=results,
meta={
"attempted": len(results),
"success": len([row for row in results if row.get("success")]),
"failed": len([row for row in results if not row.get("success")]),
},
)
finally:
if conn is not None:
conn.close()
def legacy_find_miner_id_by_ip(ip_address: str):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT id::text
FROM miners
WHERE current_ip = %s::inet
LIMIT 1;
""",
(ip_address,),
)
row = cur.fetchone()
if not row:
return None
return row["id"]
finally:
if conn is not None:
conn.close()
try:
_minerops_original_discover_one_miner_legacy_summary = discover_one_miner
def discover_one_miner(request, current_user: dict = Depends(get_current_user)):
result = _minerops_original_discover_one_miner_legacy_summary(request, current_user)
try:
ip_address = getattr(request, "ip_address", None) or getattr(request, "ip", None)
if ip_address:
miner_id = legacy_find_miner_id_by_ip(ip_address)
if miner_id and miner_needs_legacy_summary_patch(miner_id):
apply_legacy_summary_to_miner(miner_id, ip_address, 1.5)
except Exception as legacy_error:
print(f"Legacy summary fallback failed: {legacy_error}")
return result
except NameError:
pass
# -----------------------------
# Phase 3 Step 66: Room / Unit ordering
# -----------------------------
class LocationRoomReorderRequest(BaseModel):
room_ids: list[str]
def ensure_room_order_ready():
conn = None
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute(
"""
ALTER TABLE rooms
ADD COLUMN IF NOT EXISTS sort_order integer;
"""
)
cur.execute(
"""
WITH ordered AS (
SELECT
id,
((row_number() OVER (ORDER BY sort_order NULLS LAST, name)) - 1) * 10 AS new_sort_order
FROM rooms
)
UPDATE rooms
SET sort_order = ordered.new_sort_order
FROM ordered
WHERE rooms.id = ordered.id
AND rooms.sort_order IS NULL;
"""
)
conn.commit()
finally:
if conn is not None:
conn.close()
@app.get("/api/location/rooms-order")
def get_location_rooms_order(current_user: dict = Depends(get_current_user)):
ensure_location_tables()
ensure_room_order_ready()
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
rooms.id::text,
rooms.name,
COALESCE(rooms.sort_order, 999999) AS sort_order,
COUNT(location_positions.id)::int AS position_count,
COUNT(location_positions.id) FILTER (
WHERE location_positions.position_kind = 'miner_slot'
)::int AS miner_slot_count,
COUNT(location_positions.id) FILTER (
WHERE location_positions.position_kind = 'switch'
)::int AS switch_count,
COUNT(location_positions.id) FILTER (
WHERE location_positions.position_status = 'blocked'
OR location_positions.position_kind = 'blocked'
)::int AS blocked_count,
COUNT(location_positions.id) FILTER (
WHERE location_positions.miner_id IS NOT NULL
)::int AS assigned_miner_count
FROM rooms
LEFT JOIN location_positions
ON lower(location_positions.room_name) = lower(rooms.name)
GROUP BY rooms.id, rooms.name, rooms.sort_order
ORDER BY COALESCE(rooms.sort_order, 999999), rooms.name;
"""
)
rows = [dict(row) for row in cur.fetchall()]
return api_response(data=rows)
finally:
if conn is not None:
conn.close()
@app.post("/api/location/rooms/reorder")
def reorder_location_rooms(
request: LocationRoomReorderRequest,
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
ensure_room_order_ready()
if not request.room_ids:
raise HTTPException(status_code=400, detail="No room IDs supplied")
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
for index, room_id in enumerate(request.room_ids):
cur.execute(
"""
UPDATE rooms
SET sort_order = %s
WHERE id = %s::uuid;
""",
(
index * 10,
room_id,
),
)
cur.execute(
"""
SELECT
id::text,
name,
sort_order
FROM rooms
ORDER BY sort_order, name;
"""
)
rows = [dict(row) for row in cur.fetchall()]
conn.commit()
return api_response(data=rows)
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
# -----------------------------
# Phase 3 Step 67: Location management GUI APIs
# -----------------------------
class LocationRoomCreateRequest(BaseModel):
name: str
class LocationRackCreateRequest(BaseModel):
room_name: str
rack_name: str
shelf_count: int = 5
positions_per_shelf: int = 9
switch_shelf_index: int | None = None
switch_position_index: int | None = None
class LocationShelfCreateRequest(BaseModel):
room_name: str
rack_name: str
shelf_name: str | None = None
shelf_index: int | None = None
positions_per_shelf: int = 9
class LocationPositionTypeRequest(BaseModel):
room_name: str
rack_name: str
shelf_name: str
position_index: int
position_kind: str
equipment_label: str | None = None
notes: str | None = None
def location_get_table_columns(cur, table_name: str):
cur.execute(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_name = %s;
""",
(table_name,),
)
return {row["column_name"] if isinstance(row, dict) else row[0] for row in cur.fetchall()}
def find_or_create_location_room(cur, room_name: str):
clean_name = str(room_name or "").strip()
if not clean_name:
raise HTTPException(status_code=400, detail="Room name is required")
cur.execute(
"""
SELECT id::text, name
FROM rooms
WHERE lower(name) = lower(%s)
LIMIT 1;
""",
(clean_name,),
)
existing = cur.fetchone()
if existing:
return existing["id"], existing["name"], False
room_columns = location_get_table_columns(cur, "rooms")
insert_columns = []
insert_values_sql = []
values = []
def add_value(column_name, value, sql_cast=None):
if column_name not in room_columns:
return
insert_columns.append(column_name)
if sql_cast:
insert_values_sql.append(f"%s::{sql_cast}")
else:
insert_values_sql.append("%s")
values.append(value)
def add_now(column_name):
if column_name not in room_columns:
return
insert_columns.append(column_name)
insert_values_sql.append("now()")
room_id = str(uuid4())
add_value("id", room_id, "uuid")
add_value("name", clean_name)
if "site_id" in room_columns:
cur.execute(
"""
SELECT id::text
FROM sites
ORDER BY name
LIMIT 1;
"""
)
site = cur.fetchone()
if not site:
raise HTTPException(status_code=400, detail="No site exists to attach the room to")
site_id = site["id"] if isinstance(site, dict) else site[0]
add_value("site_id", site_id, "uuid")
if "sort_order" in room_columns:
cur.execute(
"""
SELECT COALESCE(MAX(sort_order), -10) + 10
FROM rooms;
"""
)
next_sort_order = cur.fetchone()[0]
add_value("sort_order", next_sort_order)
add_value("is_enabled", True)
add_value("enabled", True)
add_now("created_at")
add_now("updated_at")
sql = f"""
INSERT INTO rooms ({", ".join(insert_columns)})
VALUES ({", ".join(insert_values_sql)})
RETURNING id::text, name;
"""
cur.execute(sql, values)
created = cur.fetchone()
return created["id"], created["name"], True
def make_default_shelf_name(shelf_index: int):
if int(shelf_index) == 1:
return "shelf1(top)"
return f"shelf{int(shelf_index)}"
def create_location_slot(
cur,
room_id,
room_name,
rack_name,
shelf_name,
shelf_index,
position_index,
position_kind="miner_slot",
position_status="available",
equipment_label=None,
notes=None,
):
cur.execute(
"""
INSERT INTO location_positions (
id,
room_id,
room_name,
rack_name,
shelf_name,
shelf_index,
position_index,
position_label,
position_kind,
position_status,
equipment_label,
notes,
source_file,
raw_value,
updated_at
)
VALUES (
%s::uuid,
%s::uuid,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
'manual_gui',
%s,
now()
)
ON CONFLICT (room_name, rack_name, shelf_name, position_index)
DO NOTHING
RETURNING id::text;
""",
(
str(uuid4()),
room_id,
room_name,
rack_name,
shelf_name,
shelf_index,
position_index,
f"Pos {position_index}",
position_kind,
position_status,
equipment_label,
notes,
equipment_label or notes,
),
)
row = cur.fetchone()
return row["id"] if row else None
@app.post("/api/location/rooms/create")
def create_location_room(
request: LocationRoomCreateRequest,
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
ensure_room_order_ready()
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
room_id, room_name, was_created = find_or_create_location_room(cur, request.name)
conn.commit()
return api_response(
data={
"id": room_id,
"name": room_name,
"created": was_created,
}
)
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
@app.post("/api/location/racks/create")
def create_location_rack(
request: LocationRackCreateRequest,
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
ensure_room_order_ready()
room_name = str(request.room_name or "").strip()
rack_name = str(request.rack_name or "").strip()
if not room_name:
raise HTTPException(status_code=400, detail="Room name is required")
if not rack_name:
raise HTTPException(status_code=400, detail="Rack name is required")
shelf_count = max(1, min(int(request.shelf_count or 1), 20))
positions_per_shelf = max(1, min(int(request.positions_per_shelf or 1), 50))
conn = None
try:
conn = get_db_connection()
created_positions = 0
skipped_positions = 0
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
room_id, room_name, room_created = find_or_create_location_room(cur, room_name)
for shelf_index in range(1, shelf_count + 1):
shelf_name = make_default_shelf_name(shelf_index)
for position_index in range(1, positions_per_shelf + 1):
position_kind = "miner_slot"
position_status = "available"
equipment_label = None
notes = None
if (
request.switch_shelf_index is not None
and request.switch_position_index is not None
and int(request.switch_shelf_index) == shelf_index
and int(request.switch_position_index) == position_index
):
position_kind = "switch"
position_status = "switch"
equipment_label = f"Switch {rack_name}"
notes = "Created from location management GUI."
position_id = create_location_slot(
cur,
room_id,
room_name,
rack_name,
shelf_name,
shelf_index,
position_index,
position_kind,
position_status,
equipment_label,
notes,
)
if position_id:
created_positions += 1
else:
skipped_positions += 1
conn.commit()
return api_response(
data={
"room_name": room_name,
"rack_name": rack_name,
"shelf_count": shelf_count,
"positions_per_shelf": positions_per_shelf,
"created_positions": created_positions,
"skipped_existing_positions": skipped_positions,
}
)
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
@app.post("/api/location/shelves/create")
def create_location_shelf(
request: LocationShelfCreateRequest,
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
ensure_room_order_ready()
room_name = str(request.room_name or "").strip()
rack_name = str(request.rack_name or "").strip()
if not room_name:
raise HTTPException(status_code=400, detail="Room name is required")
if not rack_name:
raise HTTPException(status_code=400, detail="Rack name is required")
positions_per_shelf = max(1, min(int(request.positions_per_shelf or 1), 50))
conn = None
try:
conn = get_db_connection()
created_positions = 0
skipped_positions = 0
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
room_id, room_name, room_created = find_or_create_location_room(cur, room_name)
if request.shelf_index is None:
cur.execute(
"""
SELECT COALESCE(MAX(shelf_index), 0) + 1 AS next_shelf_index
FROM location_positions
WHERE lower(room_name) = lower(%s)
AND lower(rack_name) = lower(%s);
""",
(
room_name,
rack_name,
),
)
shelf_index = int(cur.fetchone()["next_shelf_index"])
else:
shelf_index = max(1, int(request.shelf_index))
shelf_name = str(request.shelf_name or "").strip()
if not shelf_name:
shelf_name = make_default_shelf_name(shelf_index)
for position_index in range(1, positions_per_shelf + 1):
position_id = create_location_slot(
cur,
room_id,
room_name,
rack_name,
shelf_name,
shelf_index,
position_index,
"miner_slot",
"available",
None,
None,
)
if position_id:
created_positions += 1
else:
skipped_positions += 1
conn.commit()
return api_response(
data={
"room_name": room_name,
"rack_name": rack_name,
"shelf_name": shelf_name,
"shelf_index": shelf_index,
"positions_per_shelf": positions_per_shelf,
"created_positions": created_positions,
"skipped_existing_positions": skipped_positions,
}
)
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
@app.post("/api/location/positions/set-type")
def set_location_position_type(
request: LocationPositionTypeRequest,
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
room_name = str(request.room_name or "").strip()
rack_name = str(request.rack_name or "").strip()
shelf_name = str(request.shelf_name or "").strip()
position_index = int(request.position_index or 0)
position_kind = str(request.position_kind or "").strip().lower()
if position_kind not in ("miner_slot", "switch", "blocked"):
raise HTTPException(status_code=400, detail="position_kind must be miner_slot, switch, or blocked")
if not room_name or not rack_name or not shelf_name or position_index <= 0:
raise HTTPException(status_code=400, detail="Room, rack, shelf, and position are required")
if position_kind == "switch":
position_status = "switch"
equipment_label = request.equipment_label or f"Switch {rack_name}"
notes = request.notes or "Switch position."
elif position_kind == "blocked":
position_status = "blocked"
equipment_label = request.equipment_label
notes = request.notes or "Blocked/bad spot."
else:
equipment_label = request.equipment_label
notes = request.notes
conn_check = None
try:
conn_check = get_db_connection()
with conn_check.cursor(row_factory=psycopg.rows.dict_row) as check_cur:
check_cur.execute(
"""
SELECT miner_id
FROM location_positions
WHERE lower(room_name) = lower(%s)
AND lower(rack_name) = lower(%s)
AND lower(shelf_name) = lower(%s)
AND position_index = %s
LIMIT 1;
""",
(
room_name,
rack_name,
shelf_name,
position_index,
),
)
existing_position = check_cur.fetchone()
if existing_position and existing_position.get("miner_id"):
position_status = "occupied"
else:
position_status = "available"
finally:
if conn_check is not None:
conn_check.close()
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
UPDATE location_positions
SET
position_kind = %s,
position_status = %s,
equipment_label = %s,
notes = %s,
updated_at = now()
WHERE lower(room_name) = lower(%s)
AND lower(rack_name) = lower(%s)
AND lower(shelf_name) = lower(%s)
AND position_index = %s
RETURNING
id::text,
room_name,
rack_name,
shelf_name,
position_index,
position_kind,
position_status,
equipment_label,
notes;
""",
(
position_kind,
position_status,
equipment_label,
notes,
room_name,
rack_name,
shelf_name,
position_index,
),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Location position not found")
row = dict(row)
conn.commit()
return api_response(data=row)
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
# -----------------------------
# Phase 3 Step 67.1: Safe location delete APIs
# -----------------------------
class LocationRackDeleteRequest(BaseModel):
room_name: str
rack_name: str
allow_delete_occupied: bool = False
class LocationRoomDeleteRequest(BaseModel):
room_name: str
allow_delete_occupied: bool = False
delete_room_record: bool = True
@app.post("/api/location/racks/delete")
def delete_location_rack(
request: LocationRackDeleteRequest,
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
room_name = str(request.room_name or "").strip()
rack_name = str(request.rack_name or "").strip()
if not room_name or not rack_name:
raise HTTPException(status_code=400, detail="Room name and rack name are required")
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
COUNT(*)::int AS position_count,
COUNT(*) FILTER (WHERE miner_id IS NOT NULL)::int AS assigned_miner_count
FROM location_positions
WHERE lower(room_name) = lower(%s)
AND lower(rack_name) = lower(%s);
""",
(
room_name,
rack_name,
),
)
counts = dict(cur.fetchone())
if counts["position_count"] == 0:
raise HTTPException(status_code=404, detail="Rack not found")
if counts["assigned_miner_count"] > 0 and not request.allow_delete_occupied:
raise HTTPException(
status_code=400,
detail="Rack has assigned miners. Move miners first before deleting the rack.",
)
if request.allow_delete_occupied:
cur.execute(
"""
UPDATE miner_location_states
SET
asset_state = 'waiting_placement',
location_position_id = NULL,
updated_at = now()
WHERE location_position_id IN (
SELECT id
FROM location_positions
WHERE lower(room_name) = lower(%s)
AND lower(rack_name) = lower(%s)
);
""",
(
room_name,
rack_name,
),
)
cur.execute(
"""
DELETE FROM location_positions
WHERE lower(room_name) = lower(%s)
AND lower(rack_name) = lower(%s);
""",
(
room_name,
rack_name,
),
)
deleted_positions = cur.rowcount
conn.commit()
return api_response(
data={
"room_name": room_name,
"rack_name": rack_name,
"deleted_positions": deleted_positions,
"assigned_miner_count": counts["assigned_miner_count"],
}
)
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
@app.post("/api/location/rooms/delete")
def delete_location_room(
request: LocationRoomDeleteRequest,
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
room_name = str(request.room_name or "").strip()
if not room_name:
raise HTTPException(status_code=400, detail="Room name is required")
conn = None
try:
conn = get_db_connection()
room_deleted = False
room_delete_reason = None
room_id = None
miner_reference_count = 0
scan_range_reference_count = 0
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
COUNT(*)::int AS position_count,
COUNT(*) FILTER (WHERE miner_id IS NOT NULL)::int AS assigned_miner_count
FROM location_positions
WHERE lower(room_name) = lower(%s);
""",
(room_name,),
)
counts = dict(cur.fetchone())
if counts["position_count"] == 0:
cur.execute(
"""
SELECT id::text
FROM rooms
WHERE lower(name) = lower(%s)
LIMIT 1;
""",
(room_name,),
)
existing_room = cur.fetchone()
if not existing_room:
raise HTTPException(status_code=404, detail="Room not found")
if counts["assigned_miner_count"] > 0 and not request.allow_delete_occupied:
raise HTTPException(
status_code=400,
detail="Room has assigned miners. Move miners first before deleting the room map.",
)
if request.allow_delete_occupied:
cur.execute(
"""
UPDATE miner_location_states
SET
asset_state = 'waiting_placement',
location_position_id = NULL,
updated_at = now()
WHERE location_position_id IN (
SELECT id
FROM location_positions
WHERE lower(room_name) = lower(%s)
);
""",
(room_name,),
)
cur.execute(
"""
DELETE FROM location_positions
WHERE lower(room_name) = lower(%s);
""",
(room_name,),
)
deleted_positions = cur.rowcount
cur.execute(
"""
SELECT id::text
FROM rooms
WHERE lower(name) = lower(%s)
LIMIT 1;
""",
(room_name,),
)
room = cur.fetchone()
if room:
room_id = room["id"]
if request.delete_room_record and room_id:
cur.execute(
"""
SELECT COUNT(*)::int AS count
FROM miners
WHERE room_id = %s::uuid;
""",
(room_id,),
)
miner_reference_count = cur.fetchone()["count"]
cur.execute(
"""
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'scan_ranges'
AND column_name = 'room_id'
) AS has_scan_range_room_id;
"""
)
has_scan_range_room_id = cur.fetchone()["has_scan_range_room_id"]
if has_scan_range_room_id:
cur.execute(
"""
SELECT COUNT(*)::int AS count
FROM scan_ranges
WHERE room_id = %s::uuid;
""",
(room_id,),
)
scan_range_reference_count = cur.fetchone()["count"]
if miner_reference_count == 0 and scan_range_reference_count == 0:
cur.execute(
"""
DELETE FROM rooms
WHERE id = %s::uuid;
""",
(room_id,),
)
room_deleted = cur.rowcount > 0
else:
room_delete_reason = (
"Room map positions were deleted, but the room record was kept because "
f"{miner_reference_count} miners and {scan_range_reference_count} scan ranges still reference it."
)
conn.commit()
return api_response(
data={
"room_name": room_name,
"deleted_positions": deleted_positions,
"assigned_miner_count": counts["assigned_miner_count"],
"room_record_deleted": room_deleted,
"room_delete_reason": room_delete_reason,
"miner_reference_count": miner_reference_count,
"scan_range_reference_count": scan_range_reference_count,
}
)
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
# -----------------------------
# Phase 3 Step 68: Spreadsheet layout import
# -----------------------------
class LocationSpreadsheetImportRequest(BaseModel):
file_name: str
file_base64: str
room_name_override: str | None = None
def xlsx_column_name_to_number(cell_ref: str):
letters = ""
for ch in str(cell_ref):
if ch.isalpha():
letters += ch.upper()
else:
break
value = 0
for ch in letters:
value = value * 26 + (ord(ch) - ord("A") + 1)
return value
def parse_xlsx_shared_strings(zf):
import xml.etree.ElementTree as ET
try:
data = zf.read("xl/sharedStrings.xml")
except KeyError:
return []
root = ET.fromstring(data)
namespace = {"main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
strings = []
for si in root.findall("main:si", namespace):
text_parts = []
for text_node in si.findall(".//main:t", namespace):
text_parts.append(text_node.text or "")
strings.append("".join(text_parts))
return strings
def parse_xlsx_workbook_sheet_names(zf):
import xml.etree.ElementTree as ET
try:
workbook_data = zf.read("xl/workbook.xml")
rels_data = zf.read("xl/_rels/workbook.xml.rels")
except KeyError:
return [("Sheet1", "xl/worksheets/sheet1.xml")]
ns_main = {"main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
ns_rel = {"rel": "http://schemas.openxmlformats.org/package/2006/relationships"}
workbook_root = ET.fromstring(workbook_data)
rels_root = ET.fromstring(rels_data)
rel_map = {}
for rel in rels_root.findall("rel:Relationship", ns_rel):
rel_id = rel.attrib.get("Id")
target = rel.attrib.get("Target")
if rel_id and target:
if target.startswith("/"):
path = target.lstrip("/")
elif target.startswith("xl/"):
path = target
else:
path = "xl/" + target
rel_map[rel_id] = path
sheets = []
for sheet in workbook_root.findall(".//main:sheet", ns_main):
name = sheet.attrib.get("name", "Sheet")
rel_id = sheet.attrib.get("{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id")
path = rel_map.get(rel_id)
if path:
sheets.append((name, path))
if not sheets:
return [("Sheet1", "xl/worksheets/sheet1.xml")]
return sheets
def parse_xlsx_sheet_rows(zf, sheet_path, shared_strings):
import xml.etree.ElementTree as ET
try:
data = zf.read(sheet_path)
except KeyError:
return []
root = ET.fromstring(data)
namespace = {"main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
rows = []
for row in root.findall(".//main:sheetData/main:row", namespace):
row_number = int(row.attrib.get("r", "0"))
cells = {}
for cell in row.findall("main:c", namespace):
cell_ref = cell.attrib.get("r", "")
col_number = xlsx_column_name_to_number(cell_ref)
cell_type = cell.attrib.get("t")
value_node = cell.find("main:v", namespace)
value = None
if value_node is not None:
raw_value = value_node.text or ""
if cell_type == "s":
try:
value = shared_strings[int(raw_value)]
except Exception:
value = raw_value
else:
value = raw_value
inline_node = cell.find("main:is/main:t", namespace)
if inline_node is not None:
value = inline_node.text or ""
if value is not None:
cells[col_number] = str(value).strip()
if cells:
rows.append(
{
"row_number": row_number,
"cells": cells,
}
)
return rows
def parse_location_layout_xlsx(file_bytes: bytes, file_name: str, room_name_override: str | None = None):
import zipfile
import io
positions = []
parse_warnings = []
parsed_room_names = set()
parsed_rack_names = set()
with zipfile.ZipFile(io.BytesIO(file_bytes), "r") as zf:
shared_strings = parse_xlsx_shared_strings(zf)
sheets = parse_xlsx_workbook_sheet_names(zf)
for sheet_name, sheet_path in sheets:
rows = parse_xlsx_sheet_rows(zf, sheet_path, shared_strings)
current_room_name = room_name_override
current_rack_name = None
shelf_index_for_rack = 0
for row in rows:
cells = row["cells"]
first_col = min(cells.keys())
first_value = str(cells.get(first_col, "") or "").strip()
if not first_value:
continue
first_lower = first_value.lower()
if first_lower.startswith("unit ") or first_lower.startswith("room "):
if not room_name_override:
current_room_name = first_value
parsed_room_names.add(current_room_name)
continue
if first_lower.startswith("rack ") or first_lower in ("bathroom", "office"):
current_rack_name = first_value
parsed_rack_names.add(current_rack_name)
shelf_index_for_rack = 0
continue
if first_lower.startswith("shelf"):
if not current_room_name:
parse_warnings.append(f"{file_name} {sheet_name} row {row['row_number']}: shelf found before unit/room name.")
continue
if not current_rack_name:
parse_warnings.append(f"{file_name} {sheet_name} row {row['row_number']}: shelf found before rack name.")
continue
shelf_index_for_rack += 1
shelf_name = first_value
max_col = max(cells.keys()) if cells else first_col
for col_number in range(first_col + 1, max_col + 1):
raw_value = str(cells.get(col_number, "") or "").strip()
position_index = col_number - first_col
source_cell = f"R{row['row_number']}C{col_number}"
item_type = "empty"
position_status = "available"
mac_address = None
label = None
notes = None
conflict_reason = None
cleaned_mac = "".join(ch for ch in raw_value.lower() if ch in "0123456789abcdef")
if raw_value:
lower_value = raw_value.lower()
if "switch" in lower_value:
item_type = "switch"
position_status = "switch"
label = raw_value
elif "bad" in lower_value or "blocked" in lower_value or "unusable" in lower_value:
item_type = "blocked"
position_status = "blocked"
label = raw_value
notes = "Imported as permanent blocked/bad spot."
elif len(cleaned_mac) == 12:
item_type = "miner"
position_status = "occupied"
mac_address = normalize_mac_for_location(raw_value)
else:
item_type = "blocked"
position_status = "blocked"
label = raw_value
notes = "Imported as blocked because the cell was not empty, switch, or valid MAC."
positions.append(
{
"room_name": current_room_name,
"rack_name": current_rack_name,
"shelf_name": shelf_name,
"shelf_index": shelf_index_for_rack,
"position_index": position_index,
"position_label": f"Pos {position_index}",
"item_type": item_type,
"position_status": position_status,
"mac_address": mac_address,
"label": label,
"notes": notes,
"conflict_reason": conflict_reason,
"source_file": file_name,
"source_sheet": sheet_name,
"source_cell": source_cell,
"raw_value": raw_value,
}
)
return {
"positions": positions,
"parse_warnings": parse_warnings,
"parsed_room_names": sorted(list(parsed_room_names)),
"parsed_rack_names": sorted(list(parsed_rack_names)),
}
@app.post("/api/location/import-spreadsheet")
def import_location_spreadsheet(
request: LocationSpreadsheetImportRequest,
current_user: dict = Depends(get_current_user),
):
import base64
ensure_location_tables()
if not request.file_base64:
raise HTTPException(status_code=400, detail="No spreadsheet file data supplied")
file_name = request.file_name or "uploaded-layout.xlsx"
if not file_name.lower().endswith(".xlsx"):
raise HTTPException(status_code=400, detail="Only .xlsx files are supported")
try:
file_bytes = base64.b64decode(request.file_base64)
except Exception:
raise HTTPException(status_code=400, detail="Could not decode uploaded spreadsheet")
parsed = parse_location_layout_xlsx(
file_bytes,
file_name,
request.room_name_override,
)
positions = parsed["positions"]
if not positions:
raise HTTPException(status_code=400, detail="No layout positions found in spreadsheet")
seed_request = LocationSeedImportRequest(
schema_version=1,
generated_from=[file_name],
notes="Imported from Location Map spreadsheet upload GUI.",
positions=positions,
)
result = import_location_layout_seed(seed_request, current_user)
if isinstance(result, dict) and result.get("data") is not None:
result["data"]["spreadsheet_parse"] = {
"file_name": file_name,
"positions_parsed": len(positions),
"parse_warnings": parsed["parse_warnings"],
"parsed_room_names": parsed["parsed_room_names"],
"parsed_rack_names": parsed["parsed_rack_names"],
"room_name_override": request.room_name_override,
}
return result
# -----------------------------
# Phase 3 Step 69: Move miners between physical and virtual locations
# -----------------------------
class MinerLocationMoveRequest(BaseModel):
miner_id: str | None = None
mac_address: str | None = None
destination_type: str = "physical"
location_position_id: str | None = None
room_name: str | None = None
rack_name: str | None = None
shelf_name: str | None = None
position_index: int | None = None
asset_state: str | None = None
notes: str | None = None
def find_miner_for_location_move(cur, miner_id: str | None, mac_address: str | None):
if miner_id:
cur.execute(
"""
SELECT
id::text,
current_mac,
host(current_ip) AS current_ip,
room_id::text
FROM miners
WHERE id = %s::uuid
LIMIT 1;
""",
(miner_id,),
)
row = cur.fetchone()
if row:
return dict(row)
if mac_address:
mac_key = normalize_mac_key_for_location(mac_address)
if not mac_key:
raise HTTPException(status_code=400, detail="MAC address is invalid")
cur.execute(
"""
SELECT
id::text,
current_mac,
host(current_ip) AS current_ip,
room_id::text
FROM miners
WHERE regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g') = %s
LIMIT 1;
""",
(mac_key,),
)
row = cur.fetchone()
if row:
return dict(row)
return None
def describe_location_position(row):
if not row:
return "Unplaced"
return (
f"{row.get('room_name') or '-'} / "
f"{row.get('rack_name') or '-'} / "
f"{row.get('shelf_name') or '-'} / "
f"Pos {row.get('position_index') or '-'}"
)
@app.post("/api/location/miners/move")
def move_miner_location(
request: MinerLocationMoveRequest,
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
ensure_maintenance_tables()
valid_virtual_states = {
"waiting_placement",
"storage",
"repair",
"decommissioned",
}
destination_type = str(request.destination_type or "physical").strip().lower()
if destination_type not in ("physical", "virtual"):
raise HTTPException(status_code=400, detail="destination_type must be physical or virtual")
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
miner = find_miner_for_location_move(cur, request.miner_id, request.mac_address)
if not miner:
raise HTTPException(status_code=404, detail="Miner not found")
miner_id = miner["id"]
current_mac = miner.get("current_mac")
cur.execute(
"""
SELECT
id::text,
room_name,
rack_name,
shelf_name,
position_index
FROM location_positions
WHERE miner_id = %s::uuid
ORDER BY room_name, rack_name, shelf_index, position_index;
""",
(miner_id,),
)
old_positions = [dict(row) for row in cur.fetchall()]
old_location_text = " / ".join([describe_location_position(row) for row in old_positions]) if old_positions else "Unplaced"
new_location_text = None
target_position_id = None
target_room_name = None
# Clear current physical positions first. If anything fails later, rollback restores them.
cur.execute(
"""
UPDATE location_positions
SET
miner_id = NULL,
expected_mac = NULL,
position_status = CASE
WHEN position_kind = 'miner_slot' THEN 'available'
ELSE position_status
END,
conflict_reason = NULL,
updated_at = now()
WHERE miner_id = %s::uuid;
""",
(miner_id,),
)
if destination_type == "physical":
if request.location_position_id:
cur.execute(
"""
SELECT
id::text,
room_id::text,
room_name,
rack_name,
shelf_name,
shelf_index,
position_index,
position_kind,
position_status,
miner_id::text AS assigned_miner_id,
expected_mac
FROM location_positions
WHERE id = %s::uuid
FOR UPDATE;
""",
(request.location_position_id,),
)
else:
if not request.room_name or not request.rack_name or not request.shelf_name or not request.position_index:
raise HTTPException(
status_code=400,
detail="Physical moves require room, rack, shelf, and position",
)
cur.execute(
"""
SELECT
id::text,
room_id::text,
room_name,
rack_name,
shelf_name,
shelf_index,
position_index,
position_kind,
position_status,
miner_id::text AS assigned_miner_id,
expected_mac
FROM location_positions
WHERE lower(room_name) = lower(%s)
AND lower(rack_name) = lower(%s)
AND lower(shelf_name) = lower(%s)
AND position_index = %s
FOR UPDATE;
""",
(
request.room_name,
request.rack_name,
request.shelf_name,
request.position_index,
),
)
target = cur.fetchone()
if not target:
raise HTTPException(status_code=404, detail="Target physical position not found")
target = dict(target)
if target.get("position_kind") != "miner_slot":
raise HTTPException(status_code=400, detail="Target position is not a miner slot")
if target.get("position_status") in ("switch", "blocked"):
raise HTTPException(status_code=400, detail="Target position is switch/blocked")
assigned_miner_id = target.get("assigned_miner_id")
if assigned_miner_id and assigned_miner_id != miner_id:
raise HTTPException(status_code=400, detail="Target position is already occupied")
target_position_id = target["id"]
target_room_name = target["room_name"]
new_location_text = describe_location_position(target)
cur.execute(
"""
UPDATE location_positions
SET
miner_id = %s::uuid,
expected_mac = %s,
position_status = 'occupied',
conflict_reason = NULL,
updated_at = now()
WHERE id = %s::uuid;
""",
(
miner_id,
current_mac,
target_position_id,
),
)
cur.execute(
"""
SELECT id::text
FROM rooms
WHERE lower(name) = lower(%s)
LIMIT 1;
""",
(target_room_name,),
)
room = cur.fetchone()
room_id = room["id"] if room else None
if room_id:
cur.execute(
"""
UPDATE miners
SET room_id = %s::uuid
WHERE id = %s::uuid;
""",
(
room_id,
miner_id,
),
)
cur.execute(
"""
INSERT INTO miner_location_states (
miner_id,
asset_state,
location_position_id,
state_notes,
updated_by_user_id,
updated_at
)
VALUES (
%s::uuid,
'shelved',
%s::uuid,
%s,
%s::uuid,
now()
)
ON CONFLICT (miner_id)
DO UPDATE SET
asset_state = 'shelved',
location_position_id = EXCLUDED.location_position_id,
state_notes = EXCLUDED.state_notes,
updated_by_user_id = EXCLUDED.updated_by_user_id,
updated_at = now();
""",
(
miner_id,
target_position_id,
request.notes,
get_current_user_id_for_location(current_user),
),
)
new_asset_state = "shelved"
else:
asset_state = str(request.asset_state or "").strip().lower()
if asset_state not in valid_virtual_states:
raise HTTPException(
status_code=400,
detail="asset_state must be waiting_placement, storage, repair, or decommissioned",
)
cur.execute(
"""
INSERT INTO miner_location_states (
miner_id,
asset_state,
location_position_id,
state_notes,
updated_by_user_id,
updated_at
)
VALUES (
%s::uuid,
%s,
NULL,
%s,
%s::uuid,
now()
)
ON CONFLICT (miner_id)
DO UPDATE SET
asset_state = EXCLUDED.asset_state,
location_position_id = NULL,
state_notes = EXCLUDED.state_notes,
updated_by_user_id = EXCLUDED.updated_by_user_id,
updated_at = now();
""",
(
miner_id,
asset_state,
request.notes,
get_current_user_id_for_location(current_user),
),
)
new_location_text = asset_state
new_asset_state = asset_state
event_description = (
f"Miner moved from {old_location_text} to {new_location_text}."
)
if request.notes:
event_description += f" Notes: {request.notes}"
cur.execute(
"""
INSERT INTO miner_maintenance_events (
id,
miner_id,
event_type,
event_status,
event_title,
description,
event_at,
component_type,
component_slot,
created_by_user_id
)
VALUES (
%s::uuid,
%s::uuid,
'moved',
'completed',
'Miner location changed',
%s,
now(),
'location',
%s,
%s::uuid
)
RETURNING id::text;
""",
(
str(uuid4()),
miner_id,
event_description,
new_location_text,
get_current_user_id_for_location(current_user),
),
)
event = cur.fetchone()
conn.commit()
return api_response(
data={
"miner_id": miner_id,
"current_mac": current_mac,
"old_location": old_location_text,
"new_location": new_location_text,
"asset_state": new_asset_state,
"location_position_id": target_position_id,
"maintenance_event_id": event["id"] if event else None,
}
)
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
# -----------------------------
# Step 86: Miner command control foundation
# -----------------------------
from fastapi import Request, HTTPException
async def minerops_command_db_connect():
import os
import asyncpg
database_url = os.environ.get("DATABASE_URL") or globals().get("DATABASE_URL")
if not database_url:
raise RuntimeError("DATABASE_URL is not configured.")
# asyncpg does not accept SQLAlchemy driver names like postgresql+psycopg://
database_url = database_url.replace("postgresql+psycopg://", "postgresql://", 1)
database_url = database_url.replace("postgresql+asyncpg://", "postgresql://", 1)
database_url = database_url.replace("postgres://", "postgresql://", 1)
return await asyncpg.connect(database_url)
async def ensure_miner_command_tables():
conn = await minerops_command_db_connect()
try:
await conn.execute("""
CREATE TABLE IF NOT EXISTS miner_command_events (
id TEXT PRIMARY KEY,
miner_id TEXT NOT NULL,
command_type TEXT NOT NULL,
command_status TEXT NOT NULL,
dry_run BOOLEAN NOT NULL DEFAULT TRUE,
requested_by_user_id TEXT,
requested_by_username TEXT,
target_ip TEXT,
target_mac TEXT,
request_payload JSONB,
response_payload JSONB,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ
);
""")
await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_miner_command_events_miner_id_created
ON miner_command_events (miner_id, created_at DESC);
""")
finally:
await conn.close()
async def get_miner_command_target(miner_id: str):
conn = await minerops_command_db_connect()
try:
columns = await conn.fetch("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'miners'
""")
column_names = {row["column_name"] for row in columns}
def pick_column(candidates):
for candidate in candidates:
if candidate in column_names:
return candidate
return None
ip_col = pick_column([
"current_ip",
"ip_address",
"ip",
"last_ip",
"network_ip"
])
mac_col = pick_column([
"current_mac",
"mac_address",
"mac",
"expected_mac",
"network_mac"
])
model_col = pick_column([
"model",
"miner_model",
"miner_type",
"hardware_model"
])
select_parts = ["id::text AS miner_id"]
if ip_col:
select_parts.append(f"{ip_col}::text AS target_ip")
else:
select_parts.append("NULL::text AS target_ip")
if mac_col:
select_parts.append(f"{mac_col}::text AS target_mac")
else:
select_parts.append("NULL::text AS target_mac")
if model_col:
select_parts.append(f"{model_col}::text AS model")
else:
select_parts.append("NULL::text AS model")
sql = f"""
SELECT {", ".join(select_parts)}
FROM miners
WHERE id::text = $1
LIMIT 1
"""
row = await conn.fetchrow(sql, miner_id)
if not row:
return None
return dict(row)
finally:
await conn.close()
async def insert_miner_command_event(
miner_id: str,
command_type: str,
command_status: str,
dry_run: bool,
target_ip: str | None = None,
target_mac: str | None = None,
requested_by_user_id: str | None = None,
requested_by_username: str | None = None,
request_payload: dict | None = None,
response_payload: dict | None = None,
error_message: str | None = None,
):
import json
import uuid
await ensure_miner_command_tables()
event_id = str(uuid.uuid4())
conn = await minerops_command_db_connect()
try:
await conn.execute(
"""
INSERT INTO miner_command_events (
id,
miner_id,
command_type,
command_status,
dry_run,
requested_by_user_id,
requested_by_username,
target_ip,
target_mac,
request_payload,
response_payload,
error_message,
completed_at
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
$10::jsonb,
$11::jsonb,
$12,
now()
)
""",
event_id,
miner_id,
command_type,
command_status,
dry_run,
requested_by_user_id,
requested_by_username,
target_ip,
target_mac,
json.dumps(request_payload or {}),
json.dumps(response_payload or {}),
error_message,
)
finally:
await conn.close()
return event_id
def minerops_command_require_bearer(request: Request):
auth_header = request.headers.get("authorization") or ""
cookie_token = request.cookies.get(SESSION_COOKIE) or ""
header_token = auth_header.split(" ", 1)[1].strip() if auth_header.lower().startswith("bearer ") else ""
if not cookie_token and (not header_token or header_token == COOKIE_TOKEN_HINT):
raise HTTPException(status_code=401, detail="Login required.")
async def minerops_command_user_from_request(request: Request):
username = None
user_id = None
# Best effort only. The existing auth system already owns real auth.
# This command layer requires a bearer token and records who the UI says is signed in when available.
try:
username = request.headers.get("x-minerops-username")
except Exception:
username = None
if not username:
username = "authenticated-user"
return user_id, username
@app.on_event("startup")
async def startup_ensure_miner_command_tables():
await ensure_miner_command_tables()
# -----------------------------
# Step 87: Real reboot command helpers
# -----------------------------
def minerops_env_enabled_default_true(name: str) -> bool:
import os
value = os.environ.get(name)
if value is None or str(value).strip() == "":
return True
value = str(value).strip().lower()
return value not in {"0", "false", "no", "off", "disabled"}
def minerops_env_enabled(name: str) -> bool:
import os
value = str(os.environ.get(name, "") or "").strip().lower()
return value in {"1", "true", "yes", "on", "enabled"}
def minerops_get_whatsminer_api_port() -> int:
import os
raw = str(os.environ.get("MINEROPS_WHATSMINER_API_PORT", "4028") or "4028").strip()
try:
return int(raw)
except Exception:
return 4028
def minerops_normalize_target_ip(target_ip: str | None) -> str | None:
if not target_ip:
return target_ip
cleaned = str(target_ip).strip()
# Stored IPs can sometimes be CIDR notation like 10.30.0.53/32.
# The socket connection needs only the host address.
if "/" in cleaned:
cleaned = cleaned.split("/", 1)[0].strip()
if cleaned.startswith("[") and cleaned.endswith("]"):
cleaned = cleaned[1:-1].strip()
return cleaned
def minerops_send_whatsminer_reboot_sync(target_ip: str, port: int = 4028, timeout_seconds: float = 10.0) -> dict:
import json
import socket
import time
target_ip = minerops_normalize_target_ip(target_ip)
if not target_ip:
raise RuntimeError("No target IP available for reboot command.")
command = {"cmd": "reboot"}
payload = json.dumps(command).encode("utf-8") + b"\n"
started = time.time()
chunks = []
with socket.create_connection((target_ip, port), timeout=timeout_seconds) as sock:
sock.settimeout(timeout_seconds)
sock.sendall(payload)
try:
sock.shutdown(socket.SHUT_WR)
except Exception:
pass
while True:
try:
chunk = sock.recv(65536)
if not chunk:
break
chunks.append(chunk)
if sum(len(item) for item in chunks) > 1048576:
break
except socket.timeout:
break
raw_response = b"".join(chunks).decode("utf-8", errors="replace").strip()
parsed_response = None
if raw_response:
try:
parsed_response = json.loads(raw_response)
except Exception:
parsed_response = None
return {
"sent": True,
"target_ip": target_ip,
"target_port": port,
"command": command,
"duration_seconds": round(time.time() - started, 3),
"raw_response": raw_response,
"json_response": parsed_response,
}
async def minerops_send_whatsminer_reboot(target_ip: str) -> dict:
import asyncio
port = minerops_get_whatsminer_api_port()
return await asyncio.to_thread(
minerops_send_whatsminer_reboot_sync,
target_ip,
port,
10.0,
)
@app.post("/api/miners/{miner_id}/commands/reboot")
async def dry_run_reboot_miner_command(miner_id: str, request: Request):
minerops_command_require_bearer(request)
try:
body = await request.json()
except Exception:
body = {}
dry_run = bool(body.get("dry_run", True))
confirm_text = str(body.get("confirm_text", "") or "").strip()
target = await get_miner_command_target(miner_id)
if not target:
raise HTTPException(status_code=404, detail="Miner not found.")
user_id, username = await minerops_command_user_from_request(request)
request_payload = {
"dry_run": dry_run,
"confirm_text": confirm_text,
"source": "miner_detail_control_card",
"real_reboot_enabled": minerops_env_enabled_default_true("MINEROPS_ENABLE_REAL_REBOOT"),
}
if dry_run:
event_id = await insert_miner_command_event(
miner_id=miner_id,
command_type="reboot",
command_status="dry_run_planned",
dry_run=True,
target_ip=target.get("target_ip"),
target_mac=target.get("target_mac"),
requested_by_user_id=user_id,
requested_by_username=username,
request_payload=request_payload,
response_payload={
"message": "Dry run only. No reboot command was sent.",
"target": target,
},
)
return {
"ok": True,
"event_id": event_id,
"miner_id": miner_id,
"command_type": "reboot",
"command_status": "dry_run_planned",
"dry_run": True,
"target_ip": target.get("target_ip"),
"target_mac": target.get("target_mac"),
"model": target.get("model"),
"message": "Dry run complete. No reboot command was sent.",
}
if not minerops_env_enabled_default_true("MINEROPS_ENABLE_REAL_REBOOT"):
event_id = await insert_miner_command_event(
miner_id=miner_id,
command_type="reboot",
command_status="blocked_real_reboot_disabled",
dry_run=False,
target_ip=target.get("target_ip"),
target_mac=target.get("target_mac"),
requested_by_user_id=user_id,
requested_by_username=username,
request_payload=request_payload,
response_payload={
"message": "Real reboot is disabled. Set MINEROPS_ENABLE_REAL_REBOOT=true to enable.",
},
error_message="Real reboot disabled by environment setting.",
)
raise HTTPException(
status_code=403,
detail={
"message": "Real reboot is disabled. Set MINEROPS_ENABLE_REAL_REBOOT=true to enable.",
"event_id": event_id,
},
)
if confirm_text != "REBOOT":
event_id = await insert_miner_command_event(
miner_id=miner_id,
command_type="reboot",
command_status="blocked_confirmation_missing",
dry_run=False,
target_ip=target.get("target_ip"),
target_mac=target.get("target_mac"),
requested_by_user_id=user_id,
requested_by_username=username,
request_payload=request_payload,
response_payload={
"message": "Confirmation text did not match REBOOT.",
},
error_message="Confirmation text did not match REBOOT.",
)
raise HTTPException(
status_code=400,
detail={
"message": "Type REBOOT to confirm.",
"event_id": event_id,
},
)
target_ip = minerops_normalize_target_ip(target.get("target_ip"))
if not target_ip:
event_id = await insert_miner_command_event(
miner_id=miner_id,
command_type="reboot",
command_status="failed_no_target_ip",
dry_run=False,
target_ip=None,
target_mac=target.get("target_mac"),
requested_by_user_id=user_id,
requested_by_username=username,
request_payload=request_payload,
response_payload={
"message": "Miner has no current IP address.",
},
error_message="Miner has no current IP address.",
)
raise HTTPException(
status_code=400,
detail={
"message": "Miner has no current IP address.",
"event_id": event_id,
},
)
request_event_id = await insert_miner_command_event(
miner_id=miner_id,
command_type="reboot",
command_status="real_reboot_requested",
dry_run=False,
target_ip=target_ip,
target_mac=target.get("target_mac"),
requested_by_user_id=user_id,
requested_by_username=username,
request_payload=request_payload,
response_payload={
"message": "Real reboot requested. Command send starting.",
},
)
try:
reboot_result = await minerops_send_whatsminer_reboot(target_ip)
result_event_id = await insert_miner_command_event(
miner_id=miner_id,
command_type="reboot",
command_status="real_reboot_sent",
dry_run=False,
target_ip=target_ip,
target_mac=target.get("target_mac"),
requested_by_user_id=user_id,
requested_by_username=username,
request_payload=request_payload,
response_payload=reboot_result,
)
return {
"ok": True,
"request_event_id": request_event_id,
"event_id": result_event_id,
"miner_id": miner_id,
"command_type": "reboot",
"command_status": "real_reboot_sent",
"dry_run": False,
"target_ip": target_ip,
"target_mac": target.get("target_mac"),
"model": target.get("model"),
"message": "Real reboot command was sent.",
"response": reboot_result,
}
except Exception as exc:
result_event_id = await insert_miner_command_event(
miner_id=miner_id,
command_type="reboot",
command_status="real_reboot_send_failed",
dry_run=False,
target_ip=target_ip,
target_mac=target.get("target_mac"),
requested_by_user_id=user_id,
requested_by_username=username,
request_payload=request_payload,
response_payload={
"message": "Real reboot command failed.",
},
error_message=str(exc),
)
raise HTTPException(
status_code=502,
detail={
"message": "Real reboot command failed.",
"event_id": result_event_id,
"error": str(exc),
},
)
@app.get("/api/miners/{miner_id}/commands")
async def list_miner_command_events(miner_id: str, request: Request):
minerops_command_require_bearer(request)
await ensure_miner_command_tables()
conn = await minerops_command_db_connect()
try:
rows = await conn.fetch(
"""
SELECT
id,
miner_id,
command_type,
command_status,
dry_run,
requested_by_user_id,
requested_by_username,
target_ip,
target_mac,
request_payload,
response_payload,
error_message,
created_at,
completed_at
FROM miner_command_events
WHERE miner_id = $1
ORDER BY created_at DESC
LIMIT 25
""",
miner_id,
)
return {
"miner_id": miner_id,
"commands": [dict(row) for row in rows],
}
finally:
await conn.close()
# -----------------------------
# Step 90: Power control commands
# -----------------------------
def minerops_whatsminer_power_command_name(action: str) -> str:
import os
if action == "power_off":
return str(os.environ.get("MINEROPS_WHATSMINER_POWER_OFF_CMD", "power_off") or "power_off").strip()
if action == "power_on":
return str(os.environ.get("MINEROPS_WHATSMINER_POWER_ON_CMD", "power_on") or "power_on").strip()
if action == "suspend":
return str(os.environ.get("MINEROPS_WHATSMINER_SUSPEND_CMD", "api3_service:stop") or "api3_service:stop").strip()
if action == "resume":
return str(os.environ.get("MINEROPS_WHATSMINER_RESUME_CMD", "api3_service:start") or "api3_service:start").strip()
raise RuntimeError(f"Unknown power action: {action}")
def minerops_send_whatsminer_command_sync(
target_ip: str,
command_name: str,
port: int = 4028,
timeout_seconds: float = 10.0,
) -> dict:
import json
import socket
import time
target_ip = minerops_normalize_target_ip(target_ip)
if not target_ip:
raise RuntimeError("No target IP available for command.")
command = {"cmd": command_name}
payload = json.dumps(command).encode("utf-8") + b"\n"
started = time.time()
chunks = []
with socket.create_connection((target_ip, port), timeout=timeout_seconds) as sock:
sock.settimeout(timeout_seconds)
sock.sendall(payload)
try:
sock.shutdown(socket.SHUT_WR)
except Exception:
pass
while True:
try:
chunk = sock.recv(65536)
if not chunk:
break
chunks.append(chunk)
if sum(len(item) for item in chunks) > 1048576:
break
except socket.timeout:
break
raw_response = b"".join(chunks).decode("utf-8", errors="replace").strip()
parsed_response = None
if raw_response:
try:
parsed_response = json.loads(raw_response)
except Exception:
parsed_response = None
return {
"sent": True,
"target_ip": target_ip,
"target_port": port,
"command": command,
"duration_seconds": round(time.time() - started, 3),
"raw_response": raw_response,
"json_response": parsed_response,
}
# -----------------------------
# Step 95: WhatsMiner API3 authenticated service control
# -----------------------------
def minerops_api3_account() -> str:
import os
return str(os.environ.get("MINEROPS_WHATSMINER_API3_ACCOUNT", "super") or "super").strip()
def minerops_api3_password() -> str:
import os
return str(os.environ.get("MINEROPS_WHATSMINER_API3_PASSWORD", "super") or "super").strip()
def minerops_api3_port() -> int:
import os
raw = str(os.environ.get("MINEROPS_WHATSMINER_API3_PORT", "4433") or "4433").strip()
try:
return int(raw)
except Exception:
return 4433
def minerops_api3_token(command: str, password: str, salt: str, ts: int) -> str:
import base64
import hashlib
source = f"{command}{password}{salt}{ts}"
digest = hashlib.sha256(source.encode("utf-8")).digest()
return base64.b64encode(digest).decode("utf-8")[:8]
def minerops_api3_recv_all(sock, length: int) -> bytes:
data = b""
while len(data) < length:
chunk = sock.recv(length - len(data))
if not chunk:
break
data += chunk
return data
def minerops_api3_send_payload_sync(target_ip: str, payload: dict, timeout_seconds: float = 10.0) -> dict:
import json
import socket
import struct
import time
target_ip = minerops_normalize_target_ip(target_ip)
if not target_ip:
raise RuntimeError("No target IP available for API3 command.")
port = minerops_api3_port()
started = time.time()
request_json = json.dumps(payload, separators=(",", ":"))
request_bytes = request_json.encode("utf-8")
with socket.create_connection((target_ip, port), timeout=timeout_seconds) as sock:
sock.settimeout(timeout_seconds)
# WhatsMiner API3 uses a 4-byte little-endian JSON length prefix.
sock.sendall(struct.pack("<I", len(request_bytes)))
sock.sendall(request_bytes)
length_bytes = minerops_api3_recv_all(sock, 4)
if len(length_bytes) != 4:
raise RuntimeError("API3 response did not include a valid length header.")
response_length = struct.unpack("<I", length_bytes)[0]
if response_length <= 0 or response_length > 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 = "<strong>New miner reminder:</strong> 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 = [
"<h3>Miner API Password</h3>",
"<p class='small'>Change this miner's API password and save it in MinerOps for future Suspend/Resume commands. Password values are not written to command history.</p>",
"<div style='display:flex;flex-wrap:wrap;gap:8px;align-items:end;'>",
" <label>Account<br><input id='api3AccountInput' value='super' autocomplete='username' style='max-width:120px;'></label>",
" <label>Current password<br><input id='api3OldPasswordInput' type='password' autocomplete='current-password'></label>",
" <label>New password<br><input id='api3NewPasswordInput' type='password' autocomplete='new-password'></label>",
" <button id='changeMinerApiPasswordButton' class='danger' type='button'>Change Password</button>",
"</div>"
].join("");
card.appendChild(reminder);
card.appendChild(box);
const button = document.getElementById("changeMinerApiPasswordButton");
if (button) {
button.onclick = changeMinerApiPassword;
}
}
async function changeMinerApiPassword() {
const minerId = minerIdFromPage();
const token = getToken();
if (!minerId) {
setStatus("Could not detect miner ID.", "bad");
return;
}
if (!token) {
setStatus("Log in first.", "bad");
return;
}
const account = (document.getElementById("api3AccountInput") || {}).value || "super";
const oldPassword = (document.getElementById("api3OldPasswordInput") || {}).value || "";
const newPassword = (document.getElementById("api3NewPasswordInput") || {}).value || "";
if (!oldPassword || !newPassword) {
setStatus("Enter the current password and new password.", "bad");
return;
}
if (!confirm("Change the API password on this miner now?")) {
setStatus("Password change cancelled.", "warn");
return;
}
setStatus("Changing miner API password...", "warn");
try {
const data = await fetchJson("/api/miners/" + encodeURIComponent(minerId) + "/api3/change-password", {
method: "POST",
headers: {
"Authorization": "Bearer " + token,
"Content-Type": "application/json"
},
body: JSON.stringify({
account: account,
old_password: oldPassword,
new_password: newPassword,
update_stored: true
})
});
document.getElementById("api3OldPasswordInput").value = "";
document.getElementById("api3NewPasswordInput").value = "";
setStatus(data.message || "Password changed and saved for this miner.", "ok");
if (typeof window.loadMinerCommandHistory === "function") {
window.loadMinerCommandHistory();
}
} catch (error) {
setStatus("Password change error: " + error.message, "bad");
if (typeof window.loadMinerCommandHistory === "function") {
window.loadMinerCommandHistory();
}
}
}
document.addEventListener("DOMContentLoaded", function () {
ensureApi3Ui();
setTimeout(ensureApi3Ui, 500);
setTimeout(ensureApi3Ui, 1500);
});
})();
"""
@app.get("/assets/minerops-api3-credentials.js")
async def minerops_api3_credentials_js():
return Response(
content=MINEROPS_API3_CREDENTIALS_JS,
media_type="application/javascript",
)
# -----------------------------
# Phase 5F: Hub mode status and UI helper
# -----------------------------
@app.get("/api/hub/mode")
async def minerops_hub_mode_status():
import os
def env_true(name: str) -> bool:
value = str(os.environ.get(name, "") or "").strip().lower()
return value in {"1", "true", "yes", "on", "enabled"}
hub_mode = env_true("MINEROPS_HUB_MODE") or env_true("MINEROPS_DISABLE_LOCAL_SCANNER")
scanner_enabled = str(os.environ.get("SCHEDULED_SCAN_ENABLED", "true") or "true").strip().lower() not in {"0", "false", "no", "off", "disabled"}
return {
"hub_mode": hub_mode,
"local_scanner_enabled": scanner_enabled,
"local_scanner_disabled": hub_mode or not scanner_enabled,
"message": "Hub Mode: Windows site agent should poll miners and push updates to this hub." if hub_mode else "Local scanner mode."
}
MINEROPS_HUB_MODE_JS = r"""
(function () {
async function fetchHubMode() {
try {
const response = await fetch("/api/hub/mode", { cache: "no-store" });
if (!response.ok) return null;
return await response.json();
} catch (error) {
return null;
}
}
function ensureBanner(data) {
if (!data || !data.hub_mode) return;
let banner = document.getElementById("mineropsHubModeBanner");
if (!banner) {
banner = document.createElement("div");
banner.id = "mineropsHubModeBanner";
banner.style.cssText = [
"position:sticky",
"top:0",
"z-index:9998",
"margin:0 0 12px 0",
"padding:10px 14px",
"border-radius:10px",
"border:1px solid #facc15",
"background:#fef9c3",
"color:#713f12",
"font-weight:700",
"box-shadow:0 2px 8px rgba(15,23,42,.12)"
].join(";");
const target = document.querySelector("main") || document.body;
target.insertBefore(banner, target.firstChild);
}
banner.textContent = "Hub Mode: local miner scanning is disabled on this server. The Windows site agent will poll miners and push updates here.";
}
function disableScanButtons(data) {
if (!data || !data.hub_mode) return;
const scanWords = [
"run scan",
"scan now",
"known-miner scan",
"run known"
];
document.querySelectorAll("button, a").forEach(function (el) {
const text = (el.textContent || "").trim().toLowerCase();
if (!text) return;
const isScanControl = scanWords.some(function (word) {
return text.includes(word);
});
if (!isScanControl) return;
el.dataset.originalText = el.dataset.originalText || el.textContent;
el.textContent = "Hub Mode: use Windows Agent";
el.title = "Local scanner is disabled on the hub. The Windows site agent will poll miners.";
el.disabled = true;
el.style.opacity = "0.65";
el.style.cursor = "not-allowed";
el.onclick = function (event) {
event.preventDefault();
alert("Hub Mode is enabled. The Windows site agent will poll miners and push updates to this hub.");
return false;
};
});
}
function updateScannerPanel(data) {
if (!data || !data.hub_mode) return;
const state = document.getElementById("scannerState");
if (state) state.textContent = "Hub Mode";
const next = document.getElementById("scannerNextRun");
if (next) next.textContent = "Windows Agent";
const started = document.getElementById("scannerLastStarted");
if (started) started.textContent = "Local scanner disabled. Waiting for Windows site agent updates.";
const raw = document.getElementById("rawOutput");
if (raw && raw.textContent && raw.textContent.includes("Scanner is idle")) {
raw.textContent = "Hub Mode: local scanner disabled. Waiting for Windows site agent updates.";
}
}
async function applyHubModeUi() {
const data = await fetchHubMode();
if (!data || !data.hub_mode) return;
ensureBanner(data);
disableScanButtons(data);
updateScannerPanel(data);
}
document.addEventListener("DOMContentLoaded", function () {
applyHubModeUi();
setTimeout(applyHubModeUi, 500);
setTimeout(applyHubModeUi, 1500);
setInterval(applyHubModeUi, 5000);
});
})();
"""
@app.get("/assets/minerops-hub-mode.js")
async def minerops_hub_mode_js():
return Response(
content=MINEROPS_HUB_MODE_JS,
media_type="application/javascript",
)
# -----------------------------
# Phase 5G: Windows site agent hub endpoints
# -----------------------------
def minerops_agent_secret_expected() -> str:
import os
return str(os.environ.get("MINEROPS_AGENT_SHARED_SECRET", "") or "").strip()
def minerops_require_agent_secret(request: Request):
import hmac
expected = minerops_agent_secret_expected()
provided = str(request.headers.get("X-MinerOps-Agent-Secret", "") or "").strip()
if not expected:
raise HTTPException(
status_code=500,
detail="MINEROPS_AGENT_SHARED_SECRET is not configured on the hub.",
)
if not provided or not hmac.compare_digest(provided, expected):
raise HTTPException(
status_code=401,
detail="Invalid or missing agent secret.",
)
async def ensure_minerops_agent_tables():
conn = await minerops_command_db_connect()
try:
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS minerops_site_agents (
agent_id TEXT PRIMARY KEY,
site_name TEXT NOT NULL,
hostname TEXT,
version TEXT,
ip_address TEXT,
last_checkin_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_payload JSONB NOT NULL DEFAULT '{}'::jsonb
)
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS minerops_agent_snapshots (
id BIGSERIAL PRIMARY KEY,
agent_id TEXT NOT NULL,
site_name TEXT NOT NULL,
snapshot_kind TEXT NOT NULL DEFAULT 'miner_poll',
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
miner_count INTEGER NOT NULL DEFAULT 0,
payload JSONB NOT NULL
)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_minerops_agent_snapshots_received
ON minerops_agent_snapshots (received_at DESC)
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS minerops_agent_command_results (
id BIGSERIAL PRIMARY KEY,
agent_id TEXT NOT NULL,
site_name TEXT NOT NULL,
command_id TEXT,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
status TEXT NOT NULL,
payload JSONB NOT NULL
)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_minerops_agent_command_results_received
ON minerops_agent_command_results (received_at DESC)
"""
)
finally:
await conn.close()
@app.on_event("startup")
async def startup_ensure_minerops_agent_tables():
await ensure_minerops_agent_tables()
@app.post("/api/agent/checkin")
async def minerops_agent_checkin(request: Request):
import json
minerops_require_agent_secret(request)
await ensure_minerops_agent_tables()
try:
body = await request.json()
except Exception:
body = {}
agent_id = str(body.get("agent_id") or "plexus-windows-agent").strip()
site_name = str(body.get("site_name") or "Plexus").strip()
hostname = str(body.get("hostname") or "").strip()
version = str(body.get("version") or "").strip()
ip_address = str(body.get("ip_address") or "").strip()
conn = await minerops_command_db_connect()
try:
await conn.execute(
"""
INSERT INTO minerops_site_agents (
agent_id,
site_name,
hostname,
version,
ip_address,
last_checkin_at,
last_payload
)
VALUES ($1, $2, $3, $4, $5, now(), $6::jsonb)
ON CONFLICT (agent_id)
DO UPDATE SET
site_name = EXCLUDED.site_name,
hostname = EXCLUDED.hostname,
version = EXCLUDED.version,
ip_address = EXCLUDED.ip_address,
last_checkin_at = now(),
last_payload = EXCLUDED.last_payload
""",
agent_id,
site_name,
hostname,
version,
ip_address,
json.dumps(body),
)
finally:
await conn.close()
return {
"ok": True,
"hub_mode": True,
"agent_id": agent_id,
"site_name": site_name,
"message": "Agent check-in received.",
}
@app.get("/api/agent/config")
async def minerops_agent_config(request: Request):
minerops_require_agent_secret(request)
return {
"ok": True,
"site_name": "Plexus",
"poll_interval_seconds": 120,
"hub_url": "https://plexus.lussier.cc",
"ip_ranges": [
{
"name": "Unit 4",
"cidr": "10.30.0.0/22",
"enabled": True,
"api_port": 4028,
"api3_port": 4433,
},
{
"name": "Unit 6",
"cidr": "10.80.0.0/22",
"enabled": True,
"api_port": 4028,
"api3_port": 4433,
},
{
"name": "Unit 12",
"cidr": "10.90.0.0/22",
"enabled": False,
"api_port": 4028,
"api3_port": 4433,
},
],
"message": "Agent config from hub.",
}
@app.post("/api/agent/miner-snapshots")
async def minerops_agent_miner_snapshots(request: Request):
import json
minerops_require_agent_secret(request)
await ensure_minerops_agent_tables()
try:
body = await request.json()
except Exception:
body = {}
agent_id = str(body.get("agent_id") or "plexus-windows-agent").strip()
site_name = str(body.get("site_name") or "Plexus").strip()
miners = body.get("miners")
if not isinstance(miners, list):
miners = []
conn = await minerops_command_db_connect()
try:
row = await conn.fetchrow(
"""
INSERT INTO minerops_agent_snapshots (
agent_id,
site_name,
snapshot_kind,
miner_count,
payload
)
VALUES ($1, $2, 'miner_poll', $3, $4::jsonb)
RETURNING id, received_at
""",
agent_id,
site_name,
len(miners),
json.dumps(body),
)
finally:
await conn.close()
apply_result = await minerops_apply_agent_snapshot_to_live_tables(body)
return {
"ok": True,
"snapshot_id": row["id"] if row else None,
"received_at": row["received_at"].isoformat() if row and row["received_at"] else None,
"miner_count": len(miners),
"apply_result": apply_result,
"message": "Miner snapshot received, staged, and applied to live tables.",
}
@app.get("/api/agent/commands/pending-disabled-old")
async def minerops_agent_commands_pending(request: Request):
minerops_require_agent_secret(request)
# Command queue will be wired in the next step.
return {
"ok": True,
"commands": [],
"message": "No pending commands.",
}
@app.post("/api/agent/commands/result-disabled-old")
async def minerops_agent_commands_result(request: Request):
import json
minerops_require_agent_secret(request)
await ensure_minerops_agent_tables()
try:
body = await request.json()
except Exception:
body = {}
agent_id = str(body.get("agent_id") or "plexus-windows-agent").strip()
site_name = str(body.get("site_name") or "Plexus").strip()
command_id = str(body.get("command_id") or "").strip()
status = str(body.get("status") or "unknown").strip()
conn = await minerops_command_db_connect()
try:
row = await conn.fetchrow(
"""
INSERT INTO minerops_agent_command_results (
agent_id,
site_name,
command_id,
status,
payload
)
VALUES ($1, $2, $3, $4, $5::jsonb)
RETURNING id, received_at
""",
agent_id,
site_name,
command_id,
status,
json.dumps(body),
)
finally:
await conn.close()
return {
"ok": True,
"result_id": row["id"] if row else None,
"received_at": row["received_at"].isoformat() if row and row["received_at"] else None,
"message": "Command result received.",
}
# -----------------------------
# Phase 5H.1: Agent setup page
# -----------------------------
@app.get("/api/agent/setup-info")
async def minerops_agent_setup_info(current_user: dict = Depends(get_current_user)):
secret = minerops_agent_secret_expected()
return {
"ok": True,
"hub_url": "https://plexus.lussier.cc",
"agent_id": "plexus-windows-agent",
"site_name": "Plexus",
"agent_secret": secret,
"secret_length": len(secret),
"config_path": "C:\\MinerOpsAgent\\agent-config.json",
"message": "Keep this secret private. It allows the Windows Agent to push updates to the hub.",
}
@app.get("/agent/setup")
async def minerops_agent_setup_page():
html = """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>MinerOps Agent Setup</title>
<link rel="stylesheet" href="/assets/minerops-unified.css">
<style>
body { font-family: Arial, sans-serif; background:#f4f7fb; color:#0f2742; margin:0; padding:24px; }
.wrap { max-width: 1100px; margin: 0 auto; }
.card { background:white; border:1px solid #d9e2ef; border-radius:14px; padding:18px; box-shadow:0 2px 8px rgba(15,23,42,.08); margin-bottom:16px; }
label { font-weight:700; display:block; margin-top:12px; }
input, textarea { width:100%; box-sizing:border-box; padding:10px; border:1px solid #cbd5e1; border-radius:8px; font-family:Consolas, monospace; }
textarea { min-height:220px; }
button { background:#0b63ce; color:white; border:0; border-radius:8px; padding:10px 14px; font-weight:700; cursor:pointer; margin-right:8px; }
button.secondary { background:#475569; }
.warn { background:#fef9c3; border:1px solid #facc15; color:#713f12; padding:12px; border-radius:10px; }
.small { color:#52637a; font-size:13px; }
.bad { background:#fee2e2; color:#7f1d1d; border:1px solid #fecaca; padding:12px; border-radius:10px; }
.ok { background:#dcfce7; color:#14532d; border:1px solid #86efac; padding:12px; border-radius:10px; }
</style>
</head>
<body>
<div class="wrap">
<div class="minerops-shell-header">
<div>
<div class="minerops-shell-kicker">MINEROPS CONSOLE</div>
<h1>Windows Agent Setup</h1>
<div class="minerops-shell-subtitle">Plexus Mining Site</div>
</div>
<nav class="minerops-shell-nav">
<a href="/dashboard">Dashboard</a>
<a href="/locations">Location Map</a>
</nav>
</div>
<div id="status" class="card">Loading...</div>
<div class="card">
<h2>Agent Connection Info</h2>
<div class="warn">
Keep this secret private. Anyone with the secret can submit agent data to this hub.
</div>
<label>Hub URL</label>
<input id="hubUrl" readonly>
<label>Agent ID</label>
<input id="agentId" readonly>
<label>Site Name</label>
<input id="siteName" readonly>
<label>Agent Shared Secret</label>
<input id="agentSecret" type="password" readonly>
<p>
<button onclick="toggleSecret()">Show / Hide Secret</button>
<button onclick="copySecret()">Copy Secret</button>
<button onclick="copyConfig()">Copy Full Config JSON</button>
</p>
</div>
<div class="card">
<h2>Windows Agent Config JSON</h2>
<p class="small">Paste this into <strong>C:\\MinerOpsAgent\\agent-config.json</strong> if you need to recreate the agent config.</p>
<textarea id="configJson" readonly></textarea>
</div>
<div class="card">
<h2>WebAPI Reminder</h2>
<div class="warn">
For every new or replaced WhatsMiner, enable WebAPI/API3 before Reboot, Suspend, Resume, password changes, or agent polling will work.
</div>
</div>
</div>
<script>
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 "";
}
async function loadSetup() {
const token = getToken();
const status = document.getElementById("status");
if (!token) {
status.className = "bad";
status.textContent = "Log in on the Dashboard first, then return to this page.";
return;
}
const response = await fetch("/api/agent/setup-info", {
headers: { "Authorization": "Bearer " + token },
cache: "no-store"
});
if (!response.ok) {
status.className = "bad";
status.textContent = "Could not load agent setup info. Log in again on the Dashboard.";
return;
}
const data = await response.json();
document.getElementById("hubUrl").value = data.hub_url || "";
document.getElementById("agentId").value = data.agent_id || "";
document.getElementById("siteName").value = data.site_name || "";
document.getElementById("agentSecret").value = data.agent_secret || "";
const config = {
agent_id: data.agent_id,
site_name: data.site_name,
hub_url: data.hub_url,
agent_secret: data.agent_secret,
poll_interval_seconds: 120,
ip_ranges: [
{ name: "Unit 4", cidr: "10.30.0.0/22", enabled: true, api_port: 4028, api3_port: 4433 },
{ name: "Unit 6", cidr: "10.80.0.0/22", enabled: true, api_port: 4028, api3_port: 4433 },
{ name: "Unit 12", cidr: "10.90.0.0/22", enabled: false, api_port: 4028, api3_port: 4433 }
]
};
document.getElementById("configJson").value = JSON.stringify(config, null, 2);
status.className = "ok";
status.textContent = "Agent setup info loaded.";
}
function toggleSecret() {
const input = document.getElementById("agentSecret");
input.type = input.type === "password" ? "text" : "password";
}
async function copySecret() {
await navigator.clipboard.writeText(document.getElementById("agentSecret").value);
alert("Secret copied.");
}
async function copyConfig() {
await navigator.clipboard.writeText(document.getElementById("configJson").value);
alert("Config JSON copied.");
}
document.addEventListener("DOMContentLoaded", loadSetup);
</script>
<script src="/assets/minerops-miner-detail-extra.js?v=1"></script>
</body>
</html>
"""
return HTMLResponse(content=html)
# -----------------------------
# Phase 5I.1: Agent known miners endpoint
# -----------------------------
@app.get("/api/agent/known-miners")
async def minerops_agent_known_miners(request: Request):
minerops_require_agent_secret(request)
conn = await minerops_command_db_connect()
try:
rows = await conn.fetch(
"""
SELECT
miners.id::text AS miner_id,
host(miners.current_ip) AS ip_address,
miners.current_mac AS mac_address,
COALESCE(rooms.name, 'Unassigned') AS room_name,
COALESCE(NULLIF(miners.miner_type, ''), NULLIF(miners.model, ''), '') AS model,
COALESCE(miners.status, '') AS status
FROM miners
LEFT JOIN rooms ON rooms.id = miners.room_id
WHERE miners.current_ip IS NOT NULL
ORDER BY rooms.sort_order NULLS LAST, rooms.name NULLS LAST, miners.current_ip
"""
)
finally:
await conn.close()
miners = []
for row in rows:
miners.append(
{
"miner_id": row["miner_id"],
"ip_address": row["ip_address"],
"mac_address": row["mac_address"],
"room_name": row["room_name"],
"model": row["model"],
"status": row["status"],
}
)
return {
"ok": True,
"count": len(miners),
"miners": miners,
"message": "Known miners for site agent polling.",
}
# -----------------------------
# Phase 5J: Apply Windows Agent snapshots to live miner tables
# -----------------------------
def minerops_agent_to_float(value):
if value is None:
return None
try:
text = str(value).strip()
if text == "":
return None
return float(text)
except Exception:
return None
def minerops_agent_to_int(value):
number = minerops_agent_to_float(value)
if number is None:
return None
try:
return int(number)
except Exception:
return None
def minerops_agent_to_bool(value):
if value is None:
return None
if isinstance(value, bool):
return value
text = str(value).strip().lower()
if text in {"true", "1", "yes", "y", "stable"}:
return True
if text in {"false", "0", "no", "n", "unstable"}:
return False
return None
def minerops_agent_parse_summary(miner_payload: dict) -> dict:
import json
responses = miner_payload.get("responses") or {}
summary = responses.get("summary") or {}
raw = summary.get("raw") or ""
parsed = summary.get("json")
if parsed is None and raw:
try:
parsed = json.loads(raw)
except Exception:
parsed = None
if not isinstance(parsed, dict):
parsed = {}
msg = parsed.get("Msg") or parsed.get("msg") or {}
if not isinstance(msg, dict):
msg = {}
return {
"raw": parsed,
"msg": msg,
}
async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> dict:
import json
import uuid
from datetime import datetime, timezone
miners = snapshot_body.get("miners")
if not isinstance(miners, list):
miners = []
now = datetime.now(timezone.utc)
applied = 0
online_count = 0
offline_count = 0
errors = []
conn = await minerops_command_db_connect()
try:
for item in miners:
if not isinstance(item, dict):
continue
miner_id = str(item.get("miner_id") or "").strip()
ip_address = str(item.get("ip_address") or "").strip()
online = bool(item.get("online"))
if not miner_id:
continue
parsed = minerops_agent_parse_summary(item)
raw_summary = parsed["raw"]
msg = parsed["msg"]
status = "online" if online else "offline"
mhs_av = minerops_agent_to_float(msg.get("MHS av"))
mhs_1m = minerops_agent_to_float(msg.get("MHS 1m"))
mhs_15m = minerops_agent_to_float(msg.get("MHS 15m"))
hs_rt = minerops_agent_to_float(msg.get("HS RT"))
power_w = minerops_agent_to_float(msg.get("Power"))
power_rate = minerops_agent_to_float(msg.get("Power Rate"))
env_temp = minerops_agent_to_float(msg.get("Env Temp"))
chip_temp_min = minerops_agent_to_float(msg.get("Chip Temp Min"))
chip_temp_max = minerops_agent_to_float(msg.get("Chip Temp Max"))
chip_temp_avg = minerops_agent_to_float(msg.get("Chip Temp Avg"))
fan_in = minerops_agent_to_int(msg.get("Fan Speed In"))
fan_out = minerops_agent_to_int(msg.get("Fan Speed Out"))
uptime = minerops_agent_to_int(msg.get("Uptime"))
hash_stable = minerops_agent_to_bool(msg.get("Hash Stable"))
pool_rejected = minerops_agent_to_float(msg.get("Pool Rejected%"))
try:
if online:
online_count += 1
await conn.execute(
"""
UPDATE miners
SET
status = $2,
current_ip = COALESCE(NULLIF($3, '')::inet, current_ip),
last_seen_at = $4,
updated_at = now()
WHERE id = $1::uuid
""",
miner_id,
status,
ip_address,
now,
)
await conn.execute(
"""
INSERT INTO miner_status_snapshots (
id,
miner_id,
observed_ip,
observed_at,
mhs_av,
mhs_1m,
mhs_15m,
hs_rt,
power_w,
power_rate_w_per_th,
env_temp_c,
chip_temp_min_c,
chip_temp_max_c,
chip_temp_avg_c,
fan_speed_in_rpm,
fan_speed_out_rpm,
uptime_seconds,
hash_stable,
pool_rejected_percent,
raw_summary_json,
created_at
)
VALUES (
$1::uuid,
$2::uuid,
NULLIF($3, '')::inet,
$4,
$5,
$6,
$7,
$8,
$9,
$10,
$11,
$12,
$13,
$14,
$15,
$16,
$17,
$18,
$19,
$20::jsonb,
now()
)
""",
str(uuid.uuid4()),
miner_id,
ip_address,
now,
mhs_av,
mhs_1m,
mhs_15m,
hs_rt,
power_w,
power_rate,
env_temp,
chip_temp_min,
chip_temp_max,
chip_temp_avg,
fan_in,
fan_out,
uptime,
hash_stable,
pool_rejected,
json.dumps(raw_summary),
)
else:
offline_count += 1
await conn.execute(
"""
UPDATE miners
SET
status = $2,
current_ip = COALESCE(NULLIF($3, '')::inet, current_ip),
updated_at = now()
WHERE id = $1::uuid
""",
miner_id,
status,
ip_address,
)
applied += 1
except Exception as exc:
if len(errors) < 20:
errors.append(
{
"miner_id": miner_id,
"ip_address": ip_address,
"error": str(exc),
}
)
finally:
await conn.close()
return {
"applied": applied,
"online": online_count,
"offline": offline_count,
"errors": errors,
}
# -----------------------------
# Phase 5K: Agent command queue
# -----------------------------
async def minerops_ensure_agent_command_queue_table():
conn = await minerops_command_db_connect()
try:
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS minerops_agent_command_queue (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
command_type TEXT NOT NULL,
command_status TEXT NOT NULL DEFAULT 'pending',
requested_by_user_id TEXT,
requested_by_username TEXT,
request_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
result_payload JSONB,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
claimed_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_minerops_agent_command_queue_agent_status
ON minerops_agent_command_queue(agent_id, command_status, created_at)
"""
)
finally:
await conn.close()
@app.on_event("startup")
async def startup_ensure_minerops_agent_command_queue_table():
await minerops_ensure_agent_command_queue_table()
@app.post("/api/agent/commands/run-poll-now")
async def minerops_queue_agent_poll_now(current_user: dict = Depends(get_current_user)):
import uuid
import json
await minerops_ensure_agent_command_queue_table()
command_id = str(uuid.uuid4())
username = str(current_user.get("username") or current_user.get("email") or "unknown")
user_id = str(current_user.get("id") or "")
conn = await minerops_command_db_connect()
try:
await conn.execute(
"""
INSERT INTO minerops_agent_command_queue (
id,
agent_id,
command_type,
command_status,
requested_by_user_id,
requested_by_username,
request_payload,
created_at
)
VALUES (
$1,
$2,
$3,
'pending',
$4,
$5,
$6::jsonb,
now()
)
""",
command_id,
"plexus-windows-agent",
"poll_known_miners_now",
user_id,
username,
json.dumps({"source": "web", "reason": "manual_poll_now"}),
)
finally:
await conn.close()
return {
"ok": True,
"command_id": command_id,
"command_type": "poll_known_miners_now",
"message": "Agent poll command queued.",
}
@app.get("/api/agent/commands/status")
async def minerops_agent_command_status(current_user: dict = Depends(get_current_user)):
await minerops_ensure_agent_command_queue_table()
conn = await minerops_command_db_connect()
try:
rows = await conn.fetch(
"""
SELECT
id,
agent_id,
command_type,
command_status,
requested_by_username,
error_message,
created_at,
claimed_at,
completed_at,
result_payload
FROM minerops_agent_command_queue
ORDER BY created_at DESC
LIMIT 20
"""
)
finally:
await conn.close()
commands = []
for row in rows:
commands.append(
{
"id": row["id"],
"agent_id": row["agent_id"],
"command_type": row["command_type"],
"command_status": row["command_status"],
"requested_by_username": row["requested_by_username"],
"error_message": row["error_message"],
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"claimed_at": row["claimed_at"].isoformat() if row["claimed_at"] else None,
"completed_at": row["completed_at"].isoformat() if row["completed_at"] else None,
"result_payload": row["result_payload"],
}
)
return {
"ok": True,
"commands": commands,
}
@app.get("/api/agent/commands/pending")
async def minerops_agent_commands_pending_v2(request: Request):
# __SCHEDULE_DUE_HOOK_V2__
try:
minerops_process_due_scheduled_bulk_actions()
except NameError:
pass
except Exception as exc:
print(f"Scheduled bulk action due processing error: {exc}")
minerops_require_agent_secret(request)
await minerops_ensure_agent_command_queue_table()
agent_id = request.query_params.get("agent_id") or "plexus-windows-agent"
conn = await minerops_command_db_connect()
try:
rows = await conn.fetch(
"""
UPDATE minerops_agent_command_queue
SET
command_status = 'claimed',
claimed_at = now()
WHERE id IN (
SELECT id
FROM minerops_agent_command_queue
WHERE agent_id = $1
AND command_status = 'pending'
ORDER BY created_at ASC
LIMIT 5
)
RETURNING
id,
command_type,
request_payload,
created_at
""",
agent_id,
)
finally:
await conn.close()
commands = []
for row in rows:
commands.append(
{
"command_id": row["id"],
"command_type": row["command_type"],
"request_payload": row["request_payload"],
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
}
)
return {
"ok": True,
"commands": commands,
}
@app.post("/api/agent/commands/result")
async def minerops_agent_commands_result_v2(request: Request):
import json
minerops_require_agent_secret(request)
body = await request.json()
command_id = str(body.get("command_id") or "").strip()
status = str(body.get("status") or "").strip().lower()
result_payload = body.get("result_payload") or {}
error_message = body.get("error_message")
if not command_id:
raise HTTPException(status_code=400, detail="Missing command_id.")
if status not in {"completed", "failed"}:
status = "completed"
await minerops_ensure_agent_command_queue_table()
conn = await minerops_command_db_connect()
try:
await conn.execute(
"""
UPDATE minerops_agent_command_queue
SET
command_status = $2,
result_payload = $3::jsonb,
error_message = $4,
completed_at = now()
WHERE id = $1
""",
command_id,
status,
json.dumps(result_payload),
error_message,
)
finally:
await conn.close()
return {
"ok": True,
"command_id": command_id,
"status": status,
"message": "Agent command result received.",
}
# -----------------------------
# Phase 5K.5: Agent control web page
# -----------------------------
@app.get("/api/agent/status-summary")
async def minerops_agent_status_summary(current_user: dict = Depends(get_current_user)):
await minerops_ensure_agent_command_queue_table()
await ensure_minerops_agent_tables()
conn = await minerops_command_db_connect()
try:
agent = await conn.fetchrow(
"""
SELECT
agent_id,
site_name,
hostname,
version,
ip_address,
last_checkin_at,
last_payload
FROM minerops_site_agents
WHERE agent_id = 'plexus-windows-agent'
ORDER BY last_checkin_at DESC
LIMIT 1
"""
)
snapshot = await conn.fetchrow(
"""
SELECT
id,
received_at,
miner_count,
payload
FROM minerops_agent_snapshots
WHERE agent_id = 'plexus-windows-agent'
ORDER BY id DESC
LIMIT 1
"""
)
latest_counts = await conn.fetchrow(
"""
SELECT
COUNT(*) FILTER (WHERE status = 'online') AS online_count,
COUNT(*) FILTER (WHERE status = 'offline') AS offline_count,
COUNT(*) AS total_count
FROM miners
"""
)
recent_commands = await conn.fetch(
"""
SELECT
id,
command_type,
command_status,
requested_by_username,
created_at,
claimed_at,
completed_at,
error_message,
result_payload
FROM minerops_agent_command_queue
ORDER BY created_at DESC
LIMIT 10
"""
)
finally:
await conn.close()
commands = []
for row in recent_commands:
commands.append(
{
"id": row["id"],
"command_type": row["command_type"],
"command_status": row["command_status"],
"requested_by_username": row["requested_by_username"],
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"claimed_at": row["claimed_at"].isoformat() if row["claimed_at"] else None,
"completed_at": row["completed_at"].isoformat() if row["completed_at"] else None,
"error_message": row["error_message"],
"result_payload": row["result_payload"],
}
)
latest_snapshot_payload = snapshot["payload"] if snapshot else {}
latest_miners = latest_snapshot_payload.get("miners") if isinstance(latest_snapshot_payload, dict) else []
latest_online = 0
latest_offline = 0
if isinstance(latest_miners, list):
for item in latest_miners:
if isinstance(item, dict) and item.get("online"):
latest_online += 1
else:
latest_offline += 1
return {
"ok": True,
"agent": {
"agent_id": agent["agent_id"] if agent else None,
"site_name": agent["site_name"] if agent else None,
"hostname": agent["hostname"] if agent else None,
"version": agent["version"] if agent else None,
"ip_address": agent["ip_address"] if agent else None,
"last_checkin_at": agent["last_checkin_at"].isoformat() if agent and agent["last_checkin_at"] else None,
},
"latest_snapshot": {
"id": snapshot["id"] if snapshot else None,
"received_at": snapshot["received_at"].isoformat() if snapshot and snapshot["received_at"] else None,
"miner_count": snapshot["miner_count"] if snapshot else 0,
"online": latest_online,
"offline": latest_offline,
},
"live_database_counts": {
"total": latest_counts["total_count"] if latest_counts else 0,
"online": latest_counts["online_count"] if latest_counts else 0,
"offline": latest_counts["offline_count"] if latest_counts else 0,
},
"recent_commands": commands,
}
@app.get("/agent/control")
async def minerops_agent_control_page():
html = """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>MinerOps Agent Control</title>
<link rel="stylesheet" href="/assets/minerops-unified.css">
<style>
body { font-family: Arial, sans-serif; background:#f4f7fb; color:#0f2742; margin:0; padding:24px; }
.wrap { max-width: 1200px; margin: 0 auto; }
.card { background:white; border:1px solid #d9e2ef; border-radius:14px; padding:18px; box-shadow:0 2px 8px rgba(15,23,42,.08); margin-bottom:16px; }
.grid { display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap:14px; }
.stat { background:#f8fafc; border:1px solid #e2e8f0; border-radius:12px; padding:14px; }
.stat .label { color:#64748b; font-size:13px; font-weight:700; text-transform:uppercase; }
.stat .value { font-size:28px; font-weight:800; margin-top:6px; }
button { background:#0b63ce; color:white; border:0; border-radius:8px; padding:11px 16px; font-weight:700; cursor:pointer; margin-right:8px; }
button.secondary { background:#475569; }
button.danger { background:#b91c1c; }
table { width:100%; border-collapse:collapse; }
th, td { padding:10px; border-bottom:1px solid #e2e8f0; text-align:left; font-size:14px; vertical-align:top; }
th { color:#475569; font-size:12px; text-transform:uppercase; }
.ok { color:#166534; font-weight:700; }
.pending { color:#92400e; font-weight:700; }
.claimed { color:#1d4ed8; font-weight:700; }
.failed { color:#b91c1c; font-weight:700; }
.muted { color:#64748b; }
.notice { padding:12px; border-radius:10px; background:#eef6ff; border:1px solid #bfdbfe; margin-top:12px; }
.bad { background:#fee2e2; color:#7f1d1d; border:1px solid #fecaca; padding:12px; border-radius:10px; }
@media (max-width: 900px) { .grid { grid-template-columns: repeat(2, minmax(0,1fr)); } }
</style>
</head>
<body>
<div class="wrap">
<div class="minerops-shell-header">
<div>
<div class="minerops-shell-kicker">MINEROPS CONSOLE</div>
<h1>Agent Control</h1>
<div class="minerops-shell-subtitle">Windows Agent for Plexus Mining Site</div>
</div>
<nav class="minerops-shell-nav">
<a href="/dashboard">Dashboard</a>
<a href="/locations">Location Map</a>
<a href="/agent/setup">Agent Setup</a>
</nav>
</div>
<div id="statusBox" class="card">Loading...</div>
<div class="card">
<h2>Manual Actions</h2>
<button onclick="runPollNow()">Run Agent Poll Now</button>
<button class="secondary" onclick="scanNewMiners()">Scan for New Miners</button>
<button class="secondary" onclick="loadStatus()">Refresh Status</button>
<div class="notice">
This queues a command on the hub. The Windows Agent picks it up, polls known miners, sends a snapshot back, and updates the dashboard.
</div>
</div>
<div class="grid">
<div class="stat">
<div class="label">DB Total</div>
<div id="dbTotal" class="value">-</div>
</div>
<div class="stat">
<div class="label">DB Online</div>
<div id="dbOnline" class="value">-</div>
</div>
<div class="stat">
<div class="label">Latest Snapshot Online</div>
<div id="snapOnline" class="value">-</div>
</div>
<div class="stat">
<div class="label">Latest Snapshot Offline</div>
<div id="snapOffline" class="value">-</div>
</div>
</div>
<div class="card">
<h2>Agent Status</h2>
<table>
<tbody>
<tr><th>Agent ID</th><td id="agentId">-</td></tr>
<tr><th>Hostname</th><td id="agentHost">-</td></tr>
<tr><th>Version</th><td id="agentVersion">-</td></tr>
<tr><th>Last Check-in</th><td id="lastCheckin">-</td></tr>
<tr><th>Latest Snapshot</th><td id="latestSnapshot">-</td></tr>
</tbody>
</table>
</div>
<div class="card" id="discoverySummaryCard">
<h2>Discovery Summary</h2>
<div id="discoverySummaryText">Loading discovery summary...</div>
<p style="margin-top: 12px; opacity: 0.85;">
<strong>Note:</strong> An unmatched discovery does not always mean new hardware.
It may be an existing miner with a changed IP, missing MAC, or intermittent hardware.
Use <strong>Enrich / Match</strong> before <strong>Add as New</strong>.
</p>
</div>
<div class="card">
<h2>New Miner Discoveries</h2>
<table>
<thead>
<tr>
<th>Found</th>
<th>Room</th>
<th>IP</th>
<th>MAC</th>
<th>Model</th>
<th>Status</th>
<th>Matched Miner</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="discoveriesBody">
<tr><td colspan="8">Loading...</td></tr>
</tbody>
</table>
</div>
<div class="card">
<h2>Recent Agent Commands</h2>
<table>
<thead>
<tr>
<th>Created</th>
<th>Type</th>
<th>Status</th>
<th>Requested By</th>
<th>Claimed</th>
<th>Completed</th>
<th>Error</th>
</tr>
</thead>
<tbody id="commandsBody">
<tr><td colspan="8">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<script>
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 fmt(value) {
if (!value) return "-";
try {
return new Date(value).toLocaleString();
} catch {
return value;
}
}
function statusClass(status) {
if (status === "completed") return "ok";
if (status === "pending") return "pending";
if (status === "claimed") return "claimed";
if (status === "failed") return "failed";
return "";
}
async function apiFetch(url, options) {
const token = getToken();
if (!token) {
throw new Error("Log in on the Dashboard first, then return to this page.");
}
const opts = options || {};
opts.headers = opts.headers || {};
opts.headers["Authorization"] = "Bearer " + token;
const response = await fetch(url, opts);
if (!response.ok) {
throw new Error("HTTP " + response.status + " while calling " + url);
}
return await response.json();
}
async function loadStatus() {
const statusBox = document.getElementById("statusBox");
try {
const data = await apiFetch("/api/agent/status-summary", { cache: "no-store" });
document.getElementById("dbTotal").textContent = data.live_database_counts.total ?? "-";
document.getElementById("dbOnline").textContent = data.live_database_counts.online ?? "-";
document.getElementById("snapOnline").textContent = data.latest_snapshot.online ?? "-";
document.getElementById("snapOffline").textContent = data.latest_snapshot.offline ?? "-";
document.getElementById("agentId").textContent = data.agent.agent_id || "-";
document.getElementById("agentHost").textContent = data.agent.hostname || "-";
document.getElementById("agentVersion").textContent = data.agent.version || "-";
document.getElementById("lastCheckin").textContent = fmt(data.agent.last_checkin_at);
document.getElementById("latestSnapshot").textContent = "ID " + (data.latest_snapshot.id || "-") + " at " + fmt(data.latest_snapshot.received_at);
const body = document.getElementById("commandsBody");
body.innerHTML = "";
if (!data.recent_commands || data.recent_commands.length === 0) {
body.innerHTML = "<tr><td colspan='8'>No recent commands.</td></tr>";
} else {
for (const cmd of data.recent_commands) {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${fmt(cmd.created_at)}</td>
<td>${cmd.command_type || "-"}</td>
<td class="${statusClass(cmd.command_status)}">${cmd.command_status || "-"}</td>
<td>${cmd.requested_by_username || "-"}</td>
<td>${fmt(cmd.claimed_at)}</td>
<td>${fmt(cmd.completed_at)}</td>
<td>${cmd.error_message || ""}</td>
`;
body.appendChild(tr);
}
}
await loadDiscoveries();
statusBox.className = "card";
statusBox.textContent = "Agent status loaded.";
} catch (err) {
statusBox.className = "bad";
statusBox.textContent = err.message;
}
}
async function runPollNow() {
const statusBox = document.getElementById("statusBox");
try {
statusBox.className = "card";
statusBox.textContent = "Queueing manual poll command...";
const result = await apiFetch("/api/agent/commands/run-poll-now", {
method: "POST"
});
statusBox.textContent = "Queued command: " + result.command_id + ". The Windows Agent will pick it up shortly.";
await loadStatus();
} catch (err) {
statusBox.className = "bad";
statusBox.textContent = err.message;
}
}
async function scanNewMiners() {
const statusBox = document.getElementById("statusBox");
if (!confirm("Start a full scan of enabled miner ranges? This may take 15-20 minutes.")) {
return;
}
try {
statusBox.className = "card";
statusBox.textContent = "Queueing new-miner discovery scan...";
const result = await apiFetch("/api/agent/commands/scan-new-miners", {
method: "POST"
});
statusBox.textContent = "Queued discovery command: " + result.command_id + ". This scan may take 15-20 minutes.";
await loadStatus();
} catch (err) {
statusBox.className = "bad";
statusBox.textContent = err.message;
}
}
async function enrichMatchDiscovery(ipAddress) {
const statusBox = document.getElementById("statusBox");
try {
statusBox.className = "card";
statusBox.textContent = "Queueing Enrich / Match for " + ipAddress + "...";
const result = await apiFetch("/api/agent/discoveries/enrich-match", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
ip_address: ipAddress
})
});
statusBox.textContent = result.message + " Command: " + result.command_id;
await loadStatus();
} catch (err) {
statusBox.className = "bad";
statusBox.textContent = err.message;
}
}
async function reviewDiscovery(ipAddress, action) {
const statusBox = document.getElementById("statusBox");
const labels = {
add_to_inventory: "add this as a NEW physical miner. Only use this if you are sure it is not an existing miner with a changed IP",
ignore: "ignore this discovery",
non_miner: "mark this discovery as non-miner"
};
if (!confirm("Are you sure you want to " + (labels[action] || action) + " for " + ipAddress + "?")) {
return;
}
try {
statusBox.className = "card";
statusBox.textContent = "Updating discovery " + ipAddress + "...";
const result = await apiFetch("/api/agent/discoveries/review", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
ip_address: ipAddress,
action: action
})
});
statusBox.textContent = result.message || "Discovery updated.";
await loadStatus();
} catch (err) {
statusBox.className = "bad";
statusBox.textContent = err.message;
}
}
async function loadDiscoverySummary() {
const el = document.getElementById("discoverySummaryText");
if (!el) {
return;
}
try {
const summary = await apiFetch("/api/agent/discoveries/summary", { cache: "no-store" });
const counts = summary.counts || {};
const total = summary.total || 0;
el.innerHTML = `
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px;">
<div><strong>Total responders:</strong><br>${total}</div>
<div><strong>Known responders:</strong><br>${counts.known || 0}</div>
<div><strong>Unmatched:</strong><br>${counts.new || 0}</div>
<div><strong>Needs review:</strong><br>${counts.needs_review || 0}</div>
<div><strong>Matched:</strong><br>${counts.matched || 0}</div>
<div><strong>Added as new:</strong><br>${counts.added || 0}</div>
</div>
`;
} catch (err) {
el.textContent = "Could not load discovery summary: " + err.message;
}
}
function updateDiscoverySummary(discoveries) {
const el = document.getElementById("discoverySummaryText");
if (!el) {
return;
}
const counts = {
known: 0,
new: 0,
matched: 0,
needs_review: 0,
added: 0,
ignored: 0,
non_miner: 0
};
for (const d of discoveries || []) {
const status = d.discovery_status || "unknown";
counts[status] = (counts[status] || 0) + 1;
}
const total = (discoveries || []).length;
el.innerHTML = `
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px;">
<div><strong>Total responders:</strong><br>${total}</div>
<div><strong>Known responders:</strong><br>${counts.known || 0}</div>
<div><strong>Unmatched:</strong><br>${counts.new || 0}</div>
<div><strong>Needs review:</strong><br>${counts.needs_review || 0}</div>
<div><strong>Matched:</strong><br>${counts.matched || 0}</div>
<div><strong>Added as new:</strong><br>${counts.added || 0}</div>
</div>
`;
}
async function loadDiscoveries() {
const body = document.getElementById("discoveriesBody");
if (!body) {
return;
}
try {
const data = await apiFetch("/api/agent/discoveries", { cache: "no-store" });
body.innerHTML = "";
const discoveries = data.discoveries || [];
await loadDiscoverySummary();
const important = discoveries.filter(d => d.discovery_status === "new").concat(
discoveries.filter(d => d.discovery_status !== "new").slice(0, 20)
).slice(0, 30);
if (important.length === 0) {
body.innerHTML = "<tr><td colspan='8'>No discoveries yet.</td></tr>";
return;
}
for (const d of important) {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${fmt(d.created_at)}</td>
<td>${d.room_name || "-"}</td>
<td>${d.ip_address || "-"}</td>
<td>${d.mac_address || "-"}</td>
<td>${d.model || "-"}</td>
<td class="${d.discovery_status === "new" ? "pending" : "ok"}">${d.discovery_status || "-"}</td>
<td>${d.matched_miner_id || "-"}</td>
<td class="discovery-actions"></td>
`;
if ((d.discovery_status || "") === "new") {
const actionCell = tr.querySelector(".discovery-actions");
const enrichButton = document.createElement("button");
enrichButton.textContent = "Enrich / Match";
enrichButton.onclick = () => enrichMatchDiscovery(d.ip_address);
actionCell.appendChild(enrichButton);
actionCell.appendChild(document.createTextNode(" "));
const addButton = document.createElement("button");
addButton.textContent = "Add as New";
addButton.onclick = () => reviewDiscovery(d.ip_address, "add_to_inventory");
actionCell.appendChild(addButton);
actionCell.appendChild(document.createTextNode(" "));
const ignoreButton = document.createElement("button");
ignoreButton.textContent = "Ignore";
ignoreButton.className = "secondary";
ignoreButton.onclick = () => reviewDiscovery(d.ip_address, "ignore");
actionCell.appendChild(ignoreButton);
actionCell.appendChild(document.createTextNode(" "));
const nonMinerButton = document.createElement("button");
nonMinerButton.textContent = "Non-Miner";
nonMinerButton.className = "secondary";
nonMinerButton.onclick = () => reviewDiscovery(d.ip_address, "non_miner");
actionCell.appendChild(nonMinerButton);
} else {
tr.querySelector(".discovery-actions").textContent = "-";
}
body.appendChild(tr);
}
} catch (err) {
body.innerHTML = "<tr><td colspan='8'>Could not load discoveries: " + err.message + "</td></tr>";
}
}
document.addEventListener("DOMContentLoaded", loadStatus);
setInterval(loadStatus, 15000);
</script>
<script src="/assets/minerops-miner-detail-extra.js?v=1"></script>
</body>
</html>
"""
return HTMLResponse(content=html)
# -----------------------------
# Phase 5L: Agent new miner discovery
# -----------------------------
async def minerops_ensure_agent_discovery_table():
conn = await minerops_command_db_connect()
try:
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS minerops_agent_discoveries (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
site_name TEXT NOT NULL,
room_name TEXT,
ip_address INET,
mac_address TEXT,
model TEXT,
hostname TEXT,
miner_sn TEXT,
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
discovery_status TEXT NOT NULL DEFAULT 'new',
matched_miner_id UUID,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_minerops_agent_discoveries_mac
ON minerops_agent_discoveries(mac_address)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_minerops_agent_discoveries_ip
ON minerops_agent_discoveries(ip_address)
"""
)
finally:
await conn.close()
@app.on_event("startup")
async def startup_ensure_minerops_agent_discovery_table():
await minerops_ensure_agent_discovery_table()
@app.post("/api/agent/commands/scan-new-miners")
async def minerops_queue_agent_scan_new_miners(current_user: dict = Depends(get_current_user)):
import uuid
import json
await minerops_ensure_agent_command_queue_table()
command_id = str(uuid.uuid4())
username = str(current_user.get("username") or current_user.get("email") or "unknown")
user_id = str(current_user.get("id") or "")
conn = await minerops_command_db_connect()
try:
await conn.execute(
"""
INSERT INTO minerops_agent_command_queue (
id,
agent_id,
command_type,
command_status,
requested_by_user_id,
requested_by_username,
request_payload,
created_at
)
VALUES (
$1,
$2,
$3,
'pending',
$4,
$5,
$6::jsonb,
now()
)
""",
command_id,
"plexus-windows-agent",
"discover_new_miners",
user_id,
username,
json.dumps({"source": "web", "reason": "manual_new_miner_scan"}),
)
finally:
await conn.close()
return {
"ok": True,
"command_id": command_id,
"command_type": "discover_new_miners",
"message": "New-miner discovery command queued.",
}
@app.post("/api/agent/discoveries")
async def minerops_agent_post_discoveries(request: Request):
import json
import uuid
minerops_require_agent_secret(request)
await minerops_ensure_agent_discovery_table()
body = await request.json()
agent_id = str(body.get("agent_id") or "plexus-windows-agent").strip()
site_name = str(body.get("site_name") or "Plexus").strip()
discoveries = body.get("discoveries") or []
if not isinstance(discoveries, list):
discoveries = []
conn = await minerops_command_db_connect()
inserted = 0
matched_existing = 0
staged_new = 0
errors = []
try:
for item in discoveries:
if not isinstance(item, dict):
continue
ip_address = str(item.get("ip_address") or "").strip()
mac_address = str(item.get("mac_address") or "").strip().upper()
room_name = str(item.get("room_name") or "").strip()
model = str(item.get("model") or "").strip()
hostname = str(item.get("hostname") or "").strip()
miner_sn = str(item.get("miner_sn") or "").strip()
matched = None
if mac_address:
matched = await conn.fetchrow(
"""
SELECT id
FROM miners
WHERE upper(current_mac) = upper($1)
LIMIT 1
""",
mac_address,
)
if not matched and ip_address:
matched = await conn.fetchrow(
"""
SELECT id
FROM miners
WHERE current_ip = NULLIF($1, '')::inet
LIMIT 1
""",
ip_address,
)
status = "known" if matched else "new"
if matched:
matched_existing += 1
else:
staged_new += 1
try:
await conn.execute(
"""
INSERT INTO minerops_agent_discoveries (
id,
agent_id,
site_name,
room_name,
ip_address,
mac_address,
model,
hostname,
miner_sn,
raw_payload,
discovery_status,
matched_miner_id,
first_seen_at,
last_seen_at,
created_at,
updated_at
)
VALUES (
$1,
$2,
$3,
NULLIF($4, ''),
NULLIF($5, '')::inet,
NULLIF($6, ''),
NULLIF($7, ''),
NULLIF($8, ''),
NULLIF($9, ''),
$10::jsonb,
$11,
$12::uuid,
now(),
now(),
now(),
now()
)
ON CONFLICT (id) DO NOTHING
""",
str(uuid.uuid4()),
agent_id,
site_name,
room_name,
ip_address,
mac_address,
model,
hostname,
miner_sn,
json.dumps(item),
status,
str(matched["id"]) if matched else None,
)
inserted += 1
except Exception as exc:
if len(errors) < 20:
errors.append({"ip_address": ip_address, "mac_address": mac_address, "error": str(exc)})
finally:
await conn.close()
return {
"ok": True,
"received": len(discoveries),
"inserted": inserted,
"matched_existing": matched_existing,
"staged_new": staged_new,
"errors": errors,
"message": "Discovery results received.",
}
@app.get("/api/agent/discoveries")
async def minerops_agent_discoveries(current_user: dict = Depends(get_current_user)):
await minerops_ensure_agent_discovery_table()
conn = await minerops_command_db_connect()
try:
rows = await conn.fetch(
"""
SELECT
id,
agent_id,
site_name,
room_name,
ip_address::text AS ip_address,
mac_address,
model,
hostname,
miner_sn,
discovery_status,
matched_miner_id::text AS matched_miner_id,
first_seen_at,
last_seen_at,
created_at
FROM minerops_agent_discoveries
ORDER BY created_at DESC
LIMIT 100
"""
)
finally:
await conn.close()
items = []
for row in rows:
items.append(
{
"id": row["id"],
"agent_id": row["agent_id"],
"site_name": row["site_name"],
"room_name": row["room_name"],
"ip_address": row["ip_address"],
"mac_address": row["mac_address"],
"model": row["model"],
"hostname": row["hostname"],
"miner_sn": row["miner_sn"],
"discovery_status": row["discovery_status"],
"matched_miner_id": row["matched_miner_id"],
"first_seen_at": row["first_seen_at"].isoformat() if row["first_seen_at"] else None,
"last_seen_at": row["last_seen_at"].isoformat() if row["last_seen_at"] else None,
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
}
)
return {
"ok": True,
"discoveries": items,
}
@app.post("/api/agent/discoveries/review")
async def minerops_agent_discovery_review(request: Request, current_user: dict = Depends(get_current_user)):
import asyncpg
payload = await request.json()
ip_address = str(payload.get("ip_address") or "").strip()
action = str(payload.get("action") or "").strip().lower()
if not ip_address:
raise HTTPException(status_code=400, detail="ip_address is required.")
valid_actions = {"add_to_inventory", "ignore", "non_miner"}
if action not in valid_actions:
raise HTTPException(status_code=400, detail="Invalid discovery action.")
conn = await minerops_command_db_connect()
try:
discovery = await conn.fetchrow("""
SELECT
id,
agent_id,
site_name,
room_name,
ip_address::text AS ip_address,
mac_address,
model,
hostname,
miner_sn,
discovery_status
FROM minerops_agent_discoveries
WHERE ip_address = $1::inet
ORDER BY created_at DESC
LIMIT 1
""", ip_address)
if not discovery:
raise HTTPException(status_code=404, detail="Discovery not found.")
if action == "ignore":
await conn.execute("""
UPDATE minerops_agent_discoveries
SET
discovery_status = 'ignored',
updated_at = now()
WHERE ip_address = $1::inet
""", ip_address)
return {
"ok": True,
"action": action,
"ip_address": ip_address,
"message": "Discovery ignored."
}
if action == "non_miner":
await conn.execute("""
UPDATE minerops_agent_discoveries
SET
discovery_status = 'non_miner',
updated_at = now()
WHERE ip_address = $1::inet
""", ip_address)
return {
"ok": True,
"action": action,
"ip_address": ip_address,
"message": "Discovery marked as non-miner."
}
mac = discovery["mac_address"]
if mac is not None:
mac = str(mac).strip() or None
existing = None
if mac:
existing = await conn.fetchrow("""
SELECT id::text AS id
FROM miners
WHERE upper(current_mac) = upper($1)
LIMIT 1
""", mac)
if existing is None:
existing = await conn.fetchrow("""
SELECT id::text AS id
FROM miners
WHERE current_ip = $1::inet
LIMIT 1
""", ip_address)
if existing:
await conn.execute("""
UPDATE minerops_agent_discoveries
SET
discovery_status = 'known',
matched_miner_id = $2::uuid,
updated_at = now()
WHERE ip_address = $1::inet
""", ip_address, existing["id"])
return {
"ok": True,
"action": action,
"ip_address": ip_address,
"miner_id": existing["id"],
"message": "Discovery already matched an existing inventory miner."
}
room_id = None
site_id = None
room_name = discovery["room_name"]
if room_name:
try:
room = await conn.fetchrow("""
SELECT id::text AS id, site_id::text AS site_id
FROM rooms
WHERE lower(name) = lower($1)
LIMIT 1
""", str(room_name))
if room:
room_id = room["id"]
site_id = room["site_id"]
except Exception:
room_id = None
site_id = None
model = discovery["model"] or "WhatsMiner"
hostname = discovery["hostname"]
miner_sn = discovery["miner_sn"]
new_miner = await conn.fetchrow("""
INSERT INTO miners (
site_id,
room_id,
current_mac,
current_ip,
hostname,
miner_sn,
model,
miner_type,
status,
notes,
first_seen_at,
last_seen_at,
created_at,
updated_at
)
VALUES (
$1::uuid,
$2::uuid,
$3,
$4::inet,
$5,
$6,
$7,
$7,
'discovered',
$8,
now(),
now(),
now(),
now()
)
RETURNING id::text AS id
""",
site_id,
room_id,
mac,
ip_address,
hostname,
miner_sn,
model,
"Added from Agent Discovery. Review MAC, serial, location, and notes."
)
await conn.execute("""
UPDATE minerops_agent_discoveries
SET
discovery_status = 'added',
matched_miner_id = $2::uuid,
updated_at = now()
WHERE ip_address = $1::inet
""", ip_address, new_miner["id"])
return {
"ok": True,
"action": action,
"ip_address": ip_address,
"miner_id": new_miner["id"],
"message": "Discovery added to inventory."
}
finally:
await conn.close()
@app.post("/api/agent/miner-enrichment")
async def minerops_agent_miner_enrichment(request: Request):
import json
await minerops_require_agent_secret(request)
payload = await request.json()
ip_address = str(payload.get("ip_address") or "").strip()
miner_id = str(payload.get("miner_id") or "").strip()
def clean(value):
if value is None:
return None
value = str(value).strip()
return value or None
def normalize_mac(value):
value = clean(value)
if not value:
return None
value = value.replace("-", ":").upper()
return value
def jsonb_value(value):
if value is None:
return None
if isinstance(value, (dict, list)):
return json.dumps(value)
text_value = str(value).strip()
if not text_value:
return None
try:
return json.dumps(json.loads(text_value))
except Exception:
return json.dumps({"raw": text_value})
if not ip_address and not miner_id:
raise HTTPException(status_code=400, detail="ip_address or miner_id is required.")
conn = await minerops_command_db_connect()
try:
miner = None
if miner_id:
miner = await conn.fetchrow("""
SELECT id::text AS id
FROM miners
WHERE id = $1::uuid
LIMIT 1
""", miner_id)
if miner is None and ip_address:
miner = await conn.fetchrow("""
SELECT id::text AS id
FROM miners
WHERE current_ip = $1::inet
LIMIT 1
""", ip_address)
if miner is None:
raise HTTPException(status_code=404, detail="Miner not found.")
miner_id = miner["id"]
mac = normalize_mac(payload.get("mac_address"))
hostname = clean(payload.get("hostname"))
miner_sn = clean(payload.get("miner_sn"))
model = clean(payload.get("model"))
miner_type = clean(payload.get("miner_type")) or model
api_version = clean(payload.get("api_version"))
firmware_version = clean(payload.get("firmware_version"))
platform = clean(payload.get("platform"))
raw_version_json = jsonb_value(payload.get("raw_version"))
raw_miner_info_json = jsonb_value(payload.get("raw_miner_info"))
await conn.execute("""
UPDATE miners
SET
current_mac = COALESCE($2, current_mac),
hostname = COALESCE($3, hostname),
miner_sn = COALESCE($4, miner_sn),
model = COALESCE($5, model),
miner_type = COALESCE($6, miner_type),
api_version = COALESCE($7, api_version),
firmware_version = COALESCE($8, firmware_version),
platform = COALESCE($9, platform),
updated_at = now()
WHERE id = $1::uuid
""",
miner_id,
mac,
hostname,
miner_sn,
model,
miner_type,
api_version,
firmware_version,
platform
)
if raw_version_json is not None or raw_miner_info_json is not None:
await conn.execute("""
UPDATE miner_status_snapshots
SET
raw_version_json = COALESCE($2::jsonb, raw_version_json),
raw_miner_info_json = COALESCE($3::jsonb, raw_miner_info_json)
WHERE id = (
SELECT id
FROM miner_status_snapshots
WHERE miner_id = $1::uuid
ORDER BY created_at DESC
LIMIT 1
)
""", miner_id, raw_version_json, raw_miner_info_json)
updated = await conn.fetchrow("""
SELECT
id::text AS id,
current_mac,
host(current_ip) AS current_ip,
hostname,
miner_sn,
model,
miner_type,
api_version,
firmware_version,
platform,
status,
updated_at
FROM miners
WHERE id = $1::uuid
""", miner_id)
return {
"ok": True,
"miner": dict(updated),
"message": "Miner enrichment saved."
}
finally:
await conn.close()
@app.post("/api/agent/miner-enrichment-v2")
async def minerops_agent_miner_enrichment_v2(request: Request):
import json
await minerops_require_agent_secret(request)
payload = await request.json()
ip_address = str(payload.get("ip_address") or "").strip()
miner_id = str(payload.get("miner_id") or "").strip()
def clean(value):
if value is None:
return None
value = str(value).strip()
return value or None
def normalize_mac(value):
value = clean(value)
if not value:
return None
return value.replace("-", ":").upper()
def jsonb_text(value):
if value is None:
return None
if isinstance(value, (dict, list)):
return json.dumps(value)
value = str(value).strip()
if not value:
return None
try:
return json.dumps(json.loads(value))
except Exception:
return json.dumps({"raw": value})
def safe_dt(value):
try:
return value.isoformat()
except Exception:
return str(value) if value is not None else None
if not ip_address and not miner_id:
raise HTTPException(status_code=400, detail="ip_address or miner_id is required.")
mac = normalize_mac(payload.get("mac_address"))
hostname = clean(payload.get("hostname"))
miner_sn = clean(payload.get("miner_sn"))
model = clean(payload.get("model")) or "WhatsMiner"
miner_type = clean(payload.get("miner_type")) or model
api_version = clean(payload.get("api_version"))
firmware_version = clean(payload.get("firmware_version"))
platform = clean(payload.get("platform"))
raw_version_json = jsonb_text(payload.get("raw_version"))
raw_miner_info_json = jsonb_text(payload.get("raw_miner_info"))
conn = await minerops_command_db_connect()
try:
source = None
if miner_id:
source = await conn.fetchrow("""
SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes
FROM miners
WHERE id = $1::uuid
LIMIT 1
""", miner_id)
if source is None and ip_address:
source = await conn.fetchrow("""
SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes
FROM miners
WHERE current_ip = $1::inet
LIMIT 1
""", ip_address)
if source is None:
raise HTTPException(status_code=404, detail="Source miner not found.")
source_id = source["id"]
target_id = source_id
merged_from_miner_id = None
source_removed = False
if mac:
existing_mac_miner = await conn.fetchrow("""
SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes
FROM miners
WHERE upper(current_mac) = upper($1)
LIMIT 1
""", mac)
if existing_mac_miner and existing_mac_miner["id"] != source_id:
target_id = existing_mac_miner["id"]
merged_from_miner_id = source_id
await conn.execute("""
UPDATE miners
SET
current_ip = NULL,
status = 'merged',
notes = COALESCE(notes, '') || E'\nMerged into miner ' || $2 || ' after enrichment found matching MAC ' || $3 || '.',
updated_at = now()
WHERE id = $1::uuid
""", source_id, target_id, mac)
await conn.execute("""
UPDATE miners
SET
current_mac = COALESCE($2, current_mac),
current_ip = COALESCE($3::inet, current_ip),
hostname = COALESCE($4, hostname),
miner_sn = COALESCE($5, miner_sn),
model = COALESCE($6, model),
miner_type = COALESCE($7, miner_type),
api_version = COALESCE($8, api_version),
firmware_version = COALESCE($9, firmware_version),
platform = COALESCE($10, platform),
status = 'online',
last_seen_at = now(),
updated_at = now()
WHERE id = $1::uuid
""",
target_id,
mac,
ip_address or None,
hostname,
miner_sn,
model,
miner_type,
api_version,
firmware_version,
platform
)
if merged_from_miner_id:
await conn.execute("""
UPDATE miner_status_snapshots
SET miner_id = $2::uuid
WHERE miner_id = $1::uuid
""", merged_from_miner_id, target_id)
try:
await conn.execute("""
DELETE FROM miners
WHERE id = $1::uuid
""", merged_from_miner_id)
source_removed = True
except Exception:
source_removed = False
if raw_version_json is not None or raw_miner_info_json is not None:
await conn.execute("""
UPDATE miner_status_snapshots
SET
raw_version_json = CASE WHEN $2 IS NULL THEN raw_version_json ELSE $2::jsonb END,
raw_miner_info_json = CASE WHEN $3 IS NULL THEN raw_miner_info_json ELSE $3::jsonb END
WHERE id = (
SELECT id
FROM miner_status_snapshots
WHERE miner_id = $1::uuid
ORDER BY created_at DESC
LIMIT 1
)
""", target_id, raw_version_json, raw_miner_info_json)
if ip_address:
try:
await conn.execute("""
UPDATE minerops_agent_discoveries
SET
discovery_status = 'known',
matched_miner_id = $2::uuid,
updated_at = now()
WHERE ip_address = $1::inet
""", ip_address, target_id)
except Exception:
pass
updated = await conn.fetchrow("""
SELECT
id::text AS id,
current_mac,
host(current_ip) AS current_ip,
hostname,
miner_sn,
model,
miner_type,
api_version,
firmware_version,
platform,
status,
updated_at
FROM miners
WHERE id = $1::uuid
""", target_id)
result = dict(updated)
result["updated_at"] = safe_dt(result.get("updated_at"))
return {
"ok": True,
"miner": result,
"target_miner_id": target_id,
"merged_from_miner_id": merged_from_miner_id,
"source_removed": source_removed,
"message": "Miner enrichment saved."
}
finally:
await conn.close()
@app.post("/api/agent/miner-enrichment-v3")
async def minerops_agent_miner_enrichment_v3(request: Request):
import json
await minerops_require_agent_secret(request)
payload = await request.json()
ip_address = str(payload.get("ip_address") or "").strip()
miner_id = str(payload.get("miner_id") or "").strip()
def clean(value):
if value is None:
return None
value = str(value).strip()
return value or None
def normalize_mac(value):
value = clean(value)
if not value:
return None
return value.replace("-", ":").upper()
def jsonb_text(value):
if value is None:
return None
if isinstance(value, (dict, list)):
return json.dumps(value)
value = str(value).strip()
if not value:
return None
try:
return json.dumps(json.loads(value))
except Exception:
return json.dumps({"raw": value})
mac = normalize_mac(payload.get("mac_address"))
hostname = clean(payload.get("hostname"))
miner_sn = clean(payload.get("miner_sn"))
model = clean(payload.get("model")) or "WhatsMiner"
miner_type = clean(payload.get("miner_type")) or model
api_version = clean(payload.get("api_version"))
firmware_version = clean(payload.get("firmware_version"))
platform = clean(payload.get("platform"))
raw_version_json = jsonb_text(payload.get("raw_version"))
raw_miner_info_json = jsonb_text(payload.get("raw_miner_info"))
if not ip_address and not miner_id:
raise HTTPException(status_code=400, detail="ip_address or miner_id is required.")
conn = await minerops_command_db_connect()
try:
target_id = None
matched_by = None
if mac:
row = await conn.fetchrow("""
SELECT id::text AS id
FROM miners
WHERE upper(current_mac) = upper($1)
LIMIT 1
""", mac)
if row:
target_id = row["id"]
matched_by = "mac"
if target_id is None and miner_id:
row = await conn.fetchrow("""
SELECT id::text AS id
FROM miners
WHERE id = $1::uuid
LIMIT 1
""", miner_id)
if row:
target_id = row["id"]
matched_by = "miner_id"
if target_id is None and ip_address:
row = await conn.fetchrow("""
SELECT id::text AS id
FROM miners
WHERE current_ip = $1::inet
LIMIT 1
""", ip_address)
if row:
target_id = row["id"]
matched_by = "ip"
if target_id:
await conn.execute("""
UPDATE miners
SET
current_mac = COALESCE($2, current_mac),
current_ip = COALESCE($3::inet, current_ip),
hostname = COALESCE($4, hostname),
miner_sn = COALESCE($5, miner_sn),
model = COALESCE($6, model),
miner_type = COALESCE($7, miner_type),
api_version = COALESCE($8, api_version),
firmware_version = COALESCE($9, firmware_version),
platform = COALESCE($10, platform),
status = 'online',
last_seen_at = now(),
updated_at = now()
WHERE id = $1::uuid
""",
target_id,
mac,
ip_address or None,
hostname,
miner_sn,
model,
miner_type,
api_version,
firmware_version,
platform
)
if raw_version_json is not None or raw_miner_info_json is not None:
await conn.execute("""
UPDATE miner_status_snapshots
SET
raw_version_json = CASE WHEN $2 IS NULL THEN raw_version_json ELSE $2::jsonb END,
raw_miner_info_json = CASE WHEN $3 IS NULL THEN raw_miner_info_json ELSE $3::jsonb END
WHERE id = (
SELECT id
FROM miner_status_snapshots
WHERE miner_id = $1::uuid
ORDER BY created_at DESC
LIMIT 1
)
""", target_id, raw_version_json, raw_miner_info_json)
if ip_address:
await conn.execute("""
UPDATE minerops_agent_discoveries
SET
mac_address = COALESCE($2, mac_address),
model = COALESCE($3, model),
hostname = COALESCE($4, hostname),
miner_sn = COALESCE($5, miner_sn),
discovery_status = 'matched',
matched_miner_id = $6::uuid,
raw_payload = COALESCE(raw_payload, '{}'::jsonb) || $7::jsonb,
updated_at = now()
WHERE ip_address = $1::inet
""",
ip_address,
mac,
model,
hostname,
miner_sn,
target_id,
json.dumps({"enrichment": payload, "matched_by": matched_by})
)
updated = await conn.fetchrow("""
SELECT
id::text AS id,
current_mac,
host(current_ip) AS current_ip,
hostname,
miner_sn,
model,
miner_type,
api_version,
firmware_version,
platform,
status,
updated_at
FROM miners
WHERE id = $1::uuid
""", target_id)
return {
"ok": True,
"matched": True,
"matched_by": matched_by,
"miner": dict(updated),
"message": "Discovery enriched and matched to existing inventory."
}
if ip_address:
await conn.execute("""
UPDATE minerops_agent_discoveries
SET
mac_address = COALESCE($2, mac_address),
model = COALESCE($3, model),
hostname = COALESCE($4, hostname),
miner_sn = COALESCE($5, miner_sn),
discovery_status = 'needs_review',
raw_payload = COALESCE(raw_payload, '{}'::jsonb) || $6::jsonb,
updated_at = now()
WHERE ip_address = $1::inet
""",
ip_address,
mac,
model,
hostname,
miner_sn,
json.dumps({"enrichment": payload, "reason": "no_inventory_match"})
)
return {
"ok": True,
"matched": False,
"ip_address": ip_address,
"mac_address": mac,
"message": "Discovery enriched but did not match existing inventory. Review before adding."
}
finally:
await conn.close()
@app.post("/api/agent/discoveries/enrich-match")
async def minerops_queue_discovery_enrich_match(request: Request, current_user: dict = Depends(get_current_user)):
payload = await request.json()
ip_address = str(payload.get("ip_address") or "").strip()
if not ip_address:
raise HTTPException(status_code=400, detail="ip_address is required.")
conn = await minerops_command_db_connect()
try:
discovery = await conn.fetchrow("""
SELECT room_name
FROM minerops_agent_discoveries
WHERE ip_address = $1::inet
ORDER BY created_at DESC
LIMIT 1
""", ip_address)
room_name = discovery["room_name"] if discovery else None
command = await conn.fetchrow("""
INSERT INTO minerops_agent_command_queue (
id,
agent_id,
command_type,
command_status,
requested_by_username,
request_payload,
created_at
)
VALUES (
gen_random_uuid()::text,
'plexus-windows-agent',
'enrich_match_discovery',
'pending',
'admin',
jsonb_build_object(
'ip_address', $1::text,
'room_name', $2::text,
'source', 'agent-control-discovery'
),
now()
)
RETURNING id
""", ip_address, room_name)
return {
"ok": True,
"command_id": command["id"],
"ip_address": ip_address,
"message": "Enrich / Match command queued."
}
finally:
await conn.close()
@app.get("/api/agent/discoveries/summary")
async def minerops_agent_discoveries_summary(request: Request, current_user: dict = Depends(get_current_user)):
conn = await minerops_command_db_connect()
try:
rows = await conn.fetch("""
SELECT
discovery_status,
COUNT(*)::int AS count
FROM minerops_agent_discoveries
GROUP BY discovery_status
ORDER BY discovery_status
""")
latest = await conn.fetchrow("""
SELECT
MAX(COALESCE(updated_at, created_at)) AS latest_at,
COUNT(*)::int AS total
FROM minerops_agent_discoveries
""")
counts = {}
for row in rows:
counts[row["discovery_status"] or "unknown"] = row["count"]
return {
"ok": True,
"total": latest["total"] if latest else 0,
"counts": counts,
"latest_at": latest["latest_at"].isoformat() if latest and latest["latest_at"] else None
}
finally:
await conn.close()
@app.post("/api/miners/{miner_id}/agent-control")
async def minerops_queue_agent_miner_control(miner_id: str, request: Request, current_user: dict = Depends(get_current_user)):
payload = await request.json()
action = str(payload.get("action") or "").strip().lower()
dry_run = bool(payload.get("dry_run", True))
performance_mode = str(payload.get("performance_mode") or payload.get("mode") or "").strip().lower()
allowed_actions = {"reboot", "suspend", "resume", "performance_mode"}
if action == "performance_mode" and performance_mode not in {"low", "normal", "high"}:
raise HTTPException(status_code=400, detail="performance_mode must be one of: low, normal, high.")
if action not in allowed_actions:
raise HTTPException(status_code=400, detail="Invalid action. Allowed: reboot, suspend, resume, performance_mode.")
conn = await minerops_command_db_connect()
try:
miner = await conn.fetchrow("""
SELECT
id::text AS id,
host(current_ip) AS current_ip,
current_mac,
model,
miner_type,
status
FROM miners
WHERE id = $1::uuid
LIMIT 1
""", miner_id)
if not miner:
raise HTTPException(status_code=404, detail="Miner not found.")
if not miner["current_ip"]:
raise HTTPException(status_code=400, detail="Miner has no current IP.")
command = await conn.fetchrow("""
INSERT INTO minerops_agent_command_queue (
id,
agent_id,
command_type,
command_status,
requested_by_username,
request_payload,
created_at
)
VALUES (
gen_random_uuid()::text,
'plexus-windows-agent',
'miner_control',
'pending',
'admin',
jsonb_build_object(
'miner_id', $1::text,
'ip_address', $2::text,
'mac_address', $3::text,
'model', $4::text,
'miner_type', $5::text,
'action', $6::text,
'dry_run', $7::boolean,
'performance_mode', $8::text,
'source', 'miner-detail-agent-control'
),
now()
)
RETURNING id
""",
miner["id"],
miner["current_ip"],
miner["current_mac"],
miner["model"],
miner["miner_type"],
action,
dry_run,
performance_mode
)
return {
"ok": True,
"command_id": command["id"],
"miner_id": miner["id"],
"ip_address": miner["current_ip"],
"action": action,
"dry_run": dry_run,
"message": "Miner control command queued for Windows Agent."
}
finally:
await conn.close()
def minerops_ensure_manual_detail_columns():
conn = None
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute(
"""
ALTER TABLE miners
ADD COLUMN IF NOT EXISTS power_sn TEXT,
ADD COLUMN IF NOT EXISTS manual_details JSONB NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN IF NOT EXISTS manual_details_updated_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS manual_details_updated_by TEXT;
"""
)
conn.commit()
finally:
if conn is not None:
conn.close()
@app.get("/api/miners/{miner_id}/detail-extra")
def miner_detail_extra(miner_id: str, current_user: dict = Depends(get_current_user)):
import json as _json
import datetime as _datetime
import decimal as _decimal
import uuid as _uuid
def clean(value):
if value is None:
return None
if isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, (_datetime.datetime, _datetime.date)):
return value.isoformat()
if isinstance(value, _decimal.Decimal):
return float(value)
if isinstance(value, _uuid.UUID):
return str(value)
if isinstance(value, dict):
return {str(k): clean(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [clean(v) for v in value]
return str(value)
def sort_hashboard_key(row):
value = row.get("slot_number", row.get("slot", row.get("Slot", "")))
try:
return int(value)
except Exception:
return 9999
minerops_ensure_manual_detail_columns()
try:
minerops_ensure_pool_info_table()
except Exception:
pass
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
m.*,
rooms.name AS room_name
FROM miners m
LEFT JOIN rooms ON rooms.id = m.room_id
WHERE m.id = %s::uuid
LIMIT 1;
""",
(miner_id,),
)
miner_row = cur.fetchone()
if not miner_row:
raise HTTPException(status_code=404, detail="Miner not found.")
miner = dict(miner_row)
cur.execute(
"""
SELECT *
FROM miner_status_snapshots
WHERE miner_id = %s::uuid
ORDER BY created_at DESC
LIMIT 1;
""",
(miner_id,),
)
snapshot_row = cur.fetchone()
latest_snapshot = dict(snapshot_row) if snapshot_row else {}
pool_info = []
try:
cur.execute(
"""
WITH latest_pool AS (
SELECT DISTINCT ON (pool_index)
*
FROM miner_pool_snapshots
WHERE miner_id = %s::uuid
ORDER BY pool_index, created_at DESC
)
SELECT *
FROM latest_pool
ORDER BY pool_index;
""",
(miner_id,),
)
pool_info = [dict(row) for row in cur.fetchall()]
except Exception:
pool_info = []
hashboards = []
try:
cur.execute(
"""
SELECT *
FROM miner_hashboards
WHERE miner_id = %s::uuid;
""",
(miner_id,),
)
hashboards = [dict(row) for row in cur.fetchall()]
hashboards = sorted(hashboards, key=sort_hashboard_key)
except Exception as exc:
hashboards = [
{
"error": "Could not load hashboards",
"detail": str(exc),
}
]
raw_summary = latest_snapshot.get("raw_summary_json") or {}
summary_obj = {}
try:
if isinstance(raw_summary, str):
raw_summary = _json.loads(raw_summary)
if isinstance(raw_summary, dict):
if isinstance(raw_summary.get("SUMMARY"), list) and raw_summary.get("SUMMARY"):
summary_obj = raw_summary["SUMMARY"][0] or {}
elif isinstance(raw_summary.get("Msg"), dict):
summary_obj = raw_summary["Msg"] or {}
except Exception:
summary_obj = {}
summary_keys = []
if isinstance(summary_obj, dict):
summary_keys = sorted([str(k) for k in summary_obj.keys()])
data = {
"miner": miner,
"latest_snapshot": latest_snapshot,
"summary_fields": summary_obj,
"summary_keys": summary_keys,
"pool_info": pool_info,
"hashboards": hashboards,
}
return api_response(data=clean(data))
finally:
if conn is not None:
conn.close()
@app.patch("/api/miners/{miner_id}/manual-details")
async def miner_manual_details_update(miner_id: str, request: Request, current_user: dict = Depends(get_current_user)):
import json as _json
minerops_ensure_manual_detail_columns()
body = await request.json()
editable_columns = [
"miner_sn",
"power_sn",
"hostname",
"model",
"miner_type",
"firmware_version",
"api_version",
"platform",
"notes",
]
updates = {}
for column in editable_columns:
if column in body:
value = body.get(column)
if value is None:
updates[column] = None
else:
updates[column] = str(value).strip()
manual_details = body.get("manual_details", None)
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"SELECT id FROM miners WHERE id = %s::uuid LIMIT 1;",
(miner_id,),
)
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Miner not found.")
set_parts = []
params = []
for column, value in updates.items():
set_parts.append(f"{column} = %s")
params.append(value)
if manual_details is not None:
if isinstance(manual_details, str):
manual_details = manual_details.strip()
if manual_details:
try:
manual_details = _json.loads(manual_details)
except Exception:
raise HTTPException(status_code=400, detail="Manual details must be valid JSON.")
else:
manual_details = {}
if not isinstance(manual_details, dict):
raise HTTPException(status_code=400, detail="Manual details must be a JSON object.")
set_parts.append("manual_details = %s::jsonb")
params.append(_json.dumps(manual_details))
username = (
current_user.get("username")
or current_user.get("email")
or current_user.get("id")
or "admin"
)
set_parts.append("manual_details_updated_at = now()")
set_parts.append("manual_details_updated_by = %s")
params.append(str(username))
set_parts.append("updated_at = now()")
params.append(miner_id)
sql = "UPDATE miners SET " + ", ".join(set_parts) + " WHERE id = %s::uuid RETURNING *;"
cur.execute(sql, params)
updated = dict(cur.fetchone())
conn.commit()
return api_response(
data={"miner": updated},
meta={"message": "Manual miner details saved."},
)
finally:
if conn is not None:
conn.close()
@app.get("/assets/minerops-miner-detail-extra.js")
def minerops_miner_detail_extra_js():
from fastapi.responses import Response as _Response
js = r"""
(function () {
function getToken() {
const keys = [
"minerops_token",
"mineropsToken",
"access_token",
"authToken",
"token",
"jwt"
];
for (const key of keys) {
const value = localStorage.getItem(key) || sessionStorage.getItem(key);
if (value) {
return value;
}
}
return "";
}
function authHeaders(extra) {
const token = getToken();
const headers = Object.assign({}, extra || {});
if (token) {
headers["Authorization"] = "Bearer " + token;
}
return headers;
}
function escapeHtml(value) {
if (value === null || value === undefined) {
return "";
}
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function fmtNumber(value, decimals) {
const n = Number(value);
if (!Number.isFinite(n)) {
return "";
}
return n.toFixed(decimals || 0);
}
function fmtSeconds(value) {
const seconds = Number(value || 0);
if (!seconds || seconds <= 0) {
return "";
}
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return days + "d " + hours + "h";
}
if (hours > 0) {
return hours + "h " + minutes + "m";
}
return minutes + "m";
}
function findMinerId() {
const match = window.location.pathname.match(/\/miners\/([^\/]+)\/detail/);
if (!match) {
return "";
}
return match[1];
}
async function apiGet(url) {
const response = await fetch(url, {
headers: authHeaders(),
credentials: "same-origin"
});
if (!response.ok) {
throw new Error(await response.text());
}
const payload = await response.json();
return payload.data || payload;
}
async function apiPatch(url, body) {
const response = await fetch(url, {
method: "PATCH",
headers: authHeaders({
"Content-Type": "application/json"
}),
credentials: "same-origin",
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(await response.text());
}
const payload = await response.json();
return payload.data || payload;
}
function row(label, value) {
return `
<tr>
<th style="text-align:left; white-space:nowrap; padding-right:12px;">${escapeHtml(label)}</th>
<td>${escapeHtml(value || "")}</td>
</tr>
`;
}
function inputRow(label, name, value) {
return `
<label style="display:block; margin-bottom:8px;">
<div class="small">${escapeHtml(label)}</div>
<input
name="${escapeHtml(name)}"
value="${escapeHtml(value || "")}"
style="width:100%; max-width:520px;"
/>
</label>
`;
}
function textareaRow(label, name, value) {
return `
<label style="display:block; margin-bottom:8px;">
<div class="small">${escapeHtml(label)}</div>
<textarea
name="${escapeHtml(name)}"
rows="4"
style="width:100%; max-width:760px;"
>${escapeHtml(value || "")}</textarea>
</label>
`;
}
function ensureCard() {
let card = document.getElementById("minerExtraDetailsCard");
if (card) {
return card;
}
card = document.createElement("div");
card.id = "minerExtraDetailsCard";
card.className = "card";
card.style.marginTop = "16px";
const main = document.querySelector("main") || document.querySelector(".container") || document.body;
main.appendChild(card);
return card;
}
function render(data) {
const card = ensureCard();
const miner = data.miner || {};
const snapshot = data.latest_snapshot || {};
const summary = data.summary_fields || {};
const pools = data.pool_info || [];
const hashboards = data.hashboards || [];
const manualDetails = miner.manual_details || {};
const manualDetailsText = JSON.stringify(manualDetails, null, 2);
const ths = snapshot.mhs_av ? Number(snapshot.mhs_av) / 1000000.0 : null;
card.innerHTML = `
<h2>Extra Miner Details / Manual Edit</h2>
<div class="small" style="margin-bottom:12px;">
This card shows the latest stored miner details, runtime readings, pool info, and editable manual fields.
</div>
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap:16px;">
<div>
<h3>Inventory Details</h3>
<table>
<tbody>
${row("Room", miner.room_name)}
${row("MAC", miner.current_mac)}
${row("IP", miner.current_ip)}
${row("Model", miner.model)}
${row("Miner Type", miner.miner_type)}
${row("Miner SN", miner.miner_sn)}
${row("Power SN", miner.power_sn)}
${row("Hostname", miner.hostname)}
${row("Firmware", miner.firmware_version)}
${row("API Version", miner.api_version)}
${row("Platform", miner.platform)}
${row("Status", miner.status)}
${row("Manual Updated By", miner.manual_details_updated_by)}
${row("Manual Updated At", miner.manual_details_updated_at)}
</tbody>
</table>
</div>
<div>
<h3>Latest Runtime</h3>
<table>
<tbody>
${row("TH/s", ths === null ? "" : fmtNumber(ths, 2))}
${row("Power W", fmtNumber(snapshot.power_w, 0))}
${row("Power Rate", fmtNumber(snapshot.power_rate_w_per_th, 2))}
${row("Fan In", fmtNumber(snapshot.fan_speed_in, 0))}
${row("Fan Out", fmtNumber(snapshot.fan_speed_out, 0))}
${row("Env C", fmtNumber(snapshot.env_temp_c, 1))}
${row("Chip Avg C", fmtNumber(snapshot.chip_temp_avg_c, 1))}
${row("Chip Max C", fmtNumber(snapshot.chip_temp_max_c, 1))}
${row("Uptime", fmtSeconds(snapshot.uptime_seconds))}
${row("Elapsed", fmtSeconds(snapshot.elapsed_seconds))}
${row("Pool Rejected %", fmtNumber(snapshot.pool_rejected_percent, 4))}
</tbody>
</table>
</div>
</div>
<h3 style="margin-top:16px;">Pool Info</h3>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>Pool</th>
<th>Status</th>
<th>Active</th>
<th>Worker/User</th>
<th>URL</th>
<th>Accepted</th>
<th>Rejected</th>
<th>Rejected %</th>
<th>Last Share</th>
</tr>
</thead>
<tbody>
${pools.map(pool => `
<tr>
<td>${escapeHtml(pool.pool_index || "")}</td>
<td>${escapeHtml(pool.pool_status || "")}</td>
<td>${escapeHtml(pool.stratum_active === true ? "Yes" : pool.stratum_active === false ? "No" : "")}</td>
<td>${escapeHtml(pool.worker_user || "")}</td>
<td>${escapeHtml(pool.url || "")}</td>
<td>${escapeHtml(pool.accepted || 0)}</td>
<td>${escapeHtml(pool.rejected || 0)}</td>
<td>${escapeHtml(fmtNumber(pool.pool_rejected_percent, 4))}</td>
<td>${escapeHtml(pool.last_share_time_epoch || "")}</td>
</tr>
`).join("")}
</tbody>
</table>
</div>
<h3 style="margin-top:16px;">Hashboards</h3>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>Slot</th>
<th>PCB SN</th>
<th>Status</th>
<th>Temp</th>
<th>Chip Avg</th>
<th>Chip Max</th>
<th>Hashrate</th>
</tr>
</thead>
<tbody>
${hashboards.map(board => `
<tr>
<td>${escapeHtml(board.slot_number || board.slot || "")}</td>
<td>${escapeHtml(board.pcb_sn || board.hashboard_sn || "")}</td>
<td>${escapeHtml(board.status || "")}</td>
<td>${escapeHtml(fmtNumber(board.temperature_c || board.temperature, 1))}</td>
<td>${escapeHtml(fmtNumber(board.chip_temp_avg_c, 1))}</td>
<td>${escapeHtml(fmtNumber(board.chip_temp_max_c, 1))}</td>
<td>${escapeHtml(fmtNumber(board.mhs_av, 2))}</td>
</tr>
`).join("")}
</tbody>
</table>
</div>
<details style="margin-top:16px;">
<summary>Available raw summary fields</summary>
<pre style="white-space:pre-wrap;">${escapeHtml((data.summary_keys || []).join("\\n"))}</pre>
</details>
<h3 style="margin-top:16px;">Manual Edit Missing Details</h3>
<form id="minerManualDetailsForm">
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap:12px;">
<div>
${inputRow("Miner SN", "miner_sn", miner.miner_sn)}
${inputRow("Power SN", "power_sn", miner.power_sn)}
${inputRow("Hostname", "hostname", miner.hostname)}
${inputRow("Model", "model", miner.model)}
${inputRow("Miner Type", "miner_type", miner.miner_type)}
</div>
<div>
${inputRow("Firmware Version", "firmware_version", miner.firmware_version)}
${inputRow("API Version", "api_version", miner.api_version)}
${inputRow("Platform", "platform", miner.platform)}
${textareaRow("Notes", "notes", miner.notes)}
</div>
</div>
${textareaRow("Manual Details JSON", "manual_details", manualDetailsText)}
<button type="submit">Save Manual Details</button>
<span id="minerManualDetailsStatus" class="small" style="margin-left:10px;"></span>
</form>
`;
const form = document.getElementById("minerManualDetailsForm");
form.addEventListener("submit", async function (event) {
event.preventDefault();
const status = document.getElementById("minerManualDetailsStatus");
status.textContent = "Saving...";
const formData = new FormData(form);
const body = {};
for (const [key, value] of formData.entries()) {
body[key] = value;
}
try {
await apiPatch("/api/miners/" + encodeURIComponent(findMinerId()) + "/manual-details", body);
status.textContent = "Saved.";
await load();
} catch (error) {
status.textContent = "Save failed: " + error.message;
}
});
}
async function load() {
const minerId = findMinerId();
if (!minerId) {
return;
}
const data = await apiGet("/api/miners/" + encodeURIComponent(minerId) + "/detail-extra");
render(data);
}
document.addEventListener("DOMContentLoaded", function () {
if (!findMinerId()) {
return;
}
load().catch(function (error) {
console.error("Miner extra details failed:", error);
});
});
})();
"""
return _Response(content=js, media_type="application/javascript")
@app.get("/assets/minerops-miner-detail-extra-v2.js")
def minerops_miner_detail_extra_v2_js():
from fastapi.responses import Response as _Response
js = r"""
(function () {
if (window.__mineropsExtraDetailsV2Loaded) {
return;
}
window.__mineropsExtraDetailsV2Loaded = true;
function getToken() {
const keys = [
"minerops_token",
"mineropsToken",
"access_token",
"authToken",
"token",
"jwt"
];
for (const key of keys) {
const value = localStorage.getItem(key) || sessionStorage.getItem(key);
if (value) {
return value;
}
}
return "";
}
function authHeaders(extra) {
const token = getToken();
const headers = Object.assign({}, extra || {});
if (token) {
headers["Authorization"] = "Bearer " + token;
}
return headers;
}
function escapeHtml(value) {
if (value === null || value === undefined) {
return "";
}
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function firstValue() {
for (const value of arguments) {
if (value !== null && value !== undefined && value !== "") {
return value;
}
}
return "";
}
function numberValue() {
const value = firstValue.apply(null, arguments);
const number = Number(value);
if (!Number.isFinite(number)) {
return "";
}
return number;
}
function fmtNumber(value, decimals) {
const number = Number(value);
if (!Number.isFinite(number)) {
return "";
}
return number.toFixed(decimals || 0);
}
function fmtSeconds(value) {
const seconds = Number(value || 0);
if (!seconds || seconds <= 0) {
return "";
}
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return days + "d " + hours + "h";
}
if (hours > 0) {
return hours + "h " + minutes + "m";
}
return minutes + "m";
}
function fmtEpoch(value) {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) {
return "";
}
try {
return new Date(number * 1000).toLocaleString();
} catch (error) {
return String(value);
}
}
function findMinerId() {
const match = window.location.pathname.match(/\/miners\/([^\/]+)\/detail/);
if (!match) {
return "";
}
return decodeURIComponent(match[1]);
}
async function apiGet(url) {
const response = await fetch(url, {
headers: authHeaders(),
credentials: "same-origin"
});
if (!response.ok) {
throw new Error(await response.text());
}
const payload = await response.json();
return payload.data || payload;
}
async function apiPatch(url, body) {
const response = await fetch(url, {
method: "PATCH",
headers: authHeaders({
"Content-Type": "application/json"
}),
credentials: "same-origin",
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(await response.text());
}
const payload = await response.json();
return payload.data || payload;
}
function tableRow(label, value) {
return `
<tr>
<th style="text-align:left; white-space:nowrap; padding:4px 12px 4px 0;">${escapeHtml(label)}</th>
<td style="padding:4px 0;">${escapeHtml(value)}</td>
</tr>
`;
}
function inputRow(label, name, value) {
return `
<label style="display:block; margin-bottom:10px;">
<div class="small">${escapeHtml(label)}</div>
<input name="${escapeHtml(name)}" value="${escapeHtml(value || "")}" style="width:100%; max-width:520px;" />
</label>
`;
}
function textareaRow(label, name, value) {
return `
<label style="display:block; margin-bottom:10px;">
<div class="small">${escapeHtml(label)}</div>
<textarea name="${escapeHtml(name)}" rows="4" style="width:100%; max-width:760px;">${escapeHtml(value || "")}</textarea>
</label>
`;
}
function ensureCard() {
let card = document.getElementById("minerExtraDetailsCardV2");
if (card) {
return card;
}
card = document.createElement("div");
card.id = "minerExtraDetailsCardV2";
card.className = "card";
card.style.marginTop = "16px";
card.innerHTML = "<h2>Loading Extra Miner Details...</h2>";
const main = document.querySelector("main") || document.querySelector(".container") || document.querySelector(".content") || document.body;
main.appendChild(card);
return card;
}
function render(data) {
const card = ensureCard();
const miner = data.miner || {};
const snapshot = data.latest_snapshot || {};
const summary = data.summary_fields || {};
const pools = data.pool_info || [];
const hashboards = data.hashboards || [];
const mhsAv = numberValue(snapshot.mhs_av, summary["MHS av"]);
const ths = mhsAv ? (mhsAv / 1000000.0) : "";
const powerW = numberValue(snapshot.power_w, summary["Power"]);
const powerRate = numberValue(snapshot.power_rate_w_per_th, summary["Power Rate"]);
const fanIn = numberValue(snapshot.fan_speed_in, summary["Fan Speed In"], summary["Fan In"], summary["Fan1"]);
const fanOut = numberValue(snapshot.fan_speed_out, summary["Fan Speed Out"], summary["Fan Out"], summary["Fan2"]);
const envTemp = numberValue(snapshot.env_temp_c, summary["Env Temp"]);
const chipAvg = numberValue(snapshot.chip_temp_avg_c, summary["Chip Temp Avg"], summary["Temperature"]);
const chipMax = numberValue(snapshot.chip_temp_max_c, summary["Chip Temp Max"], summary["Temperature"]);
const uptime = numberValue(snapshot.uptime_seconds, summary["Uptime"]);
const elapsed = numberValue(snapshot.elapsed_seconds, summary["Elapsed"]);
const poolRejected = numberValue(snapshot.pool_rejected_percent, summary["Pool Rejected%"]);
const manualDetails = miner.manual_details || {};
const manualDetailsText = JSON.stringify(manualDetails, null, 2);
card.innerHTML = `
<h2>Extra Miner Details / Manual Edit</h2>
<div class="small" style="margin-bottom:12px;">
Shows inventory, latest runtime telemetry, pool details, hashboard info, and manual editable fields.
</div>
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap:16px;">
<div>
<h3>Inventory Details</h3>
<table>
<tbody>
${tableRow("Room", miner.room_name)}
${tableRow("MAC", miner.current_mac)}
${tableRow("IP", miner.current_ip)}
${tableRow("Model", miner.model)}
${tableRow("Miner Type", miner.miner_type)}
${tableRow("Miner SN", miner.miner_sn)}
${tableRow("Power SN", miner.power_sn)}
${tableRow("Hostname", miner.hostname)}
${tableRow("Firmware", miner.firmware_version)}
${tableRow("API Version", miner.api_version)}
${tableRow("Platform", miner.platform)}
${tableRow("Status", miner.status)}
${tableRow("Manual Updated By", miner.manual_details_updated_by)}
${tableRow("Manual Updated At", miner.manual_details_updated_at)}
</tbody>
</table>
</div>
<div>
<h3>Latest Runtime Telemetry</h3>
<table>
<tbody>
${tableRow("TH/s", fmtNumber(ths, 2))}
${tableRow("Power W", fmtNumber(powerW, 0))}
${tableRow("Power Rate", fmtNumber(powerRate, 2))}
${tableRow("Fan In", fmtNumber(fanIn, 0))}
${tableRow("Fan Out", fmtNumber(fanOut, 0))}
${tableRow("Env C", fmtNumber(envTemp, 1))}
${tableRow("Chip Avg C", fmtNumber(chipAvg, 1))}
${tableRow("Chip Max C", fmtNumber(chipMax, 1))}
${tableRow("Uptime", fmtSeconds(uptime))}
${tableRow("Elapsed", fmtSeconds(elapsed))}
${tableRow("Pool Rejected %", fmtNumber(poolRejected, 4))}
</tbody>
</table>
</div>
</div>
<h3 style="margin-top:16px;">Pool Info</h3>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>Pool</th>
<th>Status</th>
<th>Active</th>
<th>Worker/User</th>
<th>URL</th>
<th>Accepted</th>
<th>Rejected</th>
<th>Rejected %</th>
<th>Last Share</th>
</tr>
</thead>
<tbody>
${pools.map(pool => `
<tr>
<td>${escapeHtml(pool.pool_index || "")}</td>
<td>${escapeHtml(pool.pool_status || "")}</td>
<td>${escapeHtml(pool.stratum_active === true ? "Yes" : pool.stratum_active === false ? "No" : "")}</td>
<td>${escapeHtml(pool.worker_user || "")}</td>
<td>${escapeHtml(pool.url || "")}</td>
<td>${escapeHtml(pool.accepted || 0)}</td>
<td>${escapeHtml(pool.rejected || 0)}</td>
<td>${escapeHtml(fmtNumber(pool.pool_rejected_percent, 4))}</td>
<td>${escapeHtml(fmtEpoch(pool.last_share_time_epoch))}</td>
</tr>
`).join("") || `<tr><td colspan="9">No pool info has been collected for this miner yet.</td></tr>`}
</tbody>
</table>
</div>
<h3 style="margin-top:16px;">Hashboards</h3>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>Slot</th>
<th>PCB SN</th>
<th>Status</th>
<th>Temp</th>
<th>Chip Avg</th>
<th>Chip Max</th>
<th>Hashrate</th>
</tr>
</thead>
<tbody>
${hashboards.map(board => `
<tr>
<td>${escapeHtml(firstValue(board.slot_number, board.slot))}</td>
<td>${escapeHtml(firstValue(board.pcb_sn, board.hashboard_sn))}</td>
<td>${escapeHtml(board.status || "")}</td>
<td>${escapeHtml(fmtNumber(firstValue(board.temperature_c, board.temperature), 1))}</td>
<td>${escapeHtml(fmtNumber(board.chip_temp_avg_c, 1))}</td>
<td>${escapeHtml(fmtNumber(board.chip_temp_max_c, 1))}</td>
<td>${escapeHtml(fmtNumber(board.mhs_av, 2))}</td>
</tr>
`).join("") || `<tr><td colspan="7">No hashboard details found yet.</td></tr>`}
</tbody>
</table>
</div>
<details style="margin-top:16px;">
<summary>Available raw summary fields</summary>
<pre style="white-space:pre-wrap;">${escapeHtml((data.summary_keys || []).join("\\n"))}</pre>
</details>
<h3 style="margin-top:16px;">Manual Edit Missing Details</h3>
<form id="minerManualDetailsFormV2">
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap:12px;">
<div>
${inputRow("Miner SN", "miner_sn", miner.miner_sn)}
${inputRow("Power SN", "power_sn", miner.power_sn)}
${inputRow("Hostname", "hostname", miner.hostname)}
${inputRow("Model", "model", miner.model)}
${inputRow("Miner Type", "miner_type", miner.miner_type)}
</div>
<div>
${inputRow("Firmware Version", "firmware_version", miner.firmware_version)}
${inputRow("API Version", "api_version", miner.api_version)}
${inputRow("Platform", "platform", miner.platform)}
${textareaRow("Notes", "notes", miner.notes)}
</div>
</div>
${textareaRow("Manual Details JSON", "manual_details", manualDetailsText)}
<button type="submit">Save Manual Details</button>
<span id="minerManualDetailsStatusV2" class="small" style="margin-left:10px;"></span>
</form>
`;
const form = document.getElementById("minerManualDetailsFormV2");
form.addEventListener("submit", async function (event) {
event.preventDefault();
const status = document.getElementById("minerManualDetailsStatusV2");
status.textContent = "Saving...";
const formData = new FormData(form);
const body = {};
for (const pair of formData.entries()) {
body[pair[0]] = pair[1];
}
try {
await apiPatch("/api/miners/" + encodeURIComponent(findMinerId()) + "/manual-details", body);
status.textContent = "Saved.";
await load();
} catch (error) {
status.textContent = "Save failed: " + error.message;
}
});
}
async function load() {
const minerId = findMinerId();
if (!minerId) {
return;
}
const card = ensureCard();
try {
const data = await apiGet("/api/miners/" + encodeURIComponent(minerId) + "/detail-extra");
render(data);
} catch (error) {
card.innerHTML = `
<h2>Extra Miner Details / Manual Edit</h2>
<div style="color:#b00020;">Could not load extra miner details.</div>
<pre style="white-space:pre-wrap;">${escapeHtml(error.message)}</pre>
`;
}
}
function start() {
if (!findMinerId()) {
return;
}
load();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start);
} else {
start();
}
})();
"""
return _Response(content=js, media_type="application/javascript")
@app.middleware("http")
async def minerops_inject_detail_extra_v2(request, call_next):
from fastapi.responses import HTMLResponse as _HTMLResponse
response = await call_next(request)
try:
if request.scope.get("endpoint") is internal_alpha_miner_detail_page:
return response
path_value = request.url.path or ""
if not (path_value.startswith("/miners/") and path_value.endswith("/detail")):
return response
content_type = response.headers.get("content-type", "")
if "text/html" not in content_type.lower():
return response
body = b""
async for chunk in response.body_iterator:
body += chunk
html = body.decode("utf-8", errors="replace")
script_tag = '<script src="/assets/minerops-miner-detail-extra-v2.js?v=20260718c"></script>'
if script_tag not in html:
lower_html = html.lower()
close_index = lower_html.rfind("</body>")
if close_index >= 0:
html = html[:close_index] + script_tag + "\n" + html[close_index:]
else:
html = html + "\n" + script_tag
headers = dict(response.headers)
headers.pop("content-length", None)
headers.pop("content-encoding", None)
return _HTMLResponse(
content=html,
status_code=response.status_code,
headers=headers,
)
except Exception:
return response
@app.get("/api/miners/{miner_id}/detail-clean-v3")
def miner_detail_clean_v3(miner_id: str, current_user: dict = Depends(get_current_user)):
import json as _json
import datetime as _datetime
import decimal as _decimal
import uuid as _uuid
def clean(value):
if value is None:
return None
if isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, (_datetime.datetime, _datetime.date)):
return value.isoformat()
if isinstance(value, _decimal.Decimal):
return float(value)
if isinstance(value, _uuid.UUID):
return str(value)
if isinstance(value, dict):
return {str(k): clean(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [clean(v) for v in value]
return str(value)
def fnum(*values):
for value in values:
try:
if value is None or value == "":
continue
return float(value)
except Exception:
continue
return None
def bval(*values):
for value in values:
if isinstance(value, bool):
return value
if isinstance(value, str):
if value.lower() in ("true", "yes", "1", "y"):
return True
if value.lower() in ("false", "no", "0", "n"):
return False
return None
try:
minerops_ensure_manual_detail_columns()
except Exception:
pass
try:
minerops_ensure_pool_info_table()
except Exception:
pass
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
m.*,
rooms.name AS room_name,
host(m.current_ip) AS current_ip_text
FROM miners m
LEFT JOIN rooms ON rooms.id = m.room_id
WHERE m.id = %s::uuid
LIMIT 1;
""",
(miner_id,),
)
miner_row = cur.fetchone()
if not miner_row:
raise HTTPException(status_code=404, detail="Miner not found.")
miner = dict(miner_row)
miner_ip = miner.get("current_ip_text") or ""
miner_mac = miner.get("current_mac") or ""
cur.execute(
"""
SELECT *
FROM miner_status_snapshots
WHERE miner_id = %s::uuid
ORDER BY created_at DESC
LIMIT 1;
""",
(miner_id,),
)
snapshot_row = cur.fetchone()
snapshot = dict(snapshot_row) if snapshot_row else {}
raw_summary = snapshot.get("raw_summary_json") or {}
summary = {}
try:
if isinstance(raw_summary, str):
raw_summary = _json.loads(raw_summary)
if isinstance(raw_summary, dict):
if isinstance(raw_summary.get("SUMMARY"), list) and raw_summary.get("SUMMARY"):
summary = raw_summary["SUMMARY"][0] or {}
elif isinstance(raw_summary.get("Msg"), dict):
summary = raw_summary["Msg"] or {}
except Exception:
summary = {}
telemetry = {
"ths": None,
"power_w": fnum(snapshot.get("power_w"), summary.get("Power")),
"w_th": fnum(snapshot.get("power_rate_w_per_th"), summary.get("Power Rate")),
"fan_in": fnum(snapshot.get("fan_speed_in"), summary.get("Fan Speed In"), summary.get("Fan In"), summary.get("Fan1")),
"fan_out": fnum(snapshot.get("fan_speed_out"), summary.get("Fan Speed Out"), summary.get("Fan Out"), summary.get("Fan2")),
"env_c": fnum(snapshot.get("env_temp_c"), summary.get("Env Temp")),
"chip_avg_c": fnum(snapshot.get("chip_temp_avg_c"), summary.get("Chip Temp Avg"), summary.get("Temperature")),
"chip_max_c": fnum(snapshot.get("chip_temp_max_c"), summary.get("Chip Temp Max"), summary.get("Temperature")),
"uptime_seconds": fnum(snapshot.get("uptime_seconds"), summary.get("Uptime")),
"elapsed_seconds": fnum(snapshot.get("elapsed_seconds"), summary.get("Elapsed")),
"pool_rejected_percent": fnum(snapshot.get("pool_rejected_percent"), summary.get("Pool Rejected%")),
"hash_stable": snapshot.get("hash_stable"),
"observed_at": snapshot.get("created_at"),
}
mhs_av = fnum(snapshot.get("mhs_av"), summary.get("MHS av"))
if mhs_av is not None:
telemetry["ths"] = mhs_av / 1000000.0
if telemetry["hash_stable"] is None:
telemetry["hash_stable"] = bval(summary.get("Hash Stable"))
pool_info = []
try:
cur.execute(
"""
WITH matched_pool AS (
SELECT *
FROM miner_pool_snapshots
WHERE miner_id = %s::uuid
OR host(ip_address) = %s
OR upper(mac_address) = upper(%s)
),
latest_pool AS (
SELECT DISTINCT ON (pool_index)
*
FROM matched_pool
ORDER BY pool_index, created_at DESC
)
SELECT *
FROM latest_pool
ORDER BY pool_index NULLS LAST;
""",
(miner_id, miner_ip, miner_mac),
)
pool_info = [dict(row) for row in cur.fetchall()]
except Exception:
pool_info = []
return api_response(
data=clean(
{
"miner": miner,
"telemetry": telemetry,
"pool_info": pool_info,
"summary_keys": sorted([str(k) for k in summary.keys()]) if isinstance(summary, dict) else [],
}
)
)
finally:
if conn is not None:
conn.close()
@app.get("/assets/minerops-miner-detail-clean-v3.js")
def minerops_miner_detail_clean_v3_js():
from fastapi.responses import Response as _Response
js = r"""
(function () {
if (window.__mineropsCleanDetailV3Loaded) {
return;
}
window.__mineropsCleanDetailV3Loaded = true;
function getToken() {
const keys = ["minerops_token", "mineropsToken", "access_token", "authToken", "token", "jwt"];
for (const key of keys) {
const value = localStorage.getItem(key) || sessionStorage.getItem(key);
if (value) {
return value;
}
}
return "";
}
function authHeaders(extra) {
const token = getToken();
const headers = Object.assign({}, extra || {});
if (token) {
headers["Authorization"] = "Bearer " + token;
}
return headers;
}
function escapeHtml(value) {
if (value === null || value === undefined) {
return "";
}
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function findMinerId() {
const match = window.location.pathname.match(/\/miners\/([^\/]+)\/detail/);
return match ? decodeURIComponent(match[1]) : "";
}
function fmtNumber(value, decimals) {
const number = Number(value);
if (!Number.isFinite(number)) {
return "";
}
return number.toFixed(decimals || 0);
}
function fmtSeconds(value) {
const seconds = Number(value || 0);
if (!seconds || seconds <= 0) {
return "";
}
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return days + "d " + hours + "h";
}
if (hours > 0) {
return hours + "h " + minutes + "m";
}
return minutes + "m";
}
function fmtEpoch(value) {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) {
return "";
}
return new Date(number * 1000).toLocaleString();
}
async function apiGet(url) {
const response = await fetch(url, {
headers: authHeaders(),
credentials: "same-origin"
});
if (!response.ok) {
throw new Error(await response.text());
}
const payload = await response.json();
return payload.data || payload;
}
async function apiPatch(url, body) {
const response = await fetch(url, {
method: "PATCH",
headers: authHeaders({"Content-Type": "application/json"}),
credentials: "same-origin",
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(await response.text());
}
const payload = await response.json();
return payload.data || payload;
}
function findCardByHeading(text) {
const headings = Array.from(document.querySelectorAll("h1,h2,h3"));
for (const heading of headings) {
if ((heading.textContent || "").trim().toLowerCase() === text.toLowerCase()) {
return heading.closest(".card") || heading.parentElement;
}
}
return null;
}
function removeMessyExtraCards() {
const ids = [
"minerExtraDetailsCard",
"minerExtraDetailsCardV2",
"minerOpsCleanDetailV3Card"
];
for (const id of ids) {
const element = document.getElementById(id);
if (element) {
element.remove();
}
}
}
function metricCard(label, value) {
return `
<div style="border:1px solid #e2e8f0; border-radius:10px; padding:14px; background:#f8fafc;">
<div style="font-size:12px; text-transform:uppercase; color:#64748b; font-weight:700;">${escapeHtml(label)}</div>
<div style="font-size:22px; font-weight:800; margin-top:6px;">${escapeHtml(value || "-")}</div>
</div>
`;
}
function replaceTopTelemetry(data) {
const telemetry = data.telemetry || {};
const oldCard = findCardByHeading("Latest Telemetry");
if (!oldCard) {
return;
}
oldCard.innerHTML = `
<h2>Latest Telemetry</h2>
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:12px;">
${metricCard("TH/s", fmtNumber(telemetry.ths, 2))}
${metricCard("Power W", fmtNumber(telemetry.power_w, 0))}
${metricCard("W/TH", fmtNumber(telemetry.w_th, 2))}
${metricCard("Max Chip C", fmtNumber(telemetry.chip_max_c, 1))}
${metricCard("Avg Chip C", fmtNumber(telemetry.chip_avg_c, 1))}
${metricCard("Env C", fmtNumber(telemetry.env_c, 1))}
${metricCard("Fan In", fmtNumber(telemetry.fan_in, 0))}
${metricCard("Fan Out", fmtNumber(telemetry.fan_out, 0))}
${metricCard("Uptime", fmtSeconds(telemetry.uptime_seconds))}
${metricCard("Elapsed", fmtSeconds(telemetry.elapsed_seconds))}
${metricCard("Pool Rejected %", fmtNumber(telemetry.pool_rejected_percent, 4))}
${metricCard("Hash Stable", telemetry.hash_stable === true ? "Yes" : telemetry.hash_stable === false ? "No" : "")}
</div>
`;
}
function inputRow(label, name, value) {
return `
<label style="display:block; margin-bottom:10px;">
<div class="small">${escapeHtml(label)}</div>
<input name="${escapeHtml(name)}" value="${escapeHtml(value || "")}" style="width:100%;" />
</label>
`;
}
function textareaRow(label, name, value) {
return `
<label style="display:block; margin-bottom:10px;">
<div class="small">${escapeHtml(label)}</div>
<textarea name="${escapeHtml(name)}" rows="4" style="width:100%;">${escapeHtml(value || "")}</textarea>
</label>
`;
}
function renderPoolAndEdit(data) {
removeMessyExtraCards();
const miner = data.miner || {};
const pools = data.pool_info || [];
const manualDetails = miner.manual_details || {};
const manualDetailsText = JSON.stringify(manualDetails, null, 2);
const card = document.createElement("div");
card.id = "minerOpsCleanDetailV3Card";
card.className = "card";
card.style.marginTop = "16px";
card.innerHTML = `
<h2>Pool Info / Manual Edit</h2>
<h3>Pool Info</h3>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>Pool</th>
<th>Status</th>
<th>Active</th>
<th>Worker/User</th>
<th>URL</th>
<th>Accepted</th>
<th>Rejected</th>
<th>Rejected %</th>
<th>Last Share</th>
</tr>
</thead>
<tbody>
${
pools.length
? pools.map(pool => `
<tr>
<td>${escapeHtml(pool.pool_index || "")}</td>
<td>${escapeHtml(pool.pool_status || "")}</td>
<td>${escapeHtml(pool.stratum_active === true ? "Yes" : pool.stratum_active === false ? "No" : "")}</td>
<td>${escapeHtml(pool.worker_user || "")}</td>
<td>${escapeHtml(pool.url || "")}</td>
<td>${escapeHtml(pool.accepted || 0)}</td>
<td>${escapeHtml(pool.rejected || 0)}</td>
<td>${escapeHtml(fmtNumber(pool.pool_rejected_percent, 4))}</td>
<td>${escapeHtml(fmtEpoch(pool.last_share_time_epoch))}</td>
</tr>
`).join("")
: `<tr><td colspan="9">No pool info found for this miner yet. Run the Windows pool poller again if needed.</td></tr>`
}
</tbody>
</table>
</div>
<h3 style="margin-top:16px;">Manual Edit Missing Details</h3>
<form id="minerOpsManualEditV3">
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap:12px;">
<div>
${inputRow("Miner SN", "miner_sn", miner.miner_sn)}
${inputRow("Power SN", "power_sn", miner.power_sn)}
${inputRow("Hostname", "hostname", miner.hostname)}
${inputRow("Model", "model", miner.model)}
${inputRow("Miner Type", "miner_type", miner.miner_type)}
</div>
<div>
${inputRow("Firmware Version", "firmware_version", miner.firmware_version)}
${inputRow("API Version", "api_version", miner.api_version)}
${inputRow("Platform", "platform", miner.platform)}
${textareaRow("Notes", "notes", miner.notes)}
</div>
</div>
${textareaRow("Manual Details JSON", "manual_details", manualDetailsText)}
<button type="submit">Save Manual Details</button>
<span id="minerOpsManualEditV3Status" class="small" style="margin-left:10px;"></span>
</form>
`;
const statusCard = findCardByHeading("Status");
const main = document.querySelector("main") || document.querySelector(".container") || document.body;
if (statusCard && statusCard.parentNode) {
statusCard.parentNode.insertBefore(card, statusCard.nextSibling);
} else {
main.appendChild(card);
}
const form = document.getElementById("minerOpsManualEditV3");
form.addEventListener("submit", async function (event) {
event.preventDefault();
const status = document.getElementById("minerOpsManualEditV3Status");
status.textContent = "Saving...";
const formData = new FormData(form);
const body = {};
for (const pair of formData.entries()) {
body[pair[0]] = pair[1];
}
try {
await apiPatch("/api/miners/" + encodeURIComponent(findMinerId()) + "/manual-details", body);
status.textContent = "Saved.";
await load();
} catch (error) {
status.textContent = "Save failed: " + error.message;
}
});
}
async function load() {
const minerId = findMinerId();
if (!minerId) {
return;
}
const data = await apiGet("/api/miners/" + encodeURIComponent(minerId) + "/detail-clean-v3");
replaceTopTelemetry(data);
renderPoolAndEdit(data);
setTimeout(removeMessyExtraCards, 500);
setTimeout(function () {
replaceTopTelemetry(data);
renderPoolAndEdit(data);
}, 1200);
}
function start() {
if (!findMinerId()) {
return;
}
load().catch(function (error) {
console.error("Clean miner detail V3 failed:", error);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start);
} else {
start();
}
})();
"""
return _Response(content=js, media_type="application/javascript")
@app.middleware("http")
async def minerops_inject_clean_detail_v3(request, call_next):
from fastapi.responses import HTMLResponse as _HTMLResponse
response = await call_next(request)
try:
if request.scope.get("endpoint") is internal_alpha_miner_detail_page:
return response
path_value = request.url.path or ""
if not (path_value.startswith("/miners/") and path_value.endswith("/detail")):
return response
content_type = response.headers.get("content-type", "")
if "text/html" not in content_type.lower():
return response
body = b""
async for chunk in response.body_iterator:
body += chunk
html = body.decode("utf-8", errors="replace")
script_tag = '<script src="/assets/minerops-miner-detail-clean-v3.js?v=20260718g"></script>'
if script_tag not in html:
lower_html = html.lower()
close_index = lower_html.rfind("</body>")
if close_index >= 0:
html = html[:close_index] + script_tag + "\n" + html[close_index:]
else:
html = html + "\n" + script_tag
headers = dict(response.headers)
headers.pop("content-length", None)
headers.pop("content-encoding", None)
return _HTMLResponse(
content=html,
status_code=response.status_code,
headers=headers,
)
except Exception:
return response
from fastapi import Request as MinerOpsApi3Request, HTTPException as MinerOpsApi3HTTPException
@app.post("/api/agent/api3-enrichment")
async def minerops_agent_api3_enrichment(request: MinerOpsApi3Request):
import os
import json
import uuid
import psycopg
from psycopg.types.json import Json
def as_text(value):
if value is None:
return None
value = str(value).strip()
return value if value else None
def fnum(value):
try:
if value is None or value == "":
return None
return float(value)
except Exception:
return None
def fint(value):
try:
if value is None or value == "":
return None
return int(float(value))
except Exception:
return None
def jdict(value):
return value if isinstance(value, dict) else {}
def jlist(value):
if value is None:
return []
if isinstance(value, list):
return value
return [value]
expected_secret = os.getenv("MINEROPS_AGENT_SHARED_SECRET", "")
supplied_secret = (
request.headers.get("x-minerops-agent-secret")
or request.headers.get("x-agent-secret")
or ""
)
if expected_secret and supplied_secret != expected_secret:
raise MinerOpsApi3HTTPException(status_code=401, detail="Invalid agent secret.")
body = await request.json()
agent_id = as_text(body.get("agent_id")) or "unknown-agent"
site_name = as_text(body.get("site_name")) or "unknown-site"
items = body.get("items") or []
if not isinstance(items, list):
raise MinerOpsApi3HTTPException(status_code=400, detail="items must be a list.")
conn = None
processed = 0
matched = 0
api3_ok = 0
api3_failed = 0
miner_updates = 0
status_rows = 0
pool_rows = 0
hashboard_rows = 0
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
ALTER TABLE miners
ADD COLUMN IF NOT EXISTS power_sn TEXT,
ADD COLUMN IF NOT EXISTS psu_model TEXT,
ADD COLUMN IF NOT EXISTS customer_sn TEXT,
ADD COLUMN IF NOT EXISTS api3_supported BOOLEAN,
ADD COLUMN IF NOT EXISTS api3_api_version TEXT,
ADD COLUMN IF NOT EXISTS api3_api_switch TEXT,
ADD COLUMN IF NOT EXISTS api3_last_seen_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS api3_last_error TEXT,
ADD COLUMN IF NOT EXISTS configured_performance_mode TEXT,
ADD COLUMN IF NOT EXISTS configured_performance_mode_source TEXT,
ADD COLUMN IF NOT EXISTS configured_performance_mode_updated_at TIMESTAMPTZ;
"""
)
cur.execute(
"""
ALTER TABLE miner_status_snapshots
ADD COLUMN IF NOT EXISTS ip_address INET,
ADD COLUMN IF NOT EXISTS mac_address TEXT,
ADD COLUMN IF NOT EXISTS power_rate_w_per_th DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS fan_speed_in DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS fan_speed_out DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS env_temp_c DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS elapsed_seconds DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS uptime_seconds DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS pool_rejected_percent DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS raw_summary_json JSONB;
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS miner_pool_snapshots (
id BIGSERIAL PRIMARY KEY,
miner_id UUID,
ip_address INET,
mac_address TEXT,
model TEXT,
pool_index INTEGER,
pool_status TEXT,
stratum_active BOOLEAN,
worker_user TEXT,
url TEXT,
accepted BIGINT,
rejected BIGINT,
pool_rejected_percent DOUBLE PRECISION,
last_share_time_epoch BIGINT,
raw_pool_json JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
"""
)
cur.execute(
"""
ALTER TABLE miner_pool_snapshots
ADD COLUMN IF NOT EXISTS model TEXT,
ADD COLUMN IF NOT EXISTS pool_index INTEGER,
ADD COLUMN IF NOT EXISTS pool_status TEXT,
ADD COLUMN IF NOT EXISTS stratum_active BOOLEAN,
ADD COLUMN IF NOT EXISTS worker_user TEXT,
ADD COLUMN IF NOT EXISTS url TEXT,
ADD COLUMN IF NOT EXISTS accepted BIGINT,
ADD COLUMN IF NOT EXISTS rejected BIGINT,
ADD COLUMN IF NOT EXISTS pool_rejected_percent DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS last_share_time_epoch BIGINT,
ADD COLUMN IF NOT EXISTS raw_pool_json JSONB;
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS miner_api3_enrichment_snapshots (
id BIGSERIAL PRIMARY KEY,
miner_id UUID,
agent_id TEXT,
site_name TEXT,
ip_address INET,
mac_address TEXT,
api_supported BOOLEAN,
api_error TEXT,
api_version TEXT,
api_switch TEXT,
model TEXT,
miner_sn TEXT,
power_sn TEXT,
psu_model TEXT,
device_info JSONB,
miner_setting JSONB,
miner_status JSONB,
fan_setting JSONB,
custom_data JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS miner_api3_hashboard_snapshots (
id BIGSERIAL PRIMARY KEY,
miner_id UUID,
ip_address INET,
mac_address TEXT,
slot INTEGER,
pcb_sn TEXT,
hash_average_ths DOUBLE PRECISION,
factory_hash_ths DOUBLE PRECISION,
freq DOUBLE PRECISION,
effective_chips INTEGER,
chip_temp_min_c DOUBLE PRECISION,
chip_temp_avg_c DOUBLE PRECISION,
chip_temp_max_c DOUBLE PRECISION,
raw_hashboard_json JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
"""
)
for item in items:
if not isinstance(item, dict):
continue
processed += 1
ip = as_text(item.get("ip"))
api_supported = bool(item.get("api3_supported"))
api_error = as_text(item.get("api3_error"))
if api_supported:
api3_ok += 1
else:
api3_failed += 1
device_info = jdict(item.get("device_info"))
miner_setting = jdict(item.get("miner_setting"))
miner_status = jdict(item.get("miner_status"))
fan_setting = jdict(item.get("fan_setting"))
custom_data = jdict(item.get("custom_data"))
device_msg = jdict(device_info.get("msg"))
network = jdict(device_msg.get("network"))
miner = jdict(device_msg.get("miner"))
system = jdict(device_msg.get("system"))
power = jdict(device_msg.get("power"))
setting_msg = jdict(miner_setting.get("msg"))
status_msg = jdict(miner_status.get("msg"))
summary = jdict(status_msg.get("summary"))
pools = jlist(status_msg.get("pools"))
edevs = jlist(status_msg.get("edevs"))
custom_msg = jdict(custom_data.get("msg"))
mac = as_text(network.get("mac")) or as_text(item.get("mac"))
ip_from_api = as_text(network.get("ip"))
if ip_from_api:
ip = ip_from_api
model = as_text(miner.get("type"))
miner_sn = as_text(miner.get("miner-sn"))
customer_sn = as_text(custom_msg.get("CustomerSn"))
api_version = as_text(system.get("api"))
api_switch = as_text(system.get("apiswitch"))
firmware = as_text(system.get("fwversion"))
platform = as_text(system.get("platform"))
power_sn = as_text(power.get("sn"))
psu_model = as_text(power.get("model"))
power_mode = as_text(setting_msg.get("power-mode"))
miner_id = None
if mac:
cur.execute(
"""
SELECT id
FROM miners
WHERE upper(current_mac) = upper(%s)
ORDER BY updated_at DESC NULLS LAST
LIMIT 1;
""",
(mac,),
)
row = cur.fetchone()
if row:
miner_id = row["id"]
if miner_id is None and ip:
cur.execute(
"""
SELECT id
FROM miners
WHERE current_ip = %s::inet
ORDER BY updated_at DESC NULLS LAST
LIMIT 1;
""",
(ip,),
)
row = cur.fetchone()
if row:
miner_id = row["id"]
if miner_id is not None:
matched += 1
set_parts = [
"api3_supported = %s",
"api3_last_seen_at = now()",
"api3_last_error = %s",
"updated_at = now()",
]
params = [api_supported, api_error]
def add_update(column, value, overwrite_blank_only=False):
if value is None:
return
if overwrite_blank_only:
set_parts.append(f"{column} = COALESCE(NULLIF({column}, ''), %s)")
else:
set_parts.append(f"{column} = %s")
params.append(value)
add_update("current_mac", mac, True)
add_update("current_ip", ip, False)
add_update("model", model, True)
add_update("miner_type", model, True)
add_update("miner_sn", miner_sn, True)
add_update("customer_sn", customer_sn, False)
add_update("power_sn", power_sn, True)
add_update("psu_model", psu_model, True)
add_update("api_version", api_version, False)
add_update("api3_api_version", api_version, False)
add_update("api3_api_switch", api_switch, False)
add_update("firmware_version", firmware, False)
add_update("platform", platform, False)
if power_mode:
set_parts.append("configured_performance_mode = %s")
params.append(power_mode.lower())
set_parts.append("configured_performance_mode_source = %s")
params.append("api3_get_miner_setting")
set_parts.append("configured_performance_mode_updated_at = now()")
params.append(str(miner_id))
cur.execute(
"UPDATE miners SET " + ", ".join(set_parts) + " WHERE id = %s::uuid;",
params,
)
miner_updates += 1
cur.execute(
"""
INSERT INTO miner_api3_enrichment_snapshots (
miner_id,
agent_id,
site_name,
ip_address,
mac_address,
api_supported,
api_error,
api_version,
api_switch,
model,
miner_sn,
power_sn,
psu_model,
device_info,
miner_setting,
miner_status,
fan_setting,
custom_data
)
VALUES (
%s,
%s,
%s,
%s::inet,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s::jsonb,
%s::jsonb,
%s::jsonb,
%s::jsonb,
%s::jsonb
);
""",
(
str(miner_id) if miner_id is not None else None,
agent_id,
site_name,
ip,
mac,
api_supported,
api_error,
api_version,
api_switch,
model,
miner_sn,
power_sn,
psu_model,
json.dumps(device_info),
json.dumps(miner_setting),
json.dumps(miner_status),
json.dumps(fan_setting),
json.dumps(custom_data),
),
)
if miner_id is not None and summary:
ths = fnum(summary.get("hash-average"))
mhs_av = ths * 1000000.0 if ths is not None else None
cur.execute(
"""
INSERT INTO miner_status_snapshots (
miner_id,
ip_address,
mac_address,
mhs_av,
power_w,
power_rate_w_per_th,
chip_temp_avg_c,
chip_temp_max_c,
fan_speed_in,
fan_speed_out,
env_temp_c,
elapsed_seconds,
uptime_seconds,
pool_rejected_percent,
raw_summary_json,
created_at
)
VALUES (
%s::uuid,
%s::inet,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s::jsonb,
now()
);
""",
(
str(miner_id),
ip,
mac,
mhs_av,
fnum(summary.get("power-realtime")),
fnum(summary.get("power-rate")),
fnum(summary.get("chip-temp-avg")),
fnum(summary.get("chip-temp-max")),
fnum(summary.get("fan-speed-in")),
fnum(summary.get("fan-speed-out")),
fnum(summary.get("environment-temperature")),
fnum(summary.get("elapsed")),
fnum(summary.get("bootup-time")),
None,
json.dumps(miner_status),
),
)
status_rows += 1
if miner_id is not None and pools:
for pool in pools:
if not isinstance(pool, dict):
continue
cur.execute(
"""
INSERT INTO miner_pool_snapshots (
miner_id,
ip_address,
mac_address,
model,
pool_index,
pool_status,
stratum_active,
worker_user,
url,
accepted,
rejected,
pool_rejected_percent,
last_share_time_epoch,
raw_pool_json,
created_at
)
VALUES (
%s::uuid,
%s::inet,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s::jsonb,
now()
);
""",
(
str(miner_id),
ip,
mac,
model,
fint(pool.get("id")),
as_text(pool.get("status")),
pool.get("stratum-active") if isinstance(pool.get("stratum-active"), bool) else None,
as_text(pool.get("account")),
as_text(pool.get("url")),
None,
None,
fnum(pool.get("reject-rate")),
fint(pool.get("last-share-time")),
json.dumps(pool),
),
)
pool_rows += 1
if miner_id is not None and edevs:
for board in edevs:
if not isinstance(board, dict):
continue
slot = fint(board.get("slot"))
pcb_sn = None
if slot is not None:
pcb_sn = as_text(miner.get(f"pcbsn{slot}"))
cur.execute(
"""
INSERT INTO miner_api3_hashboard_snapshots (
miner_id,
ip_address,
mac_address,
slot,
pcb_sn,
hash_average_ths,
factory_hash_ths,
freq,
effective_chips,
chip_temp_min_c,
chip_temp_avg_c,
chip_temp_max_c,
raw_hashboard_json,
created_at
)
VALUES (
%s::uuid,
%s::inet,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s::jsonb,
now()
);
""",
(
str(miner_id),
ip,
mac,
slot,
pcb_sn,
fnum(board.get("hash-average")),
fnum(board.get("factory-hash")),
fnum(board.get("freq")),
fint(board.get("effective-chips")),
fnum(board.get("chip-temp-min")),
fnum(board.get("chip-temp-avg")),
fnum(board.get("chip-temp-max")),
json.dumps(board),
),
)
hashboard_rows += 1
conn.commit()
return api_response(
data={
"ok": True,
"processed": processed,
"matched": matched,
"api3_ok": api3_ok,
"api3_failed": api3_failed,
"miner_updates": miner_updates,
"status_rows": status_rows,
"pool_rows": pool_rows,
"hashboard_rows": hashboard_rows,
}
)
finally:
if conn is not None:
conn.close()
# MINEROPS_LOCATION_MANAGEMENT_V1
from fastapi.responses import Response as MinerOpsLocationResponse
@app.get("/api/miners/{miner_id}/location-management")
def minerops_get_miner_location_management(
miner_id: str,
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
ensure_maintenance_tables()
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
id::text AS miner_id,
current_mac,
host(current_ip) AS current_ip,
COALESCE(miner_type, model) AS model,
miner_sn,
power_sn,
status
FROM miners
WHERE id = %s::uuid
LIMIT 1;
""",
(miner_id,),
)
miner = cur.fetchone()
if not miner:
raise HTTPException(status_code=404, detail="Miner not found")
cur.execute(
"""
SELECT
mls.asset_state,
mls.state_notes,
mls.updated_at,
lp.id::text AS location_position_id,
lp.room_name,
lp.rack_name,
lp.shelf_name,
lp.shelf_index,
lp.position_index,
lp.position_label,
lp.position_status,
lp.position_kind,
lp.notes AS position_notes
FROM miner_location_states mls
LEFT JOIN location_positions lp ON lp.id = mls.location_position_id
WHERE mls.miner_id = %s::uuid
LIMIT 1;
""",
(miner_id,),
)
state_row = cur.fetchone()
cur.execute(
"""
SELECT
id::text AS location_position_id,
room_name,
rack_name,
shelf_name,
shelf_index,
position_index,
position_label,
position_status,
position_kind,
notes AS position_notes
FROM location_positions
WHERE miner_id = %s::uuid
ORDER BY room_name, rack_name, shelf_index, position_index
LIMIT 1;
""",
(miner_id,),
)
physical_row = cur.fetchone()
current_position = dict(physical_row) if physical_row else None
asset_state = "waiting_placement"
if state_row:
asset_state = state_row.get("asset_state") or asset_state
if current_position:
asset_state = "shelved"
cur.execute(
"""
SELECT
lp.id::text AS location_position_id,
lp.room_name,
lp.rack_name,
lp.shelf_name,
lp.shelf_index,
lp.position_index,
lp.position_label,
lp.position_status,
lp.position_kind,
lp.miner_id::text AS assigned_miner_id,
lp.expected_mac,
miners.current_mac AS assigned_mac,
host(miners.current_ip) AS assigned_ip
FROM location_positions lp
LEFT JOIN miners ON miners.id = lp.miner_id
LEFT JOIN rooms room_order ON lower(room_order.name) = lower(lp.room_name)
WHERE lp.position_kind = 'miner_slot'
AND lp.position_status NOT IN ('switch', 'blocked')
AND (
lp.miner_id IS NULL
OR lp.miner_id = %s::uuid
)
ORDER BY
COALESCE(room_order.sort_order, 999999),
lp.room_name,
lp.rack_name,
lp.shelf_index,
lp.position_index;
""",
(miner_id,),
)
available_positions = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT
id::text AS event_id,
event_type,
event_status,
event_title,
description,
event_at,
component_slot
FROM miner_maintenance_events
WHERE miner_id = %s::uuid
AND (
event_type = 'moved'
OR component_type = 'location'
OR event_title ILIKE '%%location%%'
)
ORDER BY event_at DESC NULLS LAST, created_at DESC
LIMIT 10;
""",
(miner_id,),
)
recent_location_events = [dict(row) for row in cur.fetchall()]
return api_response(
data={
"miner": dict(miner),
"asset_state": asset_state,
"state": dict(state_row) if state_row else None,
"current_position": current_position,
"available_positions": available_positions,
"virtual_states": [
{
"value": "waiting_placement",
"label": "Unracked / Waiting Placement",
},
{
"value": "repair",
"label": "Repair",
},
{
"value": "storage",
"label": "Inventory / Storage",
},
{
"value": "decommissioned",
"label": "Decommissioned",
},
],
"recent_location_events": recent_location_events,
}
)
finally:
if conn is not None:
conn.close()
MINEROPS_LOCATION_MANAGEMENT_JS = r"""
(function () {
"use strict";
const pathMatch = window.location.pathname.match(/\/miners\/([^\/]+)\/detail/);
if (!pathMatch) {
return;
}
const minerId = decodeURIComponent(pathMatch[1]);
function getStoredToken() {
const keys = [
"minerops_token",
"mineropsToken",
"authToken",
"access_token",
"token"
];
for (const storage of [window.localStorage, window.sessionStorage]) {
if (!storage) {
continue;
}
for (const key of keys) {
const value = storage.getItem(key);
if (value) {
return value;
}
}
}
return "";
}
function authHeaders(extra) {
const headers = Object.assign({}, extra || {});
const token = getStoredToken();
if (token && !headers.Authorization) {
headers.Authorization = "Bearer " + token;
}
return headers;
}
async function apiGet(url) {
const response = await fetch(url, {
method: "GET",
credentials: "same-origin",
headers: authHeaders({
"Accept": "application/json"
})
});
const payload = await response.json().catch(function () {
return null;
});
if (!response.ok || (payload && payload.error)) {
const message = payload && payload.error ? payload.error : "Request failed";
throw new Error(message);
}
return payload ? payload.data : null;
}
async function apiPost(url, body) {
const response = await fetch(url, {
method: "POST",
credentials: "same-origin",
headers: authHeaders({
"Accept": "application/json",
"Content-Type": "application/json"
}),
body: JSON.stringify(body)
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch (error) {
payload = null;
}
if (!response.ok || (payload && payload.error)) {
const detail = payload && payload.error ? payload.error : text || "Request failed";
throw new Error(detail);
}
return payload ? payload.data : null;
}
function escapeHtml(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function stateLabel(value) {
const labels = {
shelved: "Shelved",
waiting_placement: "Unracked / Waiting Placement",
repair: "Repair",
storage: "Inventory / Storage",
decommissioned: "Decommissioned",
unassigned: "Unassigned"
};
return labels[value] || value || "Unknown";
}
function positionLabel(position) {
if (!position) {
return "No shelf position assigned";
}
const label = [
position.room_name || "-",
position.rack_name || "-",
position.shelf_name || "-",
"Pos " + (position.position_index || "-")
].join(" / ");
return label;
}
function findMountPoint() {
const candidates = [
"main",
".minerops-main",
".container",
".content",
"body"
];
for (const selector of candidates) {
const el = document.querySelector(selector);
if (el) {
return el;
}
}
return document.body;
}
function ensureCard() {
let card = document.getElementById("mineropsLocationManagementCard");
if (card) {
return card;
}
card = document.createElement("section");
card.id = "mineropsLocationManagementCard";
card.style.margin = "16px 0";
card.style.padding = "16px";
card.style.border = "1px solid #d1d5db";
card.style.borderRadius = "12px";
card.style.background = "#ffffff";
card.style.boxShadow = "0 1px 2px rgba(15, 23, 42, 0.08)";
card.innerHTML = '<div style="color:#64748b;">Loading location management...</div>';
const mount = findMountPoint();
const firstLargeCard = mount.querySelector(".card, section, .minerops-card");
if (firstLargeCard && firstLargeCard.parentNode) {
firstLargeCard.parentNode.insertBefore(card, firstLargeCard.nextSibling);
} else if (mount === document.body) {
document.body.insertBefore(card, document.body.firstChild);
} else {
mount.appendChild(card);
}
return card;
}
function renderCard(data) {
const card = ensureCard();
const miner = data.miner || {};
const currentPosition = data.current_position || null;
const availablePositions = data.available_positions || [];
const events = data.recent_location_events || [];
const assetState = data.asset_state || "waiting_placement";
const currentLocationText = assetState === "shelved"
? positionLabel(currentPosition)
: stateLabel(assetState);
const options = availablePositions.map(function (position) {
const selected = currentPosition && currentPosition.location_position_id === position.location_position_id
? " selected"
: "";
return '<option value="' + escapeHtml(position.location_position_id) + '"' + selected + '>' +
escapeHtml(positionLabel(position)) +
'</option>';
}).join("");
const eventRows = events.length
? events.map(function (event) {
return '<li style="margin:6px 0;">' +
'<div><strong>' + escapeHtml(event.event_title || event.event_type || "Location event") + '</strong></div>' +
'<div style="font-size:13px;color:#64748b;">' + escapeHtml(event.event_at || "") + '</div>' +
'<div style="font-size:13px;">' + escapeHtml(event.description || "") + '</div>' +
'</li>';
}).join("")
: '<li style="color:#64748b;">No location changes recorded yet.</li>';
card.innerHTML = `
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;">
<div>
<h2 style="margin:0 0 4px 0;font-size:20px;">Location Management</h2>
<div style="color:#64748b;font-size:14px;">
Move this miner between shelves, repair, inventory, or decommissioned.
</div>
</div>
<button type="button" id="mineropsLocationRefreshBtn" style="border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;background:#fff;cursor:pointer;">
Refresh
</button>
</div>
<div style="margin-top:14px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;">
<div style="border:1px solid #e5e7eb;border-radius:10px;padding:10px;">
<div style="font-size:13px;color:#64748b;">Current State</div>
<div style="font-weight:700;">${escapeHtml(stateLabel(assetState))}</div>
</div>
<div style="border:1px solid #e5e7eb;border-radius:10px;padding:10px;">
<div style="font-size:13px;color:#64748b;">Current Location</div>
<div style="font-weight:700;">${escapeHtml(currentLocationText)}</div>
</div>
<div style="border:1px solid #e5e7eb;border-radius:10px;padding:10px;">
<div style="font-size:13px;color:#64748b;">Miner</div>
<div style="font-weight:700;">${escapeHtml(miner.current_mac || miner.current_ip || miner.miner_id || "-")}</div>
</div>
</div>
<div id="mineropsLocationMessage" style="display:none;margin-top:12px;border-radius:10px;padding:10px;"></div>
<div style="margin-top:16px;display:grid;grid-template-columns:minmax(0,1fr);gap:14px;">
<div style="border:1px solid #e5e7eb;border-radius:10px;padding:12px;">
<h3 style="margin:0 0 10px 0;font-size:16px;">Move to Shelf Position</h3>
<label style="display:block;font-size:14px;font-weight:600;margin-bottom:6px;" for="mineropsLocationPositionSelect">
Available shelf position
</label>
<select id="mineropsLocationPositionSelect" style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:9px;">
${options || '<option value="">No available positions found</option>'}
</select>
<label style="display:block;font-size:14px;font-weight:600;margin:10px 0 6px 0;" for="mineropsLocationNote">
Note
</label>
<input id="mineropsLocationNote" type="text" placeholder="Optional note, e.g. moved after repair" style="width:100%;box-sizing:border-box;border:1px solid #d1d5db;border-radius:8px;padding:9px;">
<button type="button" id="mineropsLocationMovePhysicalBtn" style="margin-top:10px;background:#0f766e;color:#fff;border:0;border-radius:8px;padding:9px 12px;cursor:pointer;">
Move to Selected Shelf Position
</button>
</div>
<div style="border:1px solid #e5e7eb;border-radius:10px;padding:12px;">
<h3 style="margin:0 0 10px 0;font-size:16px;">Move Off Shelf</h3>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button type="button" class="minerops-location-state-btn" data-state="waiting_placement" style="border:1px solid #d1d5db;border-radius:8px;padding:9px 12px;background:#fff;cursor:pointer;">Unracked</button>
<button type="button" class="minerops-location-state-btn" data-state="repair" style="border:1px solid #d1d5db;border-radius:8px;padding:9px 12px;background:#fff;cursor:pointer;">Repair</button>
<button type="button" class="minerops-location-state-btn" data-state="storage" style="border:1px solid #d1d5db;border-radius:8px;padding:9px 12px;background:#fff;cursor:pointer;">Inventory / Storage</button>
<button type="button" class="minerops-location-state-btn" data-state="decommissioned" style="border:1px solid #d1d5db;border-radius:8px;padding:9px 12px;background:#fff;cursor:pointer;">Decommissioned</button>
</div>
</div>
<div style="border:1px solid #e5e7eb;border-radius:10px;padding:12px;">
<h3 style="margin:0 0 10px 0;font-size:16px;">Recent Location History</h3>
<ul style="margin:0;padding-left:18px;">${eventRows}</ul>
</div>
</div>
`;
card.querySelector("#mineropsLocationRefreshBtn").addEventListener("click", loadLocationCard);
card.querySelector("#mineropsLocationMovePhysicalBtn").addEventListener("click", movePhysical);
card.querySelectorAll(".minerops-location-state-btn").forEach(function (button) {
button.addEventListener("click", function () {
moveVirtual(button.getAttribute("data-state"));
});
});
}
function showMessage(type, text) {
const box = document.getElementById("mineropsLocationMessage");
if (!box) {
return;
}
box.style.display = "block";
box.style.border = type === "error" ? "1px solid #fecaca" : "1px solid #bbf7d0";
box.style.background = type === "error" ? "#fef2f2" : "#f0fdf4";
box.style.color = type === "error" ? "#991b1b" : "#166534";
box.textContent = text;
}
async function loadLocationCard() {
const card = ensureCard();
try {
card.innerHTML = '<div style="color:#64748b;">Loading location management...</div>';
const data = await apiGet("/api/miners/" + encodeURIComponent(minerId) + "/location-management");
renderCard(data);
} catch (error) {
card.innerHTML = '<div style="color:#991b1b;">Location management failed to load: ' + escapeHtml(error.message || error) + '</div>';
}
}
async function movePhysical() {
const select = document.getElementById("mineropsLocationPositionSelect");
const note = document.getElementById("mineropsLocationNote");
if (!select || !select.value) {
showMessage("error", "Choose a shelf position first.");
return;
}
try {
showMessage("ok", "Moving miner...");
const result = await apiPost("/api/location/miners/move", {
miner_id: minerId,
destination_type: "physical",
location_position_id: select.value,
notes: note ? note.value : ""
});
showMessage("ok", "Moved from " + (result.old_location || "-") + " to " + (result.new_location || "-") + ".");
setTimeout(loadLocationCard, 700);
} catch (error) {
showMessage("error", error.message || String(error));
}
}
async function moveVirtual(state) {
const note = document.getElementById("mineropsLocationNote");
try {
showMessage("ok", "Moving miner...");
const result = await apiPost("/api/location/miners/move", {
miner_id: minerId,
destination_type: "virtual",
asset_state: state,
notes: note ? note.value : ""
});
showMessage("ok", "Moved from " + (result.old_location || "-") + " to " + stateLabel(result.asset_state || state) + ".");
setTimeout(loadLocationCard, 700);
} catch (error) {
showMessage("error", error.message || String(error));
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", loadLocationCard);
} else {
loadLocationCard();
}
})();
"""
@app.get("/assets/minerops-location-management.js")
def minerops_location_management_asset():
return MinerOpsLocationResponse(
content=MINEROPS_LOCATION_MANAGEMENT_JS,
media_type="application/javascript",
)
@app.middleware("http")
async def minerops_inject_location_management(request, call_next):
response = await call_next(request)
try:
if request.scope.get("endpoint") is internal_alpha_miner_detail_page:
return response
path = request.url.path or ""
if not (path.startswith("/miners/") and path.endswith("/detail")):
return response
content_type = response.headers.get("content-type", "")
if "text/html" not in content_type.lower():
return response
body = b""
async for chunk in response.body_iterator:
body += chunk
html = body.decode("utf-8", errors="ignore")
script_tag = '<script src="/assets/minerops-location-management.js"></script>'
if script_tag not in html:
if "</body>" in html:
html = html.replace("</body>", script_tag + "</body>")
else:
html += script_tag
headers = dict(response.headers)
headers.pop("content-length", None)
return MinerOpsLocationResponse(
content=html,
status_code=response.status_code,
headers=headers,
media_type="text/html",
)
except Exception:
return response
# MINEROPS_MAP_CLICK_MOVE_V1
from fastapi.responses import Response as MinerOpsMapMoveResponse
MINEROPS_MAP_CLICK_MOVE_JS = r"""
(function () {
"use strict";
if (window.location.pathname !== "/locations") {
return;
}
let mapData = null;
let positionsById = {};
let minersById = {};
let currentTargetPosition = null;
let assignTimer = null;
function getStoredToken() {
const keys = [
"minerops_token",
"mineropsToken",
"authToken",
"access_token",
"token"
];
for (const storage of [window.localStorage, window.sessionStorage]) {
if (!storage) {
continue;
}
for (const key of keys) {
const value = storage.getItem(key);
if (value) {
return value;
}
}
}
return "";
}
function authHeaders(extra) {
const headers = Object.assign({}, extra || {});
const token = getStoredToken();
if (token && !headers.Authorization) {
headers.Authorization = "Bearer " + token;
}
return headers;
}
async function apiGet(url) {
const response = await fetch(url, {
method: "GET",
credentials: "same-origin",
headers: authHeaders({
"Accept": "application/json"
})
});
const payload = await response.json().catch(function () {
return null;
});
if (!response.ok || (payload && payload.error)) {
throw new Error((payload && payload.error) || "Request failed");
}
return payload ? payload.data : null;
}
async function apiPost(url, body) {
const response = await fetch(url, {
method: "POST",
credentials: "same-origin",
headers: authHeaders({
"Accept": "application/json",
"Content-Type": "application/json"
}),
body: JSON.stringify(body)
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch (error) {
payload = null;
}
if (!response.ok || (payload && payload.error)) {
throw new Error((payload && payload.error) || text || "Request failed");
}
return payload ? payload.data : null;
}
function escapeHtml(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function positionLabel(position) {
if (!position) {
return "Unknown position";
}
return [
position.room_name || "-",
position.rack_name || "-",
position.shelf_name || "-",
"Pos " + (position.position_index || "-")
].join(" / ");
}
function minerLabel(miner) {
if (!miner) {
return "Unknown miner";
}
const parts = [];
if (miner.current_mac) {
parts.push(miner.current_mac);
}
if (miner.current_ip) {
parts.push(miner.current_ip);
}
if (miner.model || miner.miner_type) {
parts.push(miner.miner_type || miner.model);
}
if (miner.miner_sn) {
parts.push("SN " + miner.miner_sn);
}
return parts.join(" | ") || miner.miner_id || "Unknown miner";
}
function getMinerCurrentLocation(minerId) {
if (!mapData || !minerId) {
return "Unknown";
}
const positions = mapData.positions || [];
for (const position of positions) {
if (position.miner_id === minerId) {
return positionLabel(position);
}
}
const virtuals = mapData.virtual_miners || [];
for (const miner of virtuals) {
if (miner.miner_id === minerId) {
const state = miner.asset_state || "waiting_placement";
const labels = {
waiting_placement: "Unracked / Waiting Placement",
repair: "Repair",
storage: "Inventory / Storage",
decommissioned: "Decommissioned"
};
return labels[state] || state;
}
}
return "Unknown";
}
function isMoveTarget(position) {
if (!position) {
return false;
}
if (position.miner_id) {
return false;
}
if (position.position_kind !== "miner_slot") {
return false;
}
const status = String(position.position_status || "").toLowerCase();
if (status === "blocked" || status === "switch") {
return false;
}
return true;
}
function buildMinerIndex(data) {
minersById = {};
const positions = data.positions || [];
for (const position of positions) {
if (!position.miner_id) {
continue;
}
minersById[position.miner_id] = {
miner_id: position.miner_id,
current_mac: position.current_mac,
current_ip: position.current_ip,
status: position.miner_status,
hostname: position.hostname,
miner_sn: position.miner_sn,
power_sn: position.power_sn,
model: position.model,
miner_type: position.miner_type,
asset_state: position.asset_state || "shelved"
};
}
const virtuals = data.virtual_miners || [];
for (const miner of virtuals) {
if (!miner.miner_id) {
continue;
}
minersById[miner.miner_id] = miner;
}
}
async function refreshMapData() {
const data = await apiGet("/api/location/map");
mapData = data || {
positions: [],
virtual_miners: []
};
positionsById = {};
for (const position of mapData.positions || []) {
if (position.location_position_id) {
positionsById[position.location_position_id] = position;
}
}
buildMinerIndex(mapData);
assignTileTargets();
}
function positionFromTileDataset(tile) {
if (!tile) {
return null;
}
return {
location_position_id: tile.getAttribute("data-location-position-id") || null,
room_name: tile.dataset.roomName || "",
rack_name: tile.dataset.rackName || "",
shelf_name: tile.dataset.shelfName || "",
position_index: Number(tile.dataset.positionIndex || 0),
position_kind: tile.dataset.positionKind || "",
position_status: tile.dataset.positionStatus || "",
conflict_reason: tile.dataset.conflictReason || "",
miner_id: tile.dataset.minerId || null
};
}
function assignTileTargets() {
const tiles = Array.from(document.querySelectorAll(".tile"));
if (!tiles.length) {
return;
}
for (const tile of tiles) {
const position = positionFromTileDataset(tile);
if (!position || !position.room_name || !position.rack_name || !position.shelf_name || !position.position_index) {
tile.classList.remove("minerops-map-empty-move-target");
continue;
}
tile.setAttribute("data-position-label", positionLabel(position));
if (isMoveTarget(position)) {
tile.classList.add("minerops-map-empty-move-target");
tile.setAttribute("title", "Click to move a miner here: " + positionLabel(position));
if (!tile.getAttribute("tabindex")) {
tile.setAttribute("tabindex", "0");
}
} else {
tile.classList.remove("minerops-map-empty-move-target");
}
}
}
function createModal() {
if (document.getElementById("mineropsMapMoveModal")) {
return;
}
const style = document.createElement("style");
style.textContent = `
.minerops-map-empty-move-target {
cursor: copy !important;
outline-offset: 2px;
}
.minerops-map-empty-move-target:hover {
outline: 4px solid #0f766e !important;
box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.18);
}
#mineropsMapMoveModal {
position: fixed;
inset: 0;
z-index: 999999;
background: rgba(15, 23, 42, 0.45);
display: none;
align-items: center;
justify-content: center;
padding: 18px;
}
#mineropsMapMoveModal .minerops-map-move-panel {
width: min(760px, 100%);
max-height: 88vh;
overflow: auto;
background: white;
color: #102a43;
border-radius: 12px;
border: 1px solid #d9e2ec;
box-shadow: 0 12px 36px rgba(15, 23, 42, 0.28);
padding: 16px;
}
#mineropsMapMoveModal .minerops-map-move-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
margin-top: 12px;
}
#mineropsMapMoveModal label {
display: block;
font-size: 13px;
font-weight: 700;
color: #334155;
margin-bottom: 5px;
}
#mineropsMapMoveModal input,
#mineropsMapMoveModal select {
width: 100%;
box-sizing: border-box;
border: 1px solid #bcccdc;
border-radius: 8px;
padding: 9px;
}
#mineropsMapMoveModal .minerops-map-move-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
#mineropsMapMoveModal .primary {
background: #0f766e;
color: white;
border-color: #0f766e;
}
#mineropsMapMoveStatus {
display: none;
margin-top: 12px;
border-radius: 8px;
padding: 10px;
font-size: 13px;
}
`;
document.head.appendChild(style);
const modal = document.createElement("div");
modal.id = "mineropsMapMoveModal";
modal.innerHTML = `
<div class="minerops-map-move-panel" role="dialog" aria-modal="true" aria-labelledby="mineropsMapMoveTitle">
<div style="display:flex;justify-content:space-between;gap:12px;align-items:start;">
<div>
<h2 id="mineropsMapMoveTitle" style="margin:0 0 4px 0;">Move Miner to Empty Position</h2>
<div id="mineropsMapMovePosition" style="font-size:13px;color:#52606d;"></div>
</div>
<button type="button" id="mineropsMapMoveCloseBtn">Close</button>
</div>
<div id="mineropsMapMoveStatus"></div>
<div class="minerops-map-move-row">
<div>
<label for="mineropsMapMoveSearch">Search miner</label>
<input id="mineropsMapMoveSearch" type="search" placeholder="MAC, IP, SN, model...">
</div>
<div>
<label for="mineropsMapMoveSelect">Miner to move</label>
<select id="mineropsMapMoveSelect"></select>
</div>
</div>
<div style="margin-top:10px;">
<label for="mineropsMapMoveNote">Move note</label>
<input id="mineropsMapMoveNote" type="text" placeholder="Optional note, e.g. moved from repair to Rack 4C">
</div>
<div id="mineropsMapMoveCurrentLocation" style="margin-top:10px;font-size:13px;color:#334155;"></div>
<div class="minerops-map-move-actions">
<button type="button" id="mineropsMapMoveConfirmBtn" class="primary">Move Miner Here</button>
<button type="button" id="mineropsMapMoveCancelBtn">Cancel</button>
</div>
</div>
`;
document.body.appendChild(modal);
document.getElementById("mineropsMapMoveCloseBtn").addEventListener("click", closeModal);
document.getElementById("mineropsMapMoveCancelBtn").addEventListener("click", closeModal);
document.getElementById("mineropsMapMoveConfirmBtn").addEventListener("click", confirmMove);
document.getElementById("mineropsMapMoveSearch").addEventListener("input", renderMinerOptions);
document.getElementById("mineropsMapMoveSelect").addEventListener("change", updateSelectedMinerLocation);
modal.addEventListener("click", function (event) {
if (event.target === modal) {
closeModal();
}
});
}
function showStatus(type, message) {
const status = document.getElementById("mineropsMapMoveStatus");
if (!status) {
return;
}
status.style.display = "block";
status.style.background = type === "error" ? "#fef2f2" : "#f0fdf4";
status.style.border = type === "error" ? "1px solid #fecaca" : "1px solid #bbf7d0";
status.style.color = type === "error" ? "#991b1b" : "#166534";
status.textContent = message;
}
function openModal(position) {
createModal();
currentTargetPosition = position;
document.getElementById("mineropsMapMovePosition").textContent = positionLabel(position);
document.getElementById("mineropsMapMoveSearch").value = "";
document.getElementById("mineropsMapMoveNote").value = "";
document.getElementById("mineropsMapMoveStatus").style.display = "none";
renderMinerOptions();
updateSelectedMinerLocation();
document.getElementById("mineropsMapMoveModal").style.display = "flex";
document.getElementById("mineropsMapMoveSearch").focus();
}
function closeModal() {
const modal = document.getElementById("mineropsMapMoveModal");
if (modal) {
modal.style.display = "none";
}
currentTargetPosition = null;
}
function renderMinerOptions() {
const select = document.getElementById("mineropsMapMoveSelect");
const search = String(document.getElementById("mineropsMapMoveSearch").value || "").toLowerCase().trim();
if (!select) {
return;
}
const miners = Object.values(minersById).sort(function (a, b) {
return minerLabel(a).localeCompare(minerLabel(b));
});
const filtered = miners.filter(function (miner) {
if (!search) {
return true;
}
const haystack = [
miner.current_mac,
miner.current_ip,
miner.model,
miner.miner_type,
miner.miner_sn,
miner.power_sn,
miner.hostname,
miner.asset_state
].join(" ").toLowerCase();
return haystack.indexOf(search) !== -1;
});
select.innerHTML = filtered.map(function (miner) {
return '<option value="' + escapeHtml(miner.miner_id) + '">' +
escapeHtml(minerLabel(miner)) +
'</option>';
}).join("");
if (!filtered.length) {
select.innerHTML = '<option value="">No matching miners found</option>';
}
updateSelectedMinerLocation();
}
function updateSelectedMinerLocation() {
const select = document.getElementById("mineropsMapMoveSelect");
const box = document.getElementById("mineropsMapMoveCurrentLocation");
if (!select || !box) {
return;
}
const minerId = select.value;
if (!minerId) {
box.textContent = "";
return;
}
box.textContent = "Current miner location: " + getMinerCurrentLocation(minerId);
}
async function confirmMove() {
const select = document.getElementById("mineropsMapMoveSelect");
const note = document.getElementById("mineropsMapMoveNote");
if (!currentTargetPosition || !currentTargetPosition.location_position_id) {
showStatus("error", "No target position selected.");
return;
}
if (!select || !select.value) {
showStatus("error", "Choose a miner first.");
return;
}
try {
showStatus("ok", "Moving miner...");
const body = {
miner_id: select.value,
destination_type: "physical",
notes: note ? note.value : ""
};
if (currentTargetPosition.location_position_id) {
body.location_position_id = currentTargetPosition.location_position_id;
} else {
body.room_name = currentTargetPosition.room_name;
body.rack_name = currentTargetPosition.rack_name;
body.shelf_name = currentTargetPosition.shelf_name;
body.position_index = Number(currentTargetPosition.position_index || 0);
}
const result = await apiPost("/api/location/miners/move", body);
showStatus("ok", "Moved from " + (result.old_location || "-") + " to " + (result.new_location || "-") + ".");
setTimeout(function () {
window.location.reload();
}, 900);
} catch (error) {
showStatus("error", error.message || String(error));
}
}
function handleTileClick(event) {
const tile = event.target.closest(".tile");
if (!tile) {
return;
}
const position = positionFromTileDataset(tile);
if (!isMoveTarget(position)) {
return;
}
event.preventDefault();
event.stopPropagation();
openModal(position);
}
function handleTileKey(event) {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
const tile = event.target.closest(".tile");
if (!tile || !tile.classList.contains("minerops-map-empty-move-target")) {
return;
}
event.preventDefault();
tile.click();
}
function startAssignLoop() {
if (assignTimer) {
window.clearInterval(assignTimer);
}
let attempts = 0;
assignTimer = window.setInterval(function () {
attempts += 1;
assignTileTargets();
if (attempts > 40) {
window.clearInterval(assignTimer);
assignTimer = null;
}
}, 750);
}
async function init() {
createModal();
try {
await refreshMapData();
} catch (error) {
console.warn("MinerOps map click-to-move failed to load map data", error);
}
document.addEventListener("click", handleTileClick, true);
document.addEventListener("keydown", handleTileKey, true);
const observer = new MutationObserver(function () {
assignTileTargets();
});
observer.observe(document.body, {
childList: true,
subtree: true
});
startAssignLoop();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
"""
@app.get("/assets/minerops-map-click-move.js")
def minerops_map_click_move_asset():
return MinerOpsMapMoveResponse(
content=MINEROPS_MAP_CLICK_MOVE_JS,
media_type="application/javascript",
)
@app.middleware("http")
async def minerops_inject_map_click_move(request, call_next):
response = await call_next(request)
try:
path = request.url.path or ""
if path != "/locations":
return response
content_type = response.headers.get("content-type", "")
if "text/html" not in content_type.lower():
return response
body = b""
async for chunk in response.body_iterator:
body += chunk
html = body.decode("utf-8", errors="ignore")
script_tag = '<script src="/assets/minerops-map-click-move.js"></script>'
if script_tag not in html:
if "</body>" in html:
html = html.replace("</body>", script_tag + "</body>")
else:
html += script_tag
headers = dict(response.headers)
headers.pop("content-length", None)
return MinerOpsMapMoveResponse(
content=html,
status_code=response.status_code,
headers=headers,
media_type="text/html",
)
except Exception:
return response
# MINEROPS_REPAIR_QUEUE_V1
from fastapi.responses import HTMLResponse as MinerOpsRepairHTMLResponse
from fastapi.responses import Response as MinerOpsRepairResponse
class MinerOpsRepairNoteRequest(BaseModel):
note: str
event_status: str = "in_progress"
class MinerOpsRepairStatusRequest(BaseModel):
event_status: str
note: str | None = None
class MinerOpsRepairStateRequest(BaseModel):
asset_state: str
note: str | None = None
def minerops_repair_current_user_id(current_user):
try:
return get_current_user_id_for_location(current_user)
except Exception:
return None
def minerops_repair_allowed_status(value):
value = str(value or "").strip().lower()
allowed = {
"open",
"diagnosing",
"waiting_parts",
"in_progress",
"testing",
"ready_to_shelf",
"needs_followup",
"completed",
"failed",
"cancelled",
}
if value not in allowed:
raise HTTPException(
status_code=400,
detail="Invalid repair status",
)
return value
@app.get("/api/repairs/queue")
def minerops_repair_queue_api(
query: str | None = Query(default=None, max_length=255),
scope: RepairCenterScope = "active",
status_filter: str | None = Query(default=None, alias="status", max_length=64),
model: str | None = Query(default=None, max_length=255),
lifecycle_status: RepairMinerLifecycle | None = None,
site_id: UUID | None = None,
room_id: UUID | None = None,
limit: int = Query(default=250, ge=1, le=250),
offset: int = Query(default=0, ge=0),
current_user: dict = Depends(get_current_user),
):
can_view_miners = user_has_permission(current_user, "miners.view")
can_view_locations = user_has_permission(current_user, "locations.view")
if (site_id or room_id) and not can_view_locations:
raise HTTPException(
status_code=403,
detail="Location viewing permission is required for location filters",
)
if model and not can_view_miners:
raise HTTPException(
status_code=403,
detail="Miner viewing permission is required for model filters",
)
filters = RepairCenterFilters(
query=query,
scope=scope,
status=status_filter,
model=model,
lifecycle_status=lifecycle_status,
site_id=site_id,
room_id=room_id,
limit=limit,
offset=offset,
)
access = RepairCenterAccess(
can_read_repairs=user_has_permission(current_user, "repairs.read"),
can_view_miners=can_view_miners,
can_view_locations=can_view_locations,
can_complete_repairs=user_has_permission(current_user, "repairs.complete"),
can_manage_parts=user_has_permission(current_user, "repairs.parts.manage"),
can_manage_legacy=user_has_permission(current_user, "repairs.manage"),
)
result = RepairCenterService(RepairCenterRepository()).list_repair_center(
filters, access
)
return result.model_dump(mode="json")
def minerops_repair_reject_modular_mutation(cur, miner_id: str) -> None:
cur.execute(
"""
SELECT 1
FROM repairs
WHERE miner_id = %s::uuid
AND status IN ('OPEN', 'IN_PROGRESS', 'WAITING_FOR_PARTS', 'READY_FOR_TEST')
LIMIT 1
""",
(miner_id,),
)
if cur.fetchone():
raise HTTPException(
status_code=409,
detail="This miner has an active modular repair; use the modular repair workflow",
)
@app.post("/api/repairs/{miner_id}/note")
def minerops_repair_add_note_api(
miner_id: str,
request: MinerOpsRepairNoteRequest,
current_user: dict = Depends(get_current_user),
):
ensure_maintenance_tables()
note = str(request.note or "").strip()
if not note:
raise HTTPException(status_code=400, detail="Repair note is required")
event_status = minerops_repair_allowed_status(request.event_status)
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT id::text, current_mac
FROM miners
WHERE id = %s::uuid
LIMIT 1
FOR UPDATE;
""",
(miner_id,),
)
miner = cur.fetchone()
if not miner:
raise HTTPException(status_code=404, detail="Miner not found")
minerops_repair_reject_modular_mutation(cur, miner_id)
cur.execute(
"""
INSERT INTO miner_maintenance_events (
id,
miner_id,
event_type,
event_status,
event_title,
description,
event_at,
component_type,
component_slot,
created_by_user_id
)
VALUES (
%s::uuid,
%s::uuid,
'repair',
%s,
'Repair note',
%s,
now(),
'repair',
'Repair Queue',
%s::uuid
)
RETURNING id::text;
""",
(
str(uuid4()),
miner_id,
event_status,
note,
minerops_repair_current_user_id(current_user),
),
)
event = cur.fetchone()
conn.commit()
return api_response(
data={
"miner_id": miner_id,
"event_id": event["id"] if event else None,
"event_status": event_status,
"note": note,
}
)
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
@app.post("/api/repairs/{miner_id}/status")
def minerops_repair_update_status_api(
miner_id: str,
request: MinerOpsRepairStatusRequest,
current_user: dict = Depends(get_current_user),
):
ensure_maintenance_tables()
event_status = minerops_repair_allowed_status(request.event_status)
note = str(request.note or "").strip()
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT id::text
FROM miners
WHERE id = %s::uuid
LIMIT 1
FOR UPDATE;
""",
(miner_id,),
)
miner = cur.fetchone()
if not miner:
raise HTTPException(status_code=404, detail="Miner not found")
minerops_repair_reject_modular_mutation(cur, miner_id)
cur.execute(
"""
SELECT id::text
FROM miner_maintenance_events
WHERE miner_id = %s::uuid
AND event_type = 'repair'
ORDER BY event_at DESC, created_at DESC
LIMIT 1;
""",
(miner_id,),
)
latest = cur.fetchone()
if latest:
description_append = ""
if note:
description_append = "\n\nStatus update note: " + note
cur.execute(
"""
UPDATE miner_maintenance_events
SET
event_status = %s,
description = COALESCE(description, '') || %s,
updated_at = now()
WHERE id = %s::uuid
RETURNING id::text;
""",
(
event_status,
description_append,
latest["id"],
),
)
event = cur.fetchone()
else:
description = note or f"Repair status set to {event_status}."
cur.execute(
"""
INSERT INTO miner_maintenance_events (
id,
miner_id,
event_type,
event_status,
event_title,
description,
event_at,
component_type,
component_slot,
created_by_user_id
)
VALUES (
%s::uuid,
%s::uuid,
'repair',
%s,
'Repair status updated',
%s,
now(),
'repair',
'Repair Queue',
%s::uuid
)
RETURNING id::text;
""",
(
str(uuid4()),
miner_id,
event_status,
description,
minerops_repair_current_user_id(current_user),
),
)
event = cur.fetchone()
conn.commit()
return api_response(
data={
"miner_id": miner_id,
"event_id": event["id"] if event else None,
"event_status": event_status,
}
)
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
@app.post("/api/repairs/{miner_id}/state")
def minerops_repair_update_state_api(
miner_id: str,
request: MinerOpsRepairStateRequest,
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
ensure_maintenance_tables()
asset_state = str(request.asset_state or "").strip().lower()
note = str(request.note or "").strip()
allowed_states = {
"repair",
"waiting_placement",
"storage",
"decommissioned",
}
if asset_state not in allowed_states:
raise HTTPException(status_code=400, detail="Invalid asset state")
title_by_state = {
"repair": "Repair state updated",
"waiting_placement": "Repair completed - waiting placement",
"storage": "Moved from repair to storage",
"decommissioned": "Miner decommissioned",
}
description = title_by_state[asset_state]
if note:
description += ". " + note
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT id::text, current_mac
FROM miners
WHERE id = %s::uuid
LIMIT 1
FOR UPDATE;
""",
(miner_id,),
)
miner = cur.fetchone()
if not miner:
raise HTTPException(status_code=404, detail="Miner not found")
minerops_repair_reject_modular_mutation(cur, miner_id)
cur.execute(
"""
UPDATE location_positions
SET
miner_id = NULL,
expected_mac = NULL,
position_status = CASE
WHEN position_kind = 'miner_slot' THEN 'available'
ELSE position_status
END,
conflict_reason = NULL,
updated_at = now()
WHERE miner_id = %s::uuid;
""",
(miner_id,),
)
cur.execute(
"""
INSERT INTO miner_location_states (
miner_id,
asset_state,
location_position_id,
state_notes,
updated_by_user_id,
updated_at
)
VALUES (
%s::uuid,
%s,
NULL,
%s,
%s::uuid,
now()
)
ON CONFLICT (miner_id)
DO UPDATE SET
asset_state = EXCLUDED.asset_state,
location_position_id = NULL,
state_notes = CASE
WHEN EXCLUDED.state_notes IS NULL OR EXCLUDED.state_notes = ''
THEN miner_location_states.state_notes
ELSE EXCLUDED.state_notes
END,
updated_by_user_id = EXCLUDED.updated_by_user_id,
updated_at = now();
""",
(
miner_id,
asset_state,
note,
minerops_repair_current_user_id(current_user),
),
)
if asset_state in ("waiting_placement", "storage", "decommissioned"):
cur.execute(
"""
UPDATE miner_maintenance_events
SET
event_status = 'completed',
updated_at = now()
WHERE miner_id = %s::uuid
AND event_type = 'repair'
AND event_status IN (
'open',
'diagnosing',
'waiting_parts',
'in_progress',
'testing',
'needs_followup'
);
""",
(miner_id,),
)
if asset_state == "decommissioned":
cur.execute(
"""
UPDATE miners
SET status = 'offline', updated_at = now()
WHERE id = %s::uuid;
""",
(miner_id,),
)
cur.execute(
"""
INSERT INTO miner_maintenance_events (
id,
miner_id,
event_type,
event_status,
event_title,
description,
event_at,
component_type,
component_slot,
created_by_user_id
)
VALUES (
%s::uuid,
%s::uuid,
'repair',
'completed',
%s,
%s,
now(),
'repair',
'Repair Queue',
%s::uuid
)
RETURNING id::text;
""",
(
str(uuid4()),
miner_id,
title_by_state[asset_state],
description,
minerops_repair_current_user_id(current_user),
),
)
event = cur.fetchone()
conn.commit()
return api_response(
data={
"miner_id": miner_id,
"asset_state": asset_state,
"event_id": event["id"] if event else None,
}
)
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
MINEROPS_REPAIR_QUEUE_HTML = r"""
<!DOCTYPE html>
<html>
<head>
<title>MinerOps Repair Queue</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: Segoe UI, Arial, sans-serif;
background: #f4f7fb;
margin: 0;
padding: 22px;
color: #102a43;
}
.topbar {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
.topbar a {
color: #0b63ce;
font-weight: 700;
text-decoration: none;
}
h1 {
margin: 0;
font-size: 28px;
}
.muted {
color: #52606d;
font-size: 13px;
}
.card {
background: #ffffff;
border: 1px solid #d9e2ec;
border-radius: 12px;
padding: 14px;
margin-bottom: 14px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
.metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
}
.metric {
border: 1px solid #d9e2ec;
border-radius: 10px;
padding: 12px;
background: #f8fafc;
}
.metric .value {
font-size: 26px;
font-weight: 800;
}
.controls {
display: grid;
grid-template-columns: 1fr 190px 190px 120px;
gap: 10px;
align-items: end;
}
label {
display: block;
font-size: 12px;
font-weight: 700;
color: #334155;
margin-bottom: 5px;
}
input, select, textarea {
width: 100%;
box-sizing: border-box;
border: 1px solid #bcccdc;
border-radius: 8px;
padding: 9px;
font-size: 14px;
}
button {
border: 1px solid #829ab1;
border-radius: 8px;
background: #f8fafc;
padding: 8px 10px;
font-weight: 700;
cursor: pointer;
}
button:hover {
background: #e6f0ff;
}
button.primary {
background: #0f766e;
color: white;
border-color: #0f766e;
}
button.danger {
background: #fef2f2;
color: #991b1b;
border-color: #fecaca;
}
button.good {
background: #ecfdf5;
color: #166534;
border-color: #bbf7d0;
}
.status-box {
display: none;
margin-bottom: 14px;
padding: 10px;
border-radius: 8px;
font-size: 13px;
}
.status-ok {
display: block;
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #166534;
}
.status-bad {
display: block;
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 1280px;
font-size: 13px;
}
th, td {
border-bottom: 1px solid #d9e2ec;
padding: 8px;
vertical-align: top;
text-align: left;
}
th {
background: #f1f5f9;
position: sticky;
top: 0;
z-index: 1;
}
.mac {
font-weight: 800;
}
.pill {
display: inline-block;
border-radius: 999px;
padding: 2px 7px;
font-size: 11px;
font-weight: 800;
background: #e2e8f0;
color: #334155;
}
.pill.open,
.pill.diagnosing,
.pill.in_progress,
.pill.testing,
.pill.waiting_parts,
.pill.needs_followup {
background: #fff7ed;
color: #9a3412;
}
.pill.completed {
background: #dcfce7;
color: #166534;
}
.pill.failed,
.pill.cancelled {
background: #fee2e2;
color: #991b1b;
}
.row-actions {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.note-row {
display: flex;
gap: 6px;
min-width: 260px;
}
.note-row input {
min-width: 170px;
}
.small {
font-size: 12px;
color: #52606d;
}
@media (max-width: 900px) {
.controls {
grid-template-columns: 1fr;
}
body {
padding: 12px;
}
}
</style>
</head>
<body>
<div class="topbar">
<div>
<h1>Repair Queue</h1>
<div class="muted">Miners currently marked as Repair. Use this page to triage repairs and move completed units back toward placement.</div>
</div>
<div>
<a href="/dashboard">Dashboard</a>
&nbsp;|&nbsp;
<a href="/locations">Locations</a>
</div>
</div>
<div id="repairStatusBox" class="status-box"></div>
<div class="card">
<div class="metrics">
<div class="metric">
<div class="muted">Repair miners</div>
<div id="metricTotal" class="value">-</div>
</div>
<div class="metric">
<div class="muted">Open / active</div>
<div id="metricOpen" class="value">-</div>
</div>
<div class="metric">
<div class="muted">M60S</div>
<div id="metricM60" class="value">-</div>
</div>
<div class="metric">
<div class="muted">M50S++</div>
<div id="metricM50" class="value">-</div>
</div>
</div>
</div>
<div class="card">
<div class="controls">
<div>
<label for="repairSearch">Search</label>
<input id="repairSearch" type="search" placeholder="MAC, model, serial, PSU, error...">
</div>
<div>
<label for="repairStatusFilter">Status</label>
<select id="repairStatusFilter">
<option value="">All statuses</option>
<option value="open">Open</option>
<option value="diagnosing">Diagnosing</option>
<option value="waiting_parts">Waiting parts</option>
<option value="in_progress">In progress</option>
<option value="testing">Testing</option>
<option value="needs_followup">Needs follow-up</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div>
<label for="repairModelFilter">Model</label>
<select id="repairModelFilter">
<option value="">All models</option>
</select>
</div>
<div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<button type="button" id="repairRefreshBtn" class="primary">Refresh</button>
<button type="button" id="repairExportCsvBtn">Export CSV</button>
</div>
</div>
</div>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Miner</th>
<th>Model</th>
<th>Serials</th>
<th>Reported Problem</th>
<th>Repair Status</th>
<th>Repair Note</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="repairTableBody">
<tr><td colspan="7">Loading repairs...</td></tr>
</tbody>
</table>
</div>
</div>
<script>
(function () {
"use strict";
let repairItems = [];
function getStoredToken() {
const keys = [
"minerops_token",
"mineropsToken",
"authToken",
"access_token",
"token"
];
for (const storage of [window.localStorage, window.sessionStorage]) {
if (!storage) {
continue;
}
for (const key of keys) {
const value = storage.getItem(key);
if (value) {
return value;
}
}
}
return "";
}
function authHeaders(extra) {
const headers = Object.assign({}, extra || {});
const token = getStoredToken();
if (token && !headers.Authorization) {
headers.Authorization = "Bearer " + token;
}
return headers;
}
async function apiGet(url) {
const response = await fetch(url, {
method: "GET",
credentials: "same-origin",
headers: authHeaders({
"Accept": "application/json"
})
});
const payload = await response.json().catch(function () {
return null;
});
if (!response.ok || (payload && payload.error)) {
throw new Error((payload && payload.error) || "Request failed");
}
return payload ? payload.data : null;
}
async function apiPost(url, body) {
const response = await fetch(url, {
method: "POST",
credentials: "same-origin",
headers: authHeaders({
"Accept": "application/json",
"Content-Type": "application/json"
}),
body: JSON.stringify(body)
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch (error) {
payload = null;
}
if (!response.ok || (payload && payload.error)) {
throw new Error((payload && payload.error) || text || "Request failed");
}
return payload ? payload.data : null;
}
function escapeHtml(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function showStatus(type, message) {
const box = document.getElementById("repairStatusBox");
box.className = "status-box " + (type === "error" ? "status-bad" : "status-ok");
box.textContent = message;
}
function repairStatusLabel(status) {
const labels = {
"open": "Open",
"diagnosing": "Diagnosing",
"waiting_parts": "Waiting Parts",
"in_progress": "In Progress",
"testing": "Testing",
"ready_to_shelf": "Ready to Shelf",
"needs_followup": "Needs Follow-up",
"completed": "Completed",
"failed": "Failed",
"cancelled": "Cancelled",
"unknown": "Unknown"
};
return labels[status] || String(status || "Unknown")
.replace(/_/g, " ")
.replace(/\b\w/g, function (letter) {
return letter.toUpperCase();
});
}
function repairPartLabel(part) {
const labels = {
"": "No part selected",
"fan": "Fan",
"psu": "PSU",
"control_board": "Control Board",
"hashboard": "Hashboard",
"cable": "Cable",
"other": "Other"
};
return labels[part] || String(part || "")
.replace(/_/g, " ")
.replace(/\b\w/g, function (letter) {
return letter.toUpperCase();
});
}
function statusPill(status) {
const value = status || "unknown";
return '<span class="pill ' + escapeHtml(value) + '">' + escapeHtml(value) + '</span>';
}
function buildModelFilter() {
const select = document.getElementById("repairModelFilter");
const current = select.value;
const models = Array.from(new Set(repairItems.map(item => item.model || "Unknown"))).sort();
select.innerHTML = '<option value="">All models</option>' + models.map(function (model) {
return '<option value="' + escapeHtml(model) + '">' + escapeHtml(model) + '</option>';
}).join("");
if (models.indexOf(current) !== -1) {
select.value = current;
}
}
function updateMetrics(summary) {
const total = summary && summary.total ? summary.total : repairItems.length;
const statusCounts = summary && summary.status_counts ? summary.status_counts : {};
const modelCounts = summary && summary.model_counts ? summary.model_counts : {};
const active = [
"open",
"diagnosing",
"waiting_parts",
"in_progress",
"testing",
"ready_to_shelf",
"needs_followup"
].reduce(function (sum, key) {
return sum + (statusCounts[key] || 0);
}, 0);
document.getElementById("metricTotal").textContent = total;
document.getElementById("metricOpen").textContent = active;
document.getElementById("metricM60").textContent = modelCounts["M60S"] || 0;
document.getElementById("metricM50").textContent = modelCounts["M50S++"] || 0;
}
function filteredItems() {
const search = String(document.getElementById("repairSearch").value || "").toLowerCase().trim();
const status = document.getElementById("repairStatusFilter").value;
const model = document.getElementById("repairModelFilter").value;
return repairItems.filter(function (item) {
if (status && (item.latest_event_status || "unknown") !== status) {
return false;
}
if (model && (item.model || "Unknown") !== model) {
return false;
}
if (!search) {
return true;
}
const haystack = [
item.current_mac,
item.current_ip,
item.model,
item.miner_sn,
item.power_sn,
item.miner_status,
item.reported_error,
item.reported_hashrate_ths,
item.reported_voltage,
item.state_notes,
item.latest_event_description,
item.location_note,
item.import_source
].join(" ").toLowerCase();
return haystack.indexOf(search) !== -1;
});
}
function renderTable() {
const tbody = document.getElementById("repairTableBody");
const rows = filteredItems();
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="7">No repair miners match the current filter.</td></tr>';
return;
}
tbody.innerHTML = rows.map(function (item) {
const problemParts = [];
if (item.reported_error) {
problemParts.push('<div><strong>Error:</strong> ' + escapeHtml(item.reported_error) + '</div>');
}
if (item.reported_hashrate_ths) {
problemParts.push('<div><strong>TH/s:</strong> ' + escapeHtml(item.reported_hashrate_ths) + '</div>');
}
if (item.reported_voltage) {
problemParts.push('<div><strong>Voltage:</strong> ' + escapeHtml(item.reported_voltage) + '</div>');
}
if (!problemParts.length) {
problemParts.push('<span class="small">No reported problem</span>');
}
const detailUrl = "/miners/" + encodeURIComponent(item.miner_id) + "/detail";
return `
<tr data-miner-id="${escapeHtml(item.miner_id)}">
<td>
<div class="mac">${escapeHtml(item.current_mac || "-")}</div>
<div class="small">IP: ${escapeHtml(item.current_ip || "-")}</div>
<div class="small">Miner status: ${escapeHtml(item.miner_status || "-")}</div>
</td>
<td>${escapeHtml(item.model || "-")}</td>
<td>
<div><strong>Miner:</strong> ${escapeHtml(item.miner_sn || "-")}</div>
<div><strong>PSU:</strong> ${escapeHtml(item.power_sn || "-")}</div>
</td>
<td>
${problemParts.join("")}
<div class="small" style="margin-top:4px;">${escapeHtml(item.location_note || "Repair")}</div>
</td>
<td>
<div>${statusPill(item.latest_event_status || "unknown")}</div>
<div class="small">Events: ${escapeHtml(item.repair_event_count || 0)}</div>
<select class="repair-status-select" style="margin-top:6px;">
${["open","diagnosing","waiting_parts","in_progress","testing","ready_to_shelf","needs_followup","completed","failed","cancelled"].map(function (status) {
return '<option value="' + status + '"' + ((item.latest_event_status === status) ? " selected" : "") + '>' + repairStatusLabel(status) + '</option>';
}).join("")}
</select>
</td>
<td>
<div class="note-row">
<select class="repair-part-select" title="Parts used">
<option value="">No part</option>
<option value="fan">Fan</option>
<option value="psu">PSU</option>
<option value="control_board">Control Board</option>
<option value="hashboard">Hashboard</option>
<option value="cable">Cable</option>
<option value="other">Other</option>
</select>
<input class="repair-part-qty-input" type="number" min="1" step="1" value="1" title="Quantity" style="width:64px;">
<input class="repair-note-input" type="text" placeholder="Add repair note">
<button type="button" class="repair-add-note-btn">Add</button>
</div>
<div class="small" style="margin-top:5px;">${escapeHtml(item.state_notes || "")}</div>
</td>
<td>
<div class="row-actions">
<button type="button" class="repair-save-status-btn">Save status</button>
<button type="button" class="repair-mark-ready-btn good">Mark repaired</button>
<button type="button" class="repair-decommission-btn danger">Decommission</button>
<a href="${escapeHtml(detailUrl)}"><button type="button">Detail</button></a>
<a href="/locations?moveMiner=${encodeURIComponent(item.miner_id)}"><button type="button">Move to shelf</button></a>
</div>
</td>
</tr>
`;
}).join("");
tbody.querySelectorAll(".repair-add-note-btn").forEach(function (button) {
button.addEventListener("click", addNote);
});
tbody.querySelectorAll(".repair-save-status-btn").forEach(function (button) {
button.addEventListener("click", saveStatus);
});
tbody.querySelectorAll(".repair-mark-ready-btn").forEach(function (button) {
button.addEventListener("click", markReady);
});
tbody.querySelectorAll(".repair-decommission-btn").forEach(function (button) {
button.addEventListener("click", decommission);
});
}
async function loadRepairs() {
try {
const data = await apiGet("/api/repairs/queue");
repairItems = (data.items || []).filter(function (item) {
return !item.source || item.source === "legacy";
});
const legacySummary = {
total: repairItems.length,
status_counts: {},
model_counts: {}
};
for (const item of repairItems) {
const status = item.latest_event_status || item.asset_state || "unknown";
const model = item.model || "unknown";
legacySummary.status_counts[status] =
(legacySummary.status_counts[status] || 0) + 1;
legacySummary.model_counts[model] =
(legacySummary.model_counts[model] || 0) + 1;
}
updateMetrics(legacySummary);
buildModelFilter();
renderTable();
showStatus("ok", "Repair Queue loaded. " + repairItems.length + " miners in repair.");
} catch (error) {
showStatus("error", error.message || String(error));
}
}
function getRow(button) {
return button.closest("tr");
}
async function addNote(event) {
const row = getRow(event.target);
const minerId = row.getAttribute("data-miner-id");
const noteInput = row.querySelector(".repair-note-input");
const partSelect = row.querySelector(".repair-part-select");
const qtyInput = row.querySelector(".repair-part-qty-input");
const note = noteInput.value.trim();
const part = partSelect ? partSelect.value : "";
const qtyRaw = qtyInput ? qtyInput.value.trim() : "";
const qty = qtyRaw && Number(qtyRaw) > 0 ? Number(qtyRaw) : 1;
if (!note && !part) {
showStatus("error", "Enter a repair note or choose a part used first.");
return;
}
let finalNote = note || "Part used recorded.";
if (part) {
finalNote += "\n\nParts used: " + repairPartLabel(part) + " (Qty: " + qty + ")";
}
try {
await apiPost("/api/repairs/" + encodeURIComponent(minerId) + "/note", {
note: finalNote,
event_status: "in_progress"
});
noteInput.value = "";
if (partSelect) {
partSelect.value = "";
}
if (qtyInput) {
qtyInput.value = "1";
}
showStatus("ok", "Repair note added.");
await loadRepairs();
} catch (error) {
showStatus("error", error.message || String(error));
}
}
async function saveStatus(event) {
const row = getRow(event.target);
const minerId = row.getAttribute("data-miner-id");
const status = row.querySelector(".repair-status-select").value;
const noteInput = row.querySelector(".repair-note-input");
const note = noteInput.value.trim();
try {
await apiPost("/api/repairs/" + encodeURIComponent(minerId) + "/status", {
event_status: status,
note: note
});
if (noteInput) {
noteInput.value = "";
}
showStatus("ok", "Repair status updated.");
await loadRepairs();
} catch (error) {
showStatus("error", error.message || String(error));
}
}
async function markReady(event) {
const row = getRow(event.target);
const minerId = row.getAttribute("data-miner-id");
const noteInput = row.querySelector(".repair-note-input");
const note = noteInput.value.trim() || "Repair marked complete. Miner is waiting for placement.";
if (!confirm("Mark this miner repaired and move it to Waiting Placement?")) {
return;
}
try {
await apiPost("/api/repairs/" + encodeURIComponent(minerId) + "/state", {
asset_state: "waiting_placement",
note: note
});
showStatus("ok", "Miner marked repaired and moved to Waiting Placement. Use Locations to place it on a shelf.");
await loadRepairs();
} catch (error) {
showStatus("error", error.message || String(error));
}
}
async function decommission(event) {
const row = getRow(event.target);
const minerId = row.getAttribute("data-miner-id");
const noteInput = row.querySelector(".repair-note-input");
const note = noteInput.value.trim() || "Miner decommissioned from Repair Queue.";
const typed = window.prompt("Type DECOMMISSION to confirm moving this miner out of Repair and into Decommissioned.", "");
if (typed !== "DECOMMISSION") {
showStatus("error", "Decommission cancelled. You must type DECOMMISSION exactly.");
return;
}
try {
await apiPost("/api/repairs/" + encodeURIComponent(minerId) + "/state", {
asset_state: "decommissioned",
note: note
});
showStatus("ok", "Miner moved to Decommissioned.");
await loadRepairs();
} catch (error) {
showStatus("error", error.message || String(error));
}
}
function csvEscape(value) {
const s = String(value == null ? "" : value);
return '"' + s.replace(/"/g, '""') + '"';
}
function exportRepairCsv() {
const rows = filteredItems();
const headers = [
"MAC",
"IP",
"Model",
"Miner SN",
"PSU SN",
"Miner Status",
"Repair Status",
"Reported Error",
"Reported TH/s",
"Reported Voltage",
"Location Note",
"State Notes",
"Latest Repair Description",
"Latest Repair Date"
];
const lines = [headers.map(csvEscape).join(",")];
for (const item of rows) {
lines.push([
item.current_mac,
item.current_ip,
item.model,
item.miner_sn,
item.power_sn,
item.miner_status,
item.latest_event_status,
item.reported_error,
item.reported_hashrate_ths,
item.reported_voltage,
item.location_note,
item.state_notes,
item.latest_event_description,
item.latest_event_at
].map(csvEscape).join(","));
}
const csv = lines.join("\\r\\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const link = document.createElement("a");
link.href = url;
link.download = "minerops-repair-queue-" + timestamp + ".csv";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
showStatus("ok", "Exported " + rows.length + " repair queue row(s) to CSV.");
}
document.getElementById("repairExportCsvBtn").addEventListener("click", exportRepairCsv);
document.getElementById("repairRefreshBtn").addEventListener("click", function () {
loadRepairs();
loadPartsSummary();
});
document.getElementById("repairSearch").addEventListener("input", renderTable);
document.getElementById("repairStatusFilter").addEventListener("change", renderTable);
document.getElementById("repairModelFilter").addEventListener("change", renderTable);
async function loadPartsSummary() {
let card = document.getElementById("repairPartsSummaryCard");
if (!card) {
card = document.createElement("section");
card.id = "repairPartsSummaryCard";
card.className = "card";
card.innerHTML = '<h2>Parts Summary</h2><div id="repairPartsSummaryBody" class="small">Loading parts summary...</div>';
const queueHeading = Array.from(document.querySelectorAll("h2")).find(function (heading) {
return heading.textContent.trim() === "Repair Queue";
});
const queueCard = queueHeading ? queueHeading.closest(".card") : null;
if (queueCard && queueCard.parentNode) {
queueCard.parentNode.insertBefore(card, queueCard);
} else {
document.body.insertBefore(card, document.body.firstChild);
}
}
const body = document.getElementById("repairPartsSummaryBody");
try {
const data = await apiGet("/api/repairs/parts-summary");
const totals = data.totals || [];
const recent = data.recent || [];
if (!totals.length) {
body.innerHTML = "No parts have been recorded yet.";
return;
}
const totalHtml = totals.map(function (row) {
return '<span class="pill">' + escapeHtml(row.part) + ': ' + escapeHtml(row.qty) + '</span>';
}).join(" ");
const recentHtml = recent.slice(0, 8).map(function (row) {
return '<div style="margin-top:4px;">' +
escapeHtml(row.part) + ' x ' + escapeHtml(row.qty) +
' | ' + escapeHtml(row.current_mac || "-") +
' | ' + escapeHtml(row.model || "-") +
'</div>';
}).join("");
body.innerHTML =
'<div style="margin-bottom:8px;">' + totalHtml + '</div>' +
'<div><strong>Recent parts used</strong></div>' +
recentHtml;
} catch (error) {
body.innerHTML = '<span style="color:#991b1b;">Parts summary failed to load: ' + escapeHtml(error.message || error) + '</span>';
}
}
loadRepairs();
loadPartsSummary();
})();
</script>
</body>
</html>
"""
@app.get("/repairs", response_class=MinerOpsRepairHTMLResponse)
def minerops_repair_queue_page():
return MINEROPS_REPAIR_QUEUE_HTML
# MINEROPS_REPAIR_POLISH_V1
from fastapi.responses import Response as MinerOpsRepairPolishResponse
MINEROPS_REPAIR_QUICK_NAV_JS = r"""
(function () {
"use strict";
if (document.getElementById("mineropsQuickNav")) {
return;
}
const path = window.location.pathname;
const links = [
{ href: "/dashboard", label: "Dashboard" },
{ href: "/repairs", label: "Repairs" },
{ href: "/locations", label: "Locations" },
{ href: "/system-health", label: "System Health" }
];
const style = document.createElement("style");
style.textContent = `
#mineropsQuickNav {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin: 0 0 14px 0;
padding: 10px;
background: #ffffff;
border: 1px solid #d9e2ec;
border-radius: 10px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
font-family: Segoe UI, Arial, sans-serif;
}
#mineropsQuickNav .nav-label {
font-size: 13px;
color: #52606d;
font-weight: 700;
margin-right: 2px;
}
#mineropsQuickNav a {
display: inline-block;
text-decoration: none;
color: #0b63ce;
border: 1px solid #bcccdc;
border-radius: 999px;
padding: 6px 10px;
font-size: 13px;
font-weight: 700;
background: #f8fafc;
}
#mineropsQuickNav a.active {
background: #0f766e;
border-color: #0f766e;
color: #ffffff;
}
#mineropsQuickNav a:hover {
background: #e6f0ff;
}
#mineropsQuickNav a.active:hover {
background: #0f766e;
}
`;
document.head.appendChild(style);
const nav = document.createElement("div");
nav.id = "mineropsQuickNav";
nav.innerHTML = '<span class="nav-label">MinerOps:</span>' + links.map(function (link) {
const active = path === link.href ? " active" : "";
return '<a class="' + active + '" href="' + link.href + '">' + link.label + '</a>';
}).join("");
if (document.body.firstChild) {
document.body.insertBefore(nav, document.body.firstChild);
} else {
document.body.appendChild(nav);
}
})();
"""
MINEROPS_REPAIR_RETURN_MODE_JS = r"""
(function () {
"use strict";
if (window.location.pathname !== "/locations") {
return;
}
const params = new URLSearchParams(window.location.search);
const minerId = params.get("moveMiner");
if (!minerId) {
return;
}
function escapeHtml(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function insertBanner() {
if (document.getElementById("mineropsRepairReturnBanner")) {
return;
}
const style = document.createElement("style");
style.textContent = `
#mineropsRepairReturnBanner {
margin: 0 0 14px 0;
padding: 12px;
background: #ecfdf5;
border: 1px solid #99f6e4;
border-radius: 10px;
color: #134e4a;
font-family: Segoe UI, Arial, sans-serif;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
#mineropsRepairReturnBanner strong {
display: block;
margin-bottom: 4px;
}
#mineropsRepairReturnBanner code {
background: rgba(15, 118, 110, 0.12);
border-radius: 5px;
padding: 1px 4px;
}
#mineropsRepairReturnBanner a {
color: #0b63ce;
font-weight: 700;
text-decoration: none;
}
`;
document.head.appendChild(style);
const banner = document.createElement("div");
banner.id = "mineropsRepairReturnBanner";
banner.innerHTML = `
<strong>Return to Shelf mode is on</strong>
Click an empty shelf position. The selected repair miner will be preselected in the move dialog.
<div style="margin-top:4px;font-size:13px;">Miner ID: <code>${escapeHtml(minerId)}</code> &nbsp; <a href="/locations">clear mode</a></div>
`;
const quickNav = document.getElementById("mineropsQuickNav");
if (quickNav && quickNav.parentNode) {
quickNav.parentNode.insertBefore(banner, quickNav.nextSibling);
} else if (document.body.firstChild) {
document.body.insertBefore(banner, document.body.firstChild);
} else {
document.body.appendChild(banner);
}
}
function preselectMiner() {
const select = document.getElementById("mineropsMapMoveSelect");
if (!select) {
return false;
}
select.value = minerId;
const note = document.getElementById("mineropsMapMoveNote");
if (note && !note.value) {
note.value = "Returned from Repair Queue to shelf.";
}
select.dispatchEvent(new Event("change", { bubbles: true }));
const status = document.getElementById("mineropsMapMoveStatus");
if (status) {
status.style.display = "block";
status.style.background = "#f0fdf4";
status.style.border = "1px solid #bbf7d0";
status.style.color = "#166534";
if (select.value === minerId) {
status.textContent = "Repair miner preselected. Confirm the shelf move when ready.";
} else {
status.textContent = "Could not preselect the repair miner. Please search/select it manually.";
}
}
return select.value === minerId;
}
function tryPreselectSeveralTimes() {
[60, 160, 350, 700, 1200].forEach(function (delay) {
window.setTimeout(preselectMiner, delay);
});
}
document.addEventListener("click", function (event) {
const tile = event.target.closest(".tile");
if (!tile) {
return;
}
tryPreselectSeveralTimes();
}, true);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", insertBanner);
} else {
insertBanner();
}
})();
"""
@app.get("/assets/minerops-repair-quick-nav.js")
def minerops_repair_quick_nav_asset():
return MinerOpsRepairPolishResponse(
content=MINEROPS_REPAIR_QUICK_NAV_JS,
media_type="application/javascript",
)
@app.get("/assets/minerops-repair-return-mode.js")
def minerops_repair_return_mode_asset():
return MinerOpsRepairPolishResponse(
content=MINEROPS_REPAIR_RETURN_MODE_JS,
media_type="application/javascript",
)
@app.middleware("http")
async def minerops_inject_repair_polish_assets(request, call_next):
response = await call_next(request)
try:
path = request.url.path or ""
if request.scope.get("endpoint") is internal_alpha_dashboard_page:
return response
if path not in ("/dashboard", "/locations", "/repairs"):
return response
content_type = response.headers.get("content-type", "")
if "text/html" not in content_type.lower():
return response
body = b""
async for chunk in response.body_iterator:
body += chunk
html = body.decode("utf-8", errors="ignore")
scripts = [
'<script src="/assets/minerops-repair-quick-nav.js"></script>'
]
if path == "/locations":
scripts.append('<script src="/assets/minerops-repair-return-mode.js"></script>')
inject = "".join(s for s in scripts if s not in html)
if inject:
if "</body>" in html:
html = html.replace("</body>", inject + "</body>")
else:
html += inject
headers = dict(response.headers)
headers.pop("content-length", None)
return MinerOpsRepairPolishResponse(
content=html,
status_code=response.status_code,
headers=headers,
media_type="text/html",
)
except Exception:
return response
# MINEROPS_MAP_CLICK_MOVE_DATASET_FIX_V1
# MINEROPS_DASHBOARD_REPAIR_SUMMARY_V1
from fastapi.responses import Response as MinerOpsDashboardRepairResponse
MINEROPS_DASHBOARD_REPAIR_SUMMARY_JS = r"""
(function () {
"use strict";
if (window.location.pathname !== "/dashboard") {
return;
}
if (document.getElementById("mineropsDashboardRepairSummary")) {
return;
}
function getStoredToken() {
const keys = [
"minerops_token",
"mineropsToken",
"authToken",
"access_token",
"token"
];
for (const storage of [window.localStorage, window.sessionStorage]) {
if (!storage) {
continue;
}
for (const key of keys) {
const value = storage.getItem(key);
if (value) {
return value;
}
}
}
return "";
}
function authHeaders(extra) {
const headers = Object.assign({}, extra || {});
const token = getStoredToken();
if (token && !headers.Authorization) {
headers.Authorization = "Bearer " + token;
}
return headers;
}
async function apiGet(url) {
const response = await fetch(url, {
method: "GET",
credentials: "same-origin",
headers: authHeaders({
"Accept": "application/json"
})
});
const payload = await response.json().catch(function () {
return null;
});
if (!response.ok || (payload && payload.error)) {
throw new Error((payload && payload.error) || "Request failed");
}
return payload ? payload.data : null;
}
function escapeHtml(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function createShell() {
const style = document.createElement("style");
style.textContent = `
#mineropsDashboardRepairSummary {
margin: 0 0 16px 0;
padding: 14px;
background: #ffffff;
border: 1px solid #d9e2ec;
border-radius: 12px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
font-family: Segoe UI, Arial, sans-serif;
color: #102a43;
}
#mineropsDashboardRepairSummary .repair-header {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 10px;
}
#mineropsDashboardRepairSummary h2 {
margin: 0;
font-size: 20px;
}
#mineropsDashboardRepairSummary .repair-muted {
color: #52606d;
font-size: 13px;
}
#mineropsDashboardRepairSummary .repair-metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 8px;
}
#mineropsDashboardRepairSummary .repair-metric {
border: 1px solid #d9e2ec;
border-radius: 10px;
padding: 10px;
background: #f8fafc;
}
#mineropsDashboardRepairSummary .repair-metric .value {
font-size: 24px;
font-weight: 800;
margin-top: 2px;
}
#mineropsDashboardRepairSummary .repair-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
#mineropsDashboardRepairSummary a,
#mineropsDashboardRepairSummary button {
border: 1px solid #829ab1;
border-radius: 8px;
background: #f8fafc;
padding: 7px 10px;
font-weight: 700;
color: #0b63ce;
text-decoration: none;
cursor: pointer;
font-size: 13px;
}
#mineropsDashboardRepairSummary a.primary {
background: #0f766e;
border-color: #0f766e;
color: #ffffff;
}
#mineropsDashboardRepairSummary .repair-list {
margin-top: 10px;
font-size: 13px;
color: #334155;
}
#mineropsDashboardRepairSummary .repair-list strong {
color: #102a43;
}
`;
document.head.appendChild(style);
const card = document.createElement("section");
card.id = "mineropsDashboardRepairSummary";
card.innerHTML = `
<div class="repair-header">
<div>
<h2>Repair Summary</h2>
<div class="repair-muted">Loading repair queue...</div>
</div>
<div class="repair-actions">
<a class="primary" href="/repairs">Open Repair Queue</a>
<button type="button" id="mineropsRepairSummaryRefresh">Refresh</button>
</div>
</div>
<div class="repair-metrics">
<div class="repair-metric">
<div class="repair-muted">In Repair</div>
<div id="dashRepairTotal" class="value">-</div>
</div>
<div class="repair-metric">
<div class="repair-muted">Active/Open</div>
<div id="dashRepairActive" class="value">-</div>
</div>
<div class="repair-metric">
<div class="repair-muted">Waiting Parts</div>
<div id="dashRepairWaitingParts" class="value">-</div>
</div>
<div class="repair-metric">
<div class="repair-muted">Testing</div>
<div id="dashRepairTesting" class="value">-</div>
</div>
</div>
<div id="dashRepairDetails" class="repair-list"></div>
`;
const quickNav = document.getElementById("mineropsQuickNav");
if (quickNav && quickNav.parentNode) {
quickNav.parentNode.insertBefore(card, quickNav.nextSibling);
} else {
const target = document.querySelector("main, .container, .content, body") || document.body;
if (target.firstChild) {
target.insertBefore(card, target.firstChild);
} else {
target.appendChild(card);
}
}
const refresh = document.getElementById("mineropsRepairSummaryRefresh");
if (refresh) {
refresh.addEventListener("click", loadRepairSummary);
}
}
function sumStatuses(counts, statuses) {
return statuses.reduce(function (sum, status) {
return sum + Number(counts[status] || 0);
}, 0);
}
function topEntries(counts, limit) {
return Object.entries(counts || {})
.sort(function (a, b) {
return Number(b[1]) - Number(a[1]);
})
.slice(0, limit);
}
async function loadRepairSummary() {
createShell();
const detail = document.getElementById("dashRepairDetails");
try {
const data = await apiGet("/api/repairs/queue");
const summary = data.summary || {};
const statusCounts = summary.status_counts || {};
const modelCounts = summary.model_counts || {};
const errorCounts = summary.error_counts || {};
const total = Number(summary.total || 0);
const active = sumStatuses(statusCounts, [
"open",
"diagnosing",
"waiting_parts",
"in_progress",
"testing",
"needs_followup"
]);
document.getElementById("dashRepairTotal").textContent = total;
document.getElementById("dashRepairActive").textContent = active;
document.getElementById("dashRepairWaitingParts").textContent = statusCounts.waiting_parts || 0;
document.getElementById("dashRepairTesting").textContent = statusCounts.testing || 0;
const models = topEntries(modelCounts, 4).map(function (entry) {
return escapeHtml(entry[0]) + ": <strong>" + escapeHtml(entry[1]) + "</strong>";
}).join(" &nbsp; ");
const errors = topEntries(errorCounts, 4).map(function (entry) {
return escapeHtml(entry[0]) + ": <strong>" + escapeHtml(entry[1]) + "</strong>";
}).join(" &nbsp; ");
detail.innerHTML = `
<div><strong>Models:</strong> ${models || "None"}</div>
<div style="margin-top:4px;"><strong>Top reported problems:</strong> ${errors || "None"}</div>
`;
} catch (error) {
if (detail) {
detail.innerHTML = '<span style="color:#991b1b;">Repair Summary failed to load: ' + escapeHtml(error.message || error) + '</span>';
}
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", loadRepairSummary);
} else {
loadRepairSummary();
}
})();
"""
@app.get("/assets/minerops-dashboard-repair-summary.js")
def minerops_dashboard_repair_summary_asset():
return MinerOpsDashboardRepairResponse(
content=MINEROPS_DASHBOARD_REPAIR_SUMMARY_JS,
media_type="application/javascript",
)
@app.middleware("http")
async def minerops_inject_dashboard_repair_summary(request, call_next):
response = await call_next(request)
try:
if request.scope.get("endpoint") is internal_alpha_dashboard_page:
return response
path = request.url.path or ""
if path != "/dashboard":
return response
content_type = response.headers.get("content-type", "")
if "text/html" not in content_type.lower():
return response
body = b""
async for chunk in response.body_iterator:
body += chunk
html = body.decode("utf-8", errors="ignore")
script_tag = '<script src="/assets/minerops-dashboard-repair-summary.js"></script>'
if script_tag not in html:
if "</body>" in html:
html = html.replace("</body>", script_tag + "</body>")
else:
html += script_tag
headers = dict(response.headers)
headers.pop("content-length", None)
return MinerOpsDashboardRepairResponse(
content=html,
status_code=response.status_code,
headers=headers,
media_type="text/html",
)
except Exception:
return response
# MINEROPS_REPAIR_HISTORY_MODAL_V1
from fastapi.responses import Response as MinerOpsRepairHistoryResponse
MINEROPS_REPAIR_HISTORY_JS = r"""
(function () {
"use strict";
if (window.location.pathname !== "/repairs") {
return;
}
let queueCache = null;
let queueCacheAt = 0;
function getStoredToken() {
const keys = [
"minerops_token",
"mineropsToken",
"authToken",
"access_token",
"token"
];
for (const storage of [window.localStorage, window.sessionStorage]) {
if (!storage) {
continue;
}
for (const key of keys) {
const value = storage.getItem(key);
if (value) {
return value;
}
}
}
return "";
}
function authHeaders(extra) {
const headers = Object.assign({}, extra || {});
const token = getStoredToken();
if (token && !headers.Authorization) {
headers.Authorization = "Bearer " + token;
}
return headers;
}
async function apiGet(url) {
const response = await fetch(url, {
method: "GET",
credentials: "same-origin",
headers: authHeaders({
"Accept": "application/json"
})
});
const payload = await response.json().catch(function () {
return null;
});
if (!response.ok || (payload && payload.error)) {
throw new Error((payload && payload.error) || "Request failed");
}
return payload ? payload.data : null;
}
async function apiPatch(url, body) {
const response = await fetch(url, {
method: "PATCH",
credentials: "same-origin",
headers: authHeaders({
"Accept": "application/json",
"Content-Type": "application/json"
}),
body: JSON.stringify(body || {})
});
const payload = await response.json().catch(function () {
return null;
});
if (!response.ok || (payload && payload.error)) {
throw new Error((payload && payload.error) || "Request failed");
}
return payload ? payload.data : null;
}
async function apiDelete(url) {
const response = await fetch(url, {
method: "DELETE",
credentials: "same-origin",
headers: authHeaders({
"Accept": "application/json"
})
});
const payload = await response.json().catch(function () {
return null;
});
if (!response.ok || (payload && payload.error)) {
throw new Error((payload && payload.error) || "Request failed");
}
return payload ? payload.data : null;
}
function escapeHtml(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function formatDate(value) {
if (!value) {
return "-";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
}
function statusPill(status) {
const value = status || "unknown";
const safe = escapeHtml(value);
return '<span class="minerops-history-pill minerops-history-pill-' + safe.replace(/[^a-zA-Z0-9_-]/g, "") + '">' + safe + '</span>';
}
function createModal() {
if (document.getElementById("mineropsRepairHistoryModal")) {
return;
}
const style = document.createElement("style");
style.textContent = `
#mineropsRepairHistoryModal {
position: fixed;
inset: 0;
z-index: 999999;
display: none;
align-items: center;
justify-content: center;
padding: 18px;
background: rgba(15, 23, 42, 0.48);
font-family: Segoe UI, Arial, sans-serif;
}
#mineropsRepairHistoryModal .history-panel {
width: min(980px, 100%);
max-height: 88vh;
overflow: auto;
background: #ffffff;
border: 1px solid #d9e2ec;
border-radius: 14px;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.32);
color: #102a43;
}
#mineropsRepairHistoryModal .history-head {
position: sticky;
top: 0;
z-index: 2;
background: #ffffff;
border-bottom: 1px solid #d9e2ec;
padding: 14px;
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
#mineropsRepairHistoryModal h2 {
margin: 0;
font-size: 22px;
}
#mineropsRepairHistoryModal .muted {
color: #52606d;
font-size: 13px;
}
#mineropsRepairHistoryModal .history-body {
padding: 14px;
}
#mineropsRepairHistoryModal .history-metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 10px;
margin-bottom: 14px;
}
#mineropsRepairHistoryModal .history-card {
border: 1px solid #d9e2ec;
border-radius: 10px;
padding: 10px;
background: #f8fafc;
}
#mineropsRepairHistoryModal .history-card strong {
display: block;
margin-top: 2px;
overflow-wrap: anywhere;
}
#mineropsRepairHistoryModal .history-event {
border: 1px solid #d9e2ec;
border-radius: 10px;
padding: 10px;
margin-bottom: 10px;
background: #ffffff;
}
#mineropsRepairHistoryModal .history-event-title {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 4px;
}
#mineropsRepairHistoryModal .history-event-title strong {
font-size: 15px;
}
#mineropsRepairHistoryModal .history-description {
white-space: pre-wrap;
margin-top: 6px;
font-size: 13px;
line-height: 1.35;
}
#mineropsRepairHistoryModal button {
border: 1px solid #829ab1;
border-radius: 8px;
background: #f8fafc;
padding: 8px 10px;
font-weight: 700;
cursor: pointer;
}
#mineropsRepairHistoryModal a {
color: #0b63ce;
font-weight: 700;
text-decoration: none;
}
.minerops-history-pill {
display: inline-block;
border-radius: 999px;
padding: 2px 7px;
font-size: 11px;
font-weight: 800;
background: #e2e8f0;
color: #334155;
}
.minerops-history-pill-open,
.minerops-history-pill-diagnosing,
.minerops-history-pill-waiting_parts,
.minerops-history-pill-in_progress,
.minerops-history-pill-testing,
.minerops-history-pill-needs_followup {
background: #fff7ed;
color: #9a3412;
}
.minerops-history-pill-completed {
background: #dcfce7;
color: #166534;
}
.minerops-history-pill-failed,
.minerops-history-pill-cancelled {
background: #fee2e2;
color: #991b1b;
}
`;
document.head.appendChild(style);
const modal = document.createElement("div");
modal.id = "mineropsRepairHistoryModal";
modal.innerHTML = `
<div class="history-panel" role="dialog" aria-modal="true" aria-labelledby="mineropsRepairHistoryTitle">
<div class="history-head">
<div>
<h2 id="mineropsRepairHistoryTitle">Repair History</h2>
<div id="mineropsRepairHistorySubtitle" class="muted">Loading...</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<a id="mineropsRepairHistoryDetailLink" href="#">Open Miner Detail</a>
<button type="button" id="mineropsRepairHistoryClose">Close</button>
</div>
</div>
<div id="mineropsRepairHistoryBody" class="history-body">
Loading repair history...
</div>
</div>
`;
document.body.appendChild(modal);
document.getElementById("mineropsRepairHistoryClose").addEventListener("click", closeModal);
modal.addEventListener("click", function (event) {
if (event.target === modal) {
closeModal();
}
});
document.addEventListener("keydown", function (event) {
if (event.key === "Escape") {
closeModal();
}
});
modal.addEventListener("click", async function (event) {
const editButton = event.target.closest(".repair-history-edit-btn");
const deleteButton = event.target.closest(".repair-history-delete-btn");
if (!editButton && !deleteButton) {
return;
}
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
const button = editButton || deleteButton;
const eventId = button.getAttribute("data-event-id") || "";
const minerId = window.mineropsRepairHistoryCurrentMinerId || "";
const eventMap = window.mineropsRepairHistoryEventById || {};
const historyEvent = eventMap[eventId] || {};
if (!eventId || !minerId) {
alert("This history event is missing an event ID or miner ID. Refresh the page and try again.");
return;
}
if (editButton) {
const currentDescription = historyEvent.description || "";
const updatedDescription = prompt("Edit repair history note:", currentDescription);
if (updatedDescription === null) {
return;
}
try {
button.disabled = true;
button.textContent = "Saving...";
const response = await fetch(
"/api/miners/" + encodeURIComponent(minerId) + "/maintenance-events/" + encodeURIComponent(eventId),
{
method: "PATCH",
credentials: "same-origin",
headers: authHeaders({
"Accept": "application/json",
"Content-Type": "application/json"
}),
body: JSON.stringify({
description: updatedDescription
})
}
);
const payload = await response.json().catch(function () {
return null;
});
if (!response.ok || (payload && payload.error)) {
throw new Error((payload && payload.error) || "Request failed");
}
queueCache = null;
queueCacheAt = 0;
await openHistoryForMiner(minerId);
} catch (error) {
alert("Could not update history event: " + (error.message || error));
button.disabled = false;
button.textContent = "Edit";
}
return;
}
if (deleteButton) {
const title = historyEvent.event_title || historyEvent.event_type || "history event";
if (!confirm("Delete this repair history event?\\n\\n" + title + "\\n\\nThis cannot be undone.")) {
return;
}
try {
button.disabled = true;
button.textContent = "Deleting...";
const response = await fetch(
"/api/miners/" + encodeURIComponent(minerId) + "/maintenance-events/" + encodeURIComponent(eventId),
{
method: "DELETE",
credentials: "same-origin",
headers: authHeaders({
"Accept": "application/json"
})
}
);
const payload = await response.json().catch(function () {
return null;
});
if (!response.ok || (payload && payload.error)) {
throw new Error((payload && payload.error) || "Request failed");
}
queueCache = null;
queueCacheAt = 0;
await openHistoryForMiner(minerId);
} catch (error) {
alert("Could not delete history event: " + (error.message || error));
button.disabled = false;
button.textContent = "Delete";
}
}
}, true);
}
function openModalShell() {
createModal();
document.getElementById("mineropsRepairHistoryModal").style.display = "flex";
document.getElementById("mineropsRepairHistoryBody").innerHTML = "Loading repair history...";
}
function closeModal() {
const modal = document.getElementById("mineropsRepairHistoryModal");
if (modal) {
modal.style.display = "none";
}
}
async function loadQueue() {
const now = Date.now();
if (queueCache && now - queueCacheAt < 15000) {
return queueCache;
}
const data = await apiGet("/api/repairs/queue");
queueCache = data.items || [];
queueCacheAt = now;
return queueCache;
}
function normalizeEvents(data) {
if (!data) {
return [];
}
if (Array.isArray(data)) {
return data;
}
if (Array.isArray(data.items)) {
return data.items;
}
if (Array.isArray(data.events)) {
return data.events;
}
return [];
}
function eventIsRepairRelated(event) {
const eventType = String(event.event_type || "").toLowerCase();
const componentType = String(event.component_type || "").toLowerCase();
const title = String(event.event_title || "").toLowerCase();
return (
eventType === "repair" ||
eventType === "moved" ||
componentType === "repair" ||
componentType === "location" ||
title.indexOf("repair") !== -1 ||
title.indexOf("location") !== -1 ||
title.indexOf("moved") !== -1
);
}
function renderHistory(item, events) {
const body = document.getElementById("mineropsRepairHistoryBody");
const subtitle = document.getElementById("mineropsRepairHistorySubtitle");
const detailLink = document.getElementById("mineropsRepairHistoryDetailLink");
const repairEvents = events.filter(eventIsRepairRelated);
subtitle.textContent = (item.current_mac || item.miner_id || "Miner") + " | " + (item.model || "-");
detailLink.href = "/miners/" + encodeURIComponent(item.miner_id) + "/detail";
const eventHtml = repairEvents.length
? repairEvents.map(function (event) {
return `
<div class="history-event">
<div class="history-event-title">
<strong>${escapeHtml(event.event_title || event.event_type || "Event")}</strong>
${statusPill(event.event_status || "")}
<span class="muted">${escapeHtml(event.event_type || "")}</span>
</div>
<div class="muted">${escapeHtml(formatDate(event.event_at || event.created_at || ""))}</div>
<div class="history-description">${escapeHtml(event.description || "")}</div>
<div class="muted" style="margin-top:6px;">
Component: ${escapeHtml(event.component_type || "-")}
${event.component_slot ? " | Slot: " + escapeHtml(event.component_slot) : ""}
</div>
<div style="margin-top:8px;display:flex;gap:6px;flex-wrap:wrap;">
<button type="button" class="repair-history-edit-btn" data-event-id="${escapeHtml(event.id || "")}">Edit</button>
<button type="button" class="repair-history-delete-btn" data-event-id="${escapeHtml(event.id || "")}">Delete</button>
</div>
</div>
`;
}).join("")
: '<div class="history-event">No repair or location history found for this miner.</div>';
body.innerHTML = `
<div class="history-metrics">
<div class="history-card">
<div class="muted">MAC</div>
<strong>${escapeHtml(item.current_mac || "-")}</strong>
</div>
<div class="history-card">
<div class="muted">Model</div>
<strong>${escapeHtml(item.model || "-")}</strong>
</div>
<div class="history-card">
<div class="muted">Miner SN</div>
<strong>${escapeHtml(item.miner_sn || "-")}</strong>
</div>
<div class="history-card">
<div class="muted">PSU SN</div>
<strong>${escapeHtml(item.power_sn || "-")}</strong>
</div>
<div class="history-card">
<div class="muted">Reported Problem</div>
<strong>${escapeHtml(item.reported_error || "No reported error")}</strong>
</div>
<div class="history-card">
<div class="muted">Latest Status</div>
<strong>${escapeHtml(item.latest_event_status || "unknown")}</strong>
</div>
</div>
<div class="history-event">
<div class="history-event-title">
<strong>Current repair note</strong>
</div>
<div class="history-description">${escapeHtml(item.state_notes || "No current state note.")}</div>
</div>
<h3 style="margin:16px 0 8px 0;">Repair and Location Timeline</h3>
${eventHtml}
`;
const eventById = {};
for (const event of repairEvents) {
if (event.id) {
eventById[event.id] = event;
}
}
window.mineropsRepairHistoryCurrentMinerId = item.miner_id;
window.mineropsRepairHistoryEventById = eventById;
body.querySelectorAll(".repair-history-edit-btn").forEach(function (button) {
button.addEventListener("click", async function () {
const eventId = button.getAttribute("data-event-id");
const event = eventById[eventId];
if (!event) {
alert("Could not find this history event.");
return;
}
const currentDescription = event.description || "";
const updatedDescription = prompt("Edit repair history note:", currentDescription);
if (updatedDescription === null) {
return;
}
try {
button.disabled = true;
button.textContent = "Saving...";
await apiPatch(
"/api/miners/" + encodeURIComponent(item.miner_id) + "/maintenance-events/" + encodeURIComponent(eventId),
{
description: updatedDescription
}
);
await openHistoryForMiner(item.miner_id);
} catch (error) {
alert("Could not update history event: " + (error.message || error));
button.disabled = false;
button.textContent = "Edit";
}
});
});
body.querySelectorAll(".repair-history-delete-btn").forEach(function (button) {
button.addEventListener("click", async function () {
const eventId = button.getAttribute("data-event-id");
const event = eventById[eventId];
if (!event) {
alert("Could not find this history event.");
return;
}
const title = event.event_title || event.event_type || "history event";
if (!confirm("Delete this repair history event?\\n\\n" + title + "\\n\\nThis cannot be undone.")) {
return;
}
try {
button.disabled = true;
button.textContent = "Deleting...";
await apiDelete(
"/api/miners/" + encodeURIComponent(item.miner_id) + "/maintenance-events/" + encodeURIComponent(eventId)
);
queueCache = null;
queueCacheAt = 0;
await openHistoryForMiner(item.miner_id);
} catch (error) {
alert("Could not delete history event: " + (error.message || error));
button.disabled = false;
button.textContent = "Delete";
}
});
});
}
async function openHistoryForMiner(minerId) {
openModalShell();
try {
const queue = await loadQueue();
const item = queue.find(function (row) {
return row.miner_id === minerId;
});
if (!item) {
throw new Error("Miner is not currently in the Repair Queue.");
}
const eventData = await apiGet("/api/miners/" + encodeURIComponent(minerId) + "/maintenance-events");
const events = normalizeEvents(eventData);
renderHistory(item, events);
} catch (error) {
document.getElementById("mineropsRepairHistorySubtitle").textContent = "Error";
document.getElementById("mineropsRepairHistoryBody").innerHTML =
'<div style="color:#991b1b;">Repair history failed to load: ' + escapeHtml(error.message || error) + '</div>';
}
}
function addHistoryButtons() {
document.querySelectorAll("#repairTableBody tr[data-miner-id]").forEach(function (row) {
if (row.querySelector(".repair-history-btn")) {
return;
}
const actions = row.querySelector(".row-actions");
if (!actions) {
return;
}
const button = document.createElement("button");
button.type = "button";
button.className = "repair-history-btn";
button.textContent = "History";
button.addEventListener("click", function () {
openHistoryForMiner(row.getAttribute("data-miner-id"));
});
actions.insertBefore(button, actions.firstChild);
});
}
function installObserver() {
const target = document.getElementById("repairTableBody");
if (!target) {
return;
}
const observer = new MutationObserver(addHistoryButtons);
observer.observe(target, {
childList: true,
subtree: true
});
addHistoryButtons();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
createModal();
installObserver();
});
} else {
createModal();
installObserver();
}
})();
"""
@app.get("/assets/minerops-repair-history-modal.js")
def minerops_repair_history_modal_asset():
return MinerOpsRepairHistoryResponse(
content=MINEROPS_REPAIR_HISTORY_JS,
media_type="application/javascript",
)
@app.middleware("http")
async def minerops_inject_repair_history_modal(request, call_next):
response = await call_next(request)
try:
path = request.url.path or ""
if path != "/repairs":
return response
content_type = response.headers.get("content-type", "")
if "text/html" not in content_type.lower():
return response
body = b""
async for chunk in response.body_iterator:
body += chunk
html = body.decode("utf-8", errors="ignore")
script_tag = '<script src="/assets/minerops-repair-history-modal.js"></script>'
if script_tag not in html:
if "</body>" in html:
html = html.replace("</body>", script_tag + "</body>")
else:
html += script_tag
headers = dict(response.headers)
headers.pop("content-length", None)
return MinerOpsRepairHistoryResponse(
content=html,
status_code=response.status_code,
headers=headers,
media_type="text/html",
)
except Exception:
return response
# MINEROPS_SYSTEM_HEALTH_V1
from fastapi.responses import HTMLResponse as MinerOpsSystemHealthHTMLResponse
from pathlib import Path as MinerOpsSystemPath
@app.get("/api/system/health-summary")
def minerops_system_health_summary(
current_user: dict = Depends(get_current_user),
):
import re
from collections import Counter
ensure_location_tables()
ensure_maintenance_tables()
source_path = MinerOpsSystemPath(__file__)
source_text = source_path.read_text(errors="ignore")
source_lines = source_text.splitlines()
route_re = re.compile(r'^\s*@app\.(get|post|patch|delete|put)\("([^"]+)"')
routes = []
for line_no, line in enumerate(source_lines, start=1):
match = route_re.search(line)
if match:
routes.append(
{
"method": match.group(1).upper(),
"path": match.group(2),
"line": line_no,
}
)
route_counter = Counter((r["method"], r["path"]) for r in routes)
duplicate_routes = []
for (method, route_path), count in sorted(route_counter.items(), key=lambda item: (item[0][1], item[0][0])):
if count > 1:
duplicate_routes.append(
{
"method": method,
"path": route_path,
"count": count,
"lines": [
r["line"]
for r in routes
if r["method"] == method and r["path"] == route_path
],
}
)
middleware_lines = [
line_no
for line_no, line in enumerate(source_lines, start=1)
if '@app.middleware("http")' in line
]
asset_routes = [
r
for r in routes
if str(r["path"]).startswith("/assets/")
]
patch_markers = []
for line_no, line in enumerate(source_lines, start=1):
stripped = line.strip()
if stripped.startswith("# MINEROPS_") or stripped.startswith("# Step "):
patch_markers.append(
{
"line": line_no,
"text": stripped,
}
)
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
relname AS table_name,
pg_total_relation_size(relid) AS total_bytes,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_catalog.pg_statio_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 15;
"""
)
largest_tables = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT
relname AS table_name,
n_live_tup::bigint AS estimated_rows,
n_dead_tup::bigint AS estimated_dead_rows,
last_vacuum,
last_autovacuum,
last_analyze,
last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_live_tup DESC
LIMIT 20;
"""
)
row_estimates = [dict(row) for row in cur.fetchall()]
table_counts = {}
for table_name in [
"miners",
"location_positions",
"miner_location_states",
"miner_maintenance_events",
"miner_hashboards",
"miner_network_identities",
"minerops_agent_command_queue",
]:
try:
cur.execute(f"SELECT COUNT(*) AS row_count FROM {table_name};")
table_counts[table_name] = cur.fetchone()["row_count"]
except Exception as exc:
table_counts[table_name] = f"error: {exc}"
cur.execute(
"""
SELECT
COALESCE(status, 'unknown') AS status,
COUNT(*) AS count
FROM miners
GROUP BY COALESCE(status, 'unknown')
ORDER BY status;
"""
)
miner_status_counts = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT
COALESCE(asset_state, 'unknown') AS asset_state,
COUNT(*) AS count
FROM miner_location_states
GROUP BY COALESCE(asset_state, 'unknown')
ORDER BY asset_state;
"""
)
location_state_counts = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT
COALESCE(event_status, 'unknown') AS event_status,
COUNT(*) AS count
FROM miner_maintenance_events
WHERE event_type = 'repair'
GROUP BY COALESCE(event_status, 'unknown')
ORDER BY event_status;
"""
)
repair_status_counts = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT
COUNT(*) FILTER (WHERE api3_supported IS TRUE) AS api3_supported,
COUNT(*) FILTER (WHERE api3_supported IS FALSE) AS api3_failed,
COUNT(*) FILTER (WHERE api3_supported IS NULL) AS api3_unknown,
COUNT(*) FILTER (WHERE power_sn IS NOT NULL AND power_sn <> '') AS miners_with_power_sn,
COUNT(*) FILTER (WHERE miner_sn IS NOT NULL AND miner_sn <> '') AS miners_with_miner_sn
FROM miners;
"""
)
api3_summary = dict(cur.fetchone())
cur.execute(
"""
SELECT
COUNT(*) AS repair_queue_count
FROM miner_location_states
WHERE asset_state = 'repair';
"""
)
repair_queue_count = cur.fetchone()["repair_queue_count"]
cur.execute(
"""
SELECT
COUNT(*) AS raw_status_rows,
MIN(observed_at) AS raw_oldest,
MAX(observed_at) AS raw_newest,
COUNT(*) FILTER (WHERE observed_at < now() - interval '30 days') AS raw_older_than_30d
FROM miner_status_snapshots;
"""
)
raw_status_retention = dict(cur.fetchone())
cur.execute(
"""
SELECT
COUNT(*) AS hourly_rows,
MIN(bucket_at) AS hourly_oldest,
MAX(bucket_at) AS hourly_newest
FROM miner_status_hourly_rollups;
"""
)
hourly_retention = dict(cur.fetchone())
cur.execute(
"""
SELECT
COUNT(*) AS daily_rows,
MIN(bucket_date) AS daily_oldest,
MAX(bucket_date) AS daily_newest
FROM miner_status_daily_rollups;
"""
)
daily_retention = dict(cur.fetchone())
cur.execute(
"""
SELECT
mode,
raw_status_retention_days,
raw_status_rows_eligible_delete,
raw_status_rows_deleted,
hourly_rollup_rows_after,
daily_rollup_rows_after,
notes,
started_at,
finished_at
FROM minerops_retention_runs
ORDER BY started_at DESC
LIMIT 1;
"""
)
latest_retention_run = cur.fetchone()
latest_retention_run = dict(latest_retention_run) if latest_retention_run else None
return api_response(
data={
"app": {
"status": "ok",
"source_lines": len(source_lines),
"source_size_bytes": source_path.stat().st_size,
"route_count": len(routes),
"duplicate_route_count": len(duplicate_routes),
"middleware_count": len(middleware_lines),
"asset_route_count": len(asset_routes),
},
"routes": {
"duplicates": duplicate_routes,
"middleware_lines": middleware_lines,
"asset_routes": asset_routes,
"recent_patch_markers": patch_markers[-40:],
},
"database": {
"largest_tables": largest_tables,
"row_estimates": row_estimates,
"table_counts": table_counts,
"miner_status_counts": miner_status_counts,
"location_state_counts": location_state_counts,
"repair_status_counts": repair_status_counts,
"api3_summary": api3_summary,
"repair_queue_count": repair_queue_count,
"retention": {
"raw_status": raw_status_retention,
"hourly_rollups": hourly_retention,
"daily_rollups": daily_retention,
"latest_run": latest_retention_run,
},
},
}
)
finally:
if conn is not None:
conn.close()
MINEROPS_SYSTEM_HEALTH_HTML = r"""
<!DOCTYPE html>
<html>
<head>
<title>MinerOps System Health</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: Segoe UI, Arial, sans-serif;
background: #f4f7fb;
color: #102a43;
margin: 0;
padding: 22px;
}
.nav {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.nav a {
border: 1px solid #bcccdc;
border-radius: 999px;
padding: 7px 11px;
background: #ffffff;
color: #0b63ce;
font-weight: 700;
text-decoration: none;
font-size: 13px;
}
.nav a.active {
background: #0f766e;
border-color: #0f766e;
color: #ffffff;
}
h1 {
margin: 0 0 4px 0;
font-size: 28px;
}
h2 {
margin-top: 0;
}
.muted {
color: #52606d;
font-size: 13px;
}
.card {
background: #ffffff;
border: 1px solid #d9e2ec;
border-radius: 12px;
padding: 14px;
margin-bottom: 14px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
.metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(145px, 1fr));
gap: 10px;
}
.metric {
border: 1px solid #d9e2ec;
border-radius: 10px;
background: #f8fafc;
padding: 11px;
}
.metric .value {
font-size: 24px;
font-weight: 800;
margin-top: 2px;
overflow-wrap: anywhere;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th, td {
border-bottom: 1px solid #d9e2ec;
padding: 7px;
text-align: left;
vertical-align: top;
}
th {
background: #f1f5f9;
}
.table-wrap {
overflow-x: auto;
}
button {
border: 1px solid #829ab1;
border-radius: 8px;
background: #f8fafc;
padding: 8px 10px;
font-weight: 700;
cursor: pointer;
}
button.primary {
background: #0f766e;
border-color: #0f766e;
color: #ffffff;
}
.status {
margin: 12px 0;
padding: 10px;
border-radius: 8px;
font-size: 13px;
}
.ok {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #166534;
}
.bad {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
}
.grid-two {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 14px;
}
code {
background: #e2e8f0;
padding: 1px 4px;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="nav">
<a href="/dashboard">Dashboard</a>
<a href="/repairs">Repairs</a>
<a href="/locations">Locations</a>
<a class="active" href="/system-health">System Health</a>
</div>
<div style="display:flex;justify-content:space-between;gap:12px;align-items:start;flex-wrap:wrap;margin-bottom:14px;">
<div>
<h1>System Health</h1>
<div class="muted">Quick operational view of MinerOps app size, routes, database tables, repairs, and location state.</div>
</div>
<button id="refreshBtn" type="button" class="primary">Refresh</button>
</div>
<div id="statusBox" class="status ok">Loading system health...</div>
<div class="card">
<h2>App Health</h2>
<div class="metrics">
<div class="metric"><div class="muted">Status</div><div id="appStatus" class="value">-</div></div>
<div class="metric"><div class="muted">main.py lines</div><div id="sourceLines" class="value">-</div></div>
<div class="metric"><div class="muted">Routes</div><div id="routeCount" class="value">-</div></div>
<div class="metric"><div class="muted">Duplicate routes</div><div id="duplicateRoutes" class="value">-</div></div>
<div class="metric"><div class="muted">Middleware</div><div id="middlewareCount" class="value">-</div></div>
<div class="metric"><div class="muted">Assets</div><div id="assetRouteCount" class="value">-</div></div>
</div>
</div>
<div class="card">
<h2>Fleet / Repair State</h2>
<div class="metrics">
<div class="metric"><div class="muted">Miners</div><div id="minerCount" class="value">-</div></div>
<div class="metric"><div class="muted">In Repair</div><div id="repairCount" class="value">-</div></div>
<div class="metric"><div class="muted">Shelved</div><div id="shelvedCount" class="value">-</div></div>
<div class="metric"><div class="muted">Waiting Placement</div><div id="waitingCount" class="value">-</div></div>
<div class="metric"><div class="muted">API3 Supported</div><div id="api3Supported" class="value">-</div></div>
<div class="metric"><div class="muted">API3 Failed</div><div id="api3Failed" class="value">-</div></div>
</div>
</div>
<div class="card">
<h2>Status Retention</h2>
<div class="metrics">
<div class="metric"><div class="muted">Raw Status Rows</div><div id="rawStatusRows" class="value">-</div></div>
<div class="metric"><div class="muted">Older Than 30 Days</div><div id="rawOlder30" class="value">-</div></div>
<div class="metric"><div class="muted">Hourly Rollups</div><div id="hourlyRollups" class="value">-</div></div>
<div class="metric"><div class="muted">Daily Rollups</div><div id="dailyRollups" class="value">-</div></div>
<div class="metric"><div class="muted">Last Retention Mode</div><div id="lastRetentionMode" class="value">-</div></div>
<div class="metric"><div class="muted">Last Deleted Rows</div><div id="lastDeletedRows" class="value">-</div></div>
</div>
<div id="retentionDetails" class="muted" style="margin-top:10px;">Loading retention details...</div>
</div>
<div class="grid-two">
<div class="card">
<h2>Largest Tables</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Table</th><th>Size</th></tr></thead>
<tbody id="largestTablesBody"><tr><td colspan="2">Loading...</td></tr></tbody>
</table>
</div>
</div>
<div class="card">
<h2>Repair Status Counts</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Status</th><th>Count</th></tr></thead>
<tbody id="repairStatusBody"><tr><td colspan="2">Loading...</td></tr></tbody>
</table>
</div>
</div>
</div>
<div class="grid-two">
<div class="card">
<h2>Duplicate Routes</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Method</th><th>Path</th><th>Count</th><th>Lines</th></tr></thead>
<tbody id="duplicateRoutesBody"><tr><td colspan="4">Loading...</td></tr></tbody>
</table>
</div>
</div>
<div class="card">
<h2>Recent Patch Markers</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Line</th><th>Marker</th></tr></thead>
<tbody id="patchMarkersBody"><tr><td colspan="2">Loading...</td></tr></tbody>
</table>
</div>
</div>
</div>
<script>
(function () {
"use strict";
function getStoredToken() {
const keys = [
"minerops_token",
"mineropsToken",
"authToken",
"access_token",
"token"
];
for (const storage of [window.localStorage, window.sessionStorage]) {
if (!storage) {
continue;
}
for (const key of keys) {
const value = storage.getItem(key);
if (value) {
return value;
}
}
}
return "";
}
function authHeaders(extra) {
const headers = Object.assign({}, extra || {});
const token = getStoredToken();
if (token && !headers.Authorization) {
headers.Authorization = "Bearer " + token;
}
return headers;
}
async function apiGet(url) {
const response = await fetch(url, {
method: "GET",
credentials: "same-origin",
headers: authHeaders({
"Accept": "application/json"
})
});
const payload = await response.json().catch(function () {
return null;
});
if (!response.ok || (payload && payload.error)) {
throw new Error((payload && payload.error) || "Request failed");
}
return payload ? payload.data : null;
}
function escapeHtml(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function setText(id, value) {
const el = document.getElementById(id);
if (el) {
el.textContent = value == null ? "-" : value;
}
}
function tableRows(rows, columns, emptyMessage) {
if (!rows || !rows.length) {
return '<tr><td colspan="' + columns.length + '">' + escapeHtml(emptyMessage || "No rows") + '</td></tr>';
}
return rows.map(function (row) {
return "<tr>" + columns.map(function (col) {
let value = row[col];
if (Array.isArray(value)) {
value = value.join(", ");
}
return "<td>" + escapeHtml(value) + "</td>";
}).join("") + "</tr>";
}).join("");
}
function countByKey(rows, keyName, valueName) {
const result = {};
for (const row of rows || []) {
result[row[keyName]] = row[valueName];
}
return result;
}
async function loadHealth() {
const statusBox = document.getElementById("statusBox");
try {
statusBox.className = "status ok";
statusBox.textContent = "Loading system health...";
const data = await apiGet("/api/system/health-summary");
const app = data.app || {};
const db = data.database || {};
const routes = data.routes || {};
setText("appStatus", app.status || "-");
setText("sourceLines", Number(app.source_lines || 0).toLocaleString());
setText("routeCount", app.route_count || 0);
setText("duplicateRoutes", app.duplicate_route_count || 0);
setText("middlewareCount", app.middleware_count || 0);
setText("assetRouteCount", app.asset_route_count || 0);
const locationCounts = countByKey(db.location_state_counts, "asset_state", "count");
const tableCounts = db.table_counts || {};
const api3 = db.api3_summary || {};
const retention = db.retention || {};
const rawStatus = retention.raw_status || {};
const hourly = retention.hourly_rollups || {};
const daily = retention.daily_rollups || {};
const latestRetention = retention.latest_run || {};
setText("minerCount", tableCounts.miners || 0);
setText("repairCount", db.repair_queue_count || 0);
setText("shelvedCount", locationCounts.shelved || 0);
setText("waitingCount", locationCounts.waiting_placement || 0);
setText("api3Supported", api3.api3_supported || 0);
setText("api3Failed", api3.api3_failed || 0);
setText("rawStatusRows", Number(rawStatus.raw_status_rows || 0).toLocaleString());
setText("rawOlder30", Number(rawStatus.raw_older_than_30d || 0).toLocaleString());
setText("hourlyRollups", Number(hourly.hourly_rows || 0).toLocaleString());
setText("dailyRollups", Number(daily.daily_rows || 0).toLocaleString());
setText("lastRetentionMode", latestRetention.mode || "-");
setText("lastDeletedRows", Number(latestRetention.raw_status_rows_deleted || 0).toLocaleString());
const retentionDetails = document.getElementById("retentionDetails");
if (retentionDetails) {
retentionDetails.innerHTML =
"Raw range: <code>" + escapeHtml(rawStatus.raw_oldest || "-") + "</code> to <code>" + escapeHtml(rawStatus.raw_newest || "-") + "</code><br>" +
"Hourly range: <code>" + escapeHtml(hourly.hourly_oldest || "-") + "</code> to <code>" + escapeHtml(hourly.hourly_newest || "-") + "</code><br>" +
"Daily range: <code>" + escapeHtml(daily.daily_oldest || "-") + "</code> to <code>" + escapeHtml(daily.daily_newest || "-") + "</code><br>" +
"Latest run: <code>" + escapeHtml(latestRetention.started_at || "-") + "</code> | " + escapeHtml(latestRetention.notes || "-");
}
document.getElementById("largestTablesBody").innerHTML = tableRows(
db.largest_tables || [],
["table_name", "total_size"],
"No table size data"
);
document.getElementById("repairStatusBody").innerHTML = tableRows(
db.repair_status_counts || [],
["event_status", "count"],
"No repair events"
);
document.getElementById("duplicateRoutesBody").innerHTML = tableRows(
routes.duplicates || [],
["method", "path", "count", "lines"],
"No duplicate routes"
);
document.getElementById("patchMarkersBody").innerHTML = tableRows(
routes.recent_patch_markers || [],
["line", "text"],
"No patch markers"
);
statusBox.className = "status ok";
statusBox.textContent = "System health loaded.";
} catch (error) {
statusBox.className = "status bad";
statusBox.textContent = "System health failed to load: " + (error.message || error);
}
}
document.getElementById("refreshBtn").addEventListener("click", loadHealth);
loadHealth();
})();
</script>
</body>
</html>
"""
@app.get("/system-health", response_class=MinerOpsSystemHealthHTMLResponse)
def minerops_system_health_page():
return MINEROPS_SYSTEM_HEALTH_HTML
# MINEROPS_SYSTEM_HEALTH_RETENTION_V1
# MINEROPS_REPAIR_READY_TO_SHELF_STATUS_V1
# MINEROPS_REPAIR_STATUS_LABELS_V1
# MINEROPS_REPAIR_PARTS_USED_BASIC_V1
# MINEROPS_REPAIR_PARTS_QTY_V1
# MINEROPS_REPAIR_PARTS_SUMMARY_API_V1
@app.get("/api/repairs/parts-summary")
def minerops_repair_parts_summary_api(
current_user: dict = Depends(get_current_user),
):
import re
from collections import defaultdict
ensure_maintenance_tables()
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
e.miner_id,
e.description,
e.event_at,
e.created_at,
m.current_mac,
m.model
FROM miner_maintenance_events e
LEFT JOIN miners m ON m.id = e.miner_id
WHERE e.description ILIKE '%%Parts used:%%'
ORDER BY COALESCE(e.event_at, e.created_at) DESC;
"""
)
rows = [dict(row) for row in cur.fetchall()]
part_totals = defaultdict(int)
recent = []
pattern = re.compile(
r"Parts used:\s*([A-Za-z0-9 _/\-]+?)(?:\s*\(Qty:\s*(\d+)\s*\))?(?:\n|$)",
re.IGNORECASE,
)
for row in rows:
description = row.get("description") or ""
for match in pattern.finditer(description):
part = (match.group(1) or "Other").strip()
qty = int(match.group(2) or "1")
if not part:
part = "Other"
part_totals[part] += qty
if len(recent) < 15:
recent.append(
{
"part": part,
"qty": qty,
"current_mac": row.get("current_mac"),
"model": row.get("model"),
"miner_id": str(row.get("miner_id") or ""),
"event_at": row.get("event_at") or row.get("created_at"),
}
)
totals = [
{
"part": part,
"qty": qty,
}
for part, qty in sorted(
part_totals.items(),
key=lambda item: (-item[1], item[0].lower()),
)
]
return api_response(
data={
"totals": totals,
"recent": recent,
"source_event_count": len(rows),
}
)
finally:
if conn is not None:
conn.close()
# MINEROPS_REPAIR_PARTS_SUMMARY_UI_V2
# MINEROPS_REPAIR_HISTORY_EDIT_DELETE_V1
# MINEROPS_REPAIR_HISTORY_API_HELPERS_SCOPE_FIX_V1
# MINEROPS_REPAIR_HISTORY_DELEGATED_ACTIONS_V1
# Register UUID-based Repair routes after legacy static Repair paths.
app.include_router(repairs_router)
app.include_router(control_board_router)