34659 lines
1.1 MiB
Plaintext
34659 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.agent_commands.dependencies import require_agent_identity
|
|
from app.agent_commands.router import router as agent_commands_router
|
|
from app.agent_registry.router import router as agent_registry_router
|
|
from app.agent_zones.dependencies import get_agent_zone_service
|
|
from app.agent_zones.models import MinerZoneAssociationResult
|
|
from app.agent_zones.service import AgentZoneService
|
|
from app.agent_zones.router import router as agent_zones_router
|
|
from app.agent_enrollment.router import router as agent_enrollment_router
|
|
from app.miner_control.router import router as miner_control_router
|
|
from app.dashboard.router import router as dashboard_router
|
|
from app.identity import (
|
|
COOKIE_TOKEN_HINT,
|
|
SESSION_COOKIE,
|
|
get_current_user,
|
|
install_identity_middleware,
|
|
require_permission,
|
|
user_has_permission,
|
|
router as identity_router,
|
|
)
|
|
from app.frontend.router import (
|
|
dashboard_page as internal_alpha_dashboard_page,
|
|
miner_detail_page as internal_alpha_miner_detail_page,
|
|
router as frontend_router,
|
|
)
|
|
from app.inventory.router import router as inventory_router
|
|
from app.locations.router import router as locations_router
|
|
from app.components.router import router as components_router
|
|
from app.miner_detail.router import router as miner_detail_router
|
|
from app.monitoring.router import router as monitoring_router
|
|
from app.monitoring.frontend import router as monitoring_frontend_router
|
|
from app.system_health.frontend import router as system_health_frontend_router
|
|
from app.system_health.router import router as system_health_router
|
|
from app.repairs.exceptions import (
|
|
ControlBoardReplacementActorRequired,
|
|
InvalidMacAddress,
|
|
RepairConflict,
|
|
RepairMinerNotFound,
|
|
)
|
|
from app.repairs.models import (
|
|
RepairCenterAccess,
|
|
RepairCenterFilters,
|
|
RepairCenterScope,
|
|
RepairMinerLifecycle,
|
|
ControlBoardReplacementReason,
|
|
ControlBoardReplacementRequest,
|
|
RemovedBoardDisposition,
|
|
)
|
|
from app.repairs.repository import RepairRepository
|
|
from app.repairs.center_repository import RepairCenterRepository
|
|
from app.repairs.center_service import RepairCenterService
|
|
from app.repairs.service import RepairService
|
|
from app.repairs.router import (
|
|
control_board_router,
|
|
router as repairs_router,
|
|
)
|
|
|
|
|
|
app = FastAPI(title=settings.app_name)
|
|
app.include_router(identity_router)
|
|
app.include_router(frontend_router)
|
|
app.include_router(inventory_router)
|
|
app.include_router(miner_control_router)
|
|
app.include_router(agent_commands_router)
|
|
app.include_router(agent_registry_router)
|
|
app.include_router(agent_zones_router)
|
|
app.include_router(agent_enrollment_router)
|
|
app.include_router(components_router)
|
|
app.include_router(locations_router)
|
|
app.include_router(dashboard_router)
|
|
app.include_router(miner_detail_router)
|
|
app.include_router(monitoring_frontend_router)
|
|
app.include_router(monitoring_router)
|
|
app.include_router(system_health_frontend_router)
|
|
app.include_router(system_health_router)
|
|
install_identity_middleware(app)
|
|
|
|
|
|
def api_response(data: Any = None, meta: dict | None = None, error: Any = None) -> dict:
|
|
return {
|
|
"data": data,
|
|
"meta": meta or {},
|
|
"error": error,
|
|
}
|
|
|
|
def json_safe(value: Any) -> Any:
|
|
if isinstance(value, dict):
|
|
return {key: json_safe(item) for key, item in value.items()}
|
|
|
|
if isinstance(value, list):
|
|
return [json_safe(item) for item in value]
|
|
|
|
if isinstance(value, datetime):
|
|
return value.isoformat()
|
|
|
|
return value
|
|
|
|
|
|
def get_db_connection():
|
|
return psycopg.connect(
|
|
host=settings.postgres_host,
|
|
port=settings.postgres_port,
|
|
dbname=settings.postgres_db,
|
|
user=settings.postgres_user,
|
|
password=settings.postgres_password,
|
|
connect_timeout=5,
|
|
)
|
|
|
|
|
|
def check_database() -> str:
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor() as cur:
|
|
cur.execute("SELECT 1;")
|
|
result = cur.fetchone()
|
|
if result and result[0] == 1:
|
|
return "ok"
|
|
return "error"
|
|
except Exception as exc:
|
|
return f"error: {exc}"
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
def check_redis() -> str:
|
|
try:
|
|
client = redis.Redis.from_url(settings.redis_url, socket_connect_timeout=5)
|
|
if client.ping():
|
|
return "ok"
|
|
return "error"
|
|
except Exception as exc:
|
|
return f"error: {exc}"
|
|
|
|
|
|
@app.get("/api/health")
|
|
def health_check():
|
|
database_status = check_database()
|
|
redis_status = check_redis()
|
|
|
|
overall_status = "ok"
|
|
if database_status != "ok" or redis_status != "ok":
|
|
overall_status = "degraded"
|
|
|
|
return api_response(
|
|
data={
|
|
"status": overall_status,
|
|
"app": settings.app_name,
|
|
"environment": settings.app_env,
|
|
"database": database_status,
|
|
"redis": redis_status,
|
|
}
|
|
)
|
|
|
|
|
|
class CreateRoomRequest(BaseModel):
|
|
site_id: str | None = None
|
|
name: str = Field(min_length=1, max_length=120)
|
|
description: str | None = None
|
|
sort_order: int = 0
|
|
|
|
|
|
class UpdateRoomRequest(BaseModel):
|
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
|
description: str | None = None
|
|
sort_order: int | None = None
|
|
|
|
|
|
@app.get("/api/sites")
|
|
def list_sites(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text, name, description, created_at, updated_at
|
|
FROM sites
|
|
ORDER BY name;
|
|
"""
|
|
)
|
|
sites = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=sites)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/rooms")
|
|
def list_rooms(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
rooms.id::text,
|
|
rooms.site_id::text,
|
|
sites.name AS site_name,
|
|
rooms.name,
|
|
rooms.description,
|
|
rooms.sort_order,
|
|
rooms.created_at,
|
|
rooms.updated_at
|
|
FROM rooms
|
|
JOIN sites ON sites.id = rooms.site_id
|
|
ORDER BY rooms.sort_order, rooms.name;
|
|
"""
|
|
)
|
|
rooms = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=rooms)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/rooms")
|
|
def create_room(request: CreateRoomRequest, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
site_id = request.site_id
|
|
|
|
if site_id is None:
|
|
cur.execute("SELECT id::text FROM sites ORDER BY name LIMIT 1;")
|
|
site = cur.fetchone()
|
|
if not site:
|
|
raise HTTPException(status_code=500, detail="No site exists")
|
|
site_id = site["id"]
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO rooms (site_id, name, description, sort_order)
|
|
VALUES (%s, %s, %s, %s)
|
|
RETURNING id::text, site_id::text, name, description, sort_order, created_at, updated_at;
|
|
""",
|
|
(
|
|
site_id,
|
|
request.name.strip(),
|
|
request.description,
|
|
request.sort_order,
|
|
),
|
|
)
|
|
room = cur.fetchone()
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
|
VALUES (%s, 'room.created', 'room', %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
room["id"],
|
|
psycopg.types.json.Jsonb(
|
|
{
|
|
"name": room["name"],
|
|
"site_id": room["site_id"],
|
|
"sort_order": room["sort_order"],
|
|
}
|
|
),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
return api_response(data=dict(room))
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.patch("/api/rooms/{room_id}")
|
|
def update_room(room_id: str, request: UpdateRoomRequest, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text, name, description, sort_order
|
|
FROM rooms
|
|
WHERE id = %s;
|
|
""",
|
|
(room_id,),
|
|
)
|
|
old_room = cur.fetchone()
|
|
|
|
if not old_room:
|
|
raise HTTPException(status_code=404, detail="Room not found")
|
|
|
|
new_name = request.name.strip() if request.name is not None else old_room["name"]
|
|
new_description = request.description if request.description is not None else old_room["description"]
|
|
new_sort_order = request.sort_order if request.sort_order is not None else old_room["sort_order"]
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE rooms
|
|
SET name = %s,
|
|
description = %s,
|
|
sort_order = %s,
|
|
updated_at = now()
|
|
WHERE id = %s
|
|
RETURNING id::text, site_id::text, name, description, sort_order, created_at, updated_at;
|
|
""",
|
|
(new_name, new_description, new_sort_order, room_id),
|
|
)
|
|
room = cur.fetchone()
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json, new_value_json)
|
|
VALUES (%s, 'room.updated', 'room', %s, %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
room["id"],
|
|
psycopg.types.json.Jsonb(json_safe(dict(old_room))),
|
|
psycopg.types.json.Jsonb(json_safe(dict(room))),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
return api_response(data=dict(room))
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.delete("/api/rooms/{room_id}")
|
|
def delete_room(room_id: str, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text, name, description, sort_order
|
|
FROM rooms
|
|
WHERE id = %s;
|
|
""",
|
|
(room_id,),
|
|
)
|
|
room = cur.fetchone()
|
|
|
|
if not room:
|
|
raise HTTPException(status_code=404, detail="Room not found")
|
|
|
|
cur.execute("DELETE FROM rooms WHERE id = %s;", (room_id,))
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json)
|
|
VALUES (%s, 'room.deleted', 'room', %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
room["id"],
|
|
psycopg.types.json.Jsonb(json_safe(dict(room))),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
return api_response(data={"deleted": True, "room_id": room_id})
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
class CreateRoomRequest(BaseModel):
|
|
site_id: str | None = None
|
|
name: str = Field(min_length=1, max_length=120)
|
|
description: str | None = None
|
|
sort_order: int = 0
|
|
|
|
|
|
class UpdateRoomRequest(BaseModel):
|
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
|
description: str | None = None
|
|
sort_order: int | None = None
|
|
|
|
|
|
@app.get("/api/sites")
|
|
def list_sites(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text, name, description, created_at, updated_at
|
|
FROM sites
|
|
ORDER BY name;
|
|
"""
|
|
)
|
|
sites = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=sites)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/rooms")
|
|
def list_rooms(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
rooms.id::text,
|
|
rooms.site_id::text,
|
|
sites.name AS site_name,
|
|
rooms.name,
|
|
rooms.description,
|
|
rooms.sort_order,
|
|
rooms.created_at,
|
|
rooms.updated_at
|
|
FROM rooms
|
|
JOIN sites ON sites.id = rooms.site_id
|
|
ORDER BY rooms.sort_order, rooms.name;
|
|
"""
|
|
)
|
|
rooms = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=rooms)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/rooms")
|
|
def create_room(request: CreateRoomRequest, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
site_id = request.site_id
|
|
|
|
if site_id is None:
|
|
cur.execute("SELECT id::text FROM sites ORDER BY name LIMIT 1;")
|
|
site = cur.fetchone()
|
|
if not site:
|
|
raise HTTPException(status_code=500, detail="No site exists")
|
|
site_id = site["id"]
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO rooms (site_id, name, description, sort_order)
|
|
VALUES (%s, %s, %s, %s)
|
|
RETURNING id::text, site_id::text, name, description, sort_order, created_at, updated_at;
|
|
""",
|
|
(
|
|
site_id,
|
|
request.name.strip(),
|
|
request.description,
|
|
request.sort_order,
|
|
),
|
|
)
|
|
room = cur.fetchone()
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
|
VALUES (%s, 'room.created', 'room', %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
room["id"],
|
|
psycopg.types.json.Jsonb(
|
|
{
|
|
"name": room["name"],
|
|
"site_id": room["site_id"],
|
|
"sort_order": room["sort_order"],
|
|
}
|
|
),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
return api_response(data=dict(room))
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.patch("/api/rooms/{room_id}")
|
|
def update_room(room_id: str, request: UpdateRoomRequest, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text, name, description, sort_order
|
|
FROM rooms
|
|
WHERE id = %s;
|
|
""",
|
|
(room_id,),
|
|
)
|
|
old_room = cur.fetchone()
|
|
|
|
if not old_room:
|
|
raise HTTPException(status_code=404, detail="Room not found")
|
|
|
|
new_name = request.name.strip() if request.name is not None else old_room["name"]
|
|
new_description = request.description if request.description is not None else old_room["description"]
|
|
new_sort_order = request.sort_order if request.sort_order is not None else old_room["sort_order"]
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE rooms
|
|
SET name = %s,
|
|
description = %s,
|
|
sort_order = %s,
|
|
updated_at = now()
|
|
WHERE id = %s
|
|
RETURNING id::text, site_id::text, name, description, sort_order, created_at, updated_at;
|
|
""",
|
|
(new_name, new_description, new_sort_order, room_id),
|
|
)
|
|
room = cur.fetchone()
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json, new_value_json)
|
|
VALUES (%s, 'room.updated', 'room', %s, %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
room["id"],
|
|
psycopg.types.json.Jsonb(json_safe(dict(old_room))),
|
|
psycopg.types.json.Jsonb(json_safe(dict(room))),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
return api_response(data=dict(room))
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.delete("/api/rooms/{room_id}")
|
|
def delete_room(room_id: str, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text, name, description, sort_order
|
|
FROM rooms
|
|
WHERE id = %s;
|
|
""",
|
|
(room_id,),
|
|
)
|
|
room = cur.fetchone()
|
|
|
|
if not room:
|
|
raise HTTPException(status_code=404, detail="Room not found")
|
|
|
|
cur.execute("DELETE FROM rooms WHERE id = %s;", (room_id,))
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json)
|
|
VALUES (%s, 'room.deleted', 'room', %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
room["id"],
|
|
psycopg.types.json.Jsonb(json_safe(dict(room))),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
return api_response(data={"deleted": True, "room_id": room_id})
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
import ipaddress
|
|
|
|
|
|
class CreateScanRangeRequest(BaseModel):
|
|
room_id: str | None = None
|
|
name: str = Field(min_length=1, max_length=120)
|
|
range_type: str = "cidr"
|
|
cidr: str | None = None
|
|
start_ip: str | None = None
|
|
end_ip: str | None = None
|
|
enabled: bool = True
|
|
scan_interval_seconds: int = Field(default=900, ge=60, le=86400)
|
|
timeout_ms: int = Field(default=3000, ge=500, le=30000)
|
|
retry_limit: int = Field(default=1, ge=0, le=5)
|
|
parallelism_limit: int = Field(default=10, ge=1, le=100)
|
|
stagger_ms: int = Field(default=250, ge=0, le=10000)
|
|
api_port: int = Field(default=4028, ge=1, le=65535)
|
|
|
|
|
|
class UpdateScanRangeRequest(BaseModel):
|
|
room_id: str | None = None
|
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
|
range_type: str | None = None
|
|
cidr: str | None = None
|
|
start_ip: str | None = None
|
|
end_ip: str | None = None
|
|
enabled: bool | None = None
|
|
scan_interval_seconds: int | None = Field(default=None, ge=60, le=86400)
|
|
timeout_ms: int | None = Field(default=None, ge=500, le=30000)
|
|
retry_limit: int | None = Field(default=None, ge=0, le=5)
|
|
parallelism_limit: int | None = Field(default=None, ge=1, le=100)
|
|
stagger_ms: int | None = Field(default=None, ge=0, le=10000)
|
|
api_port: int | None = Field(default=None, ge=1, le=65535)
|
|
|
|
|
|
def validate_scan_range_input(range_type: str, cidr: str | None, start_ip: str | None, end_ip: str | None) -> None:
|
|
if range_type not in ("cidr", "start_end"):
|
|
raise HTTPException(status_code=422, detail="range_type must be 'cidr' or 'start_end'")
|
|
|
|
if range_type == "cidr":
|
|
if not cidr:
|
|
raise HTTPException(status_code=422, detail="cidr is required when range_type is cidr")
|
|
try:
|
|
ipaddress.ip_network(cidr, strict=False)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail=f"Invalid CIDR range: {exc}")
|
|
|
|
if range_type == "start_end":
|
|
if not start_ip or not end_ip:
|
|
raise HTTPException(status_code=422, detail="start_ip and end_ip are required when range_type is start_end")
|
|
try:
|
|
start = ipaddress.ip_address(start_ip)
|
|
end = ipaddress.ip_address(end_ip)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail=f"Invalid IP address: {exc}")
|
|
|
|
if start.version != end.version:
|
|
raise HTTPException(status_code=422, detail="start_ip and end_ip must use the same IP version")
|
|
|
|
if int(start) > int(end):
|
|
raise HTTPException(status_code=422, detail="start_ip must be lower than or equal to end_ip")
|
|
|
|
|
|
@app.get("/api/scan-ranges")
|
|
def list_scan_ranges(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
scan_ranges.id::text,
|
|
scan_ranges.room_id::text,
|
|
rooms.name AS room_name,
|
|
scan_ranges.name,
|
|
scan_ranges.range_type,
|
|
scan_ranges.cidr,
|
|
host(scan_ranges.start_ip) AS start_ip,
|
|
host(scan_ranges.end_ip) AS end_ip,
|
|
scan_ranges.enabled,
|
|
scan_ranges.scan_interval_seconds,
|
|
scan_ranges.timeout_ms,
|
|
scan_ranges.retry_limit,
|
|
scan_ranges.parallelism_limit,
|
|
scan_ranges.stagger_ms,
|
|
scan_ranges.api_port,
|
|
scan_ranges.last_scan_at,
|
|
scan_ranges.next_scan_at,
|
|
scan_ranges.created_at,
|
|
scan_ranges.updated_at
|
|
FROM scan_ranges
|
|
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
|
|
ORDER BY rooms.sort_order, scan_ranges.name;
|
|
"""
|
|
)
|
|
ranges = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=ranges)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/scan-ranges")
|
|
def create_scan_range(request: CreateScanRangeRequest, current_user: dict = Depends(get_current_user)):
|
|
validate_scan_range_input(request.range_type, request.cidr, request.start_ip, request.end_ip)
|
|
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO scan_ranges (
|
|
room_id,
|
|
name,
|
|
range_type,
|
|
cidr,
|
|
start_ip,
|
|
end_ip,
|
|
enabled,
|
|
scan_interval_seconds,
|
|
timeout_ms,
|
|
retry_limit,
|
|
parallelism_limit,
|
|
stagger_ms,
|
|
api_port,
|
|
created_by_user_id,
|
|
next_scan_at
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, now())
|
|
RETURNING
|
|
id::text,
|
|
room_id::text,
|
|
name,
|
|
range_type,
|
|
cidr,
|
|
host(start_ip) AS start_ip,
|
|
host(end_ip) AS end_ip,
|
|
enabled,
|
|
scan_interval_seconds,
|
|
timeout_ms,
|
|
retry_limit,
|
|
parallelism_limit,
|
|
stagger_ms,
|
|
api_port,
|
|
last_scan_at,
|
|
next_scan_at,
|
|
created_at,
|
|
updated_at;
|
|
""",
|
|
(
|
|
request.room_id,
|
|
request.name.strip(),
|
|
request.range_type,
|
|
request.cidr,
|
|
request.start_ip,
|
|
request.end_ip,
|
|
request.enabled,
|
|
request.scan_interval_seconds,
|
|
request.timeout_ms,
|
|
request.retry_limit,
|
|
request.parallelism_limit,
|
|
request.stagger_ms,
|
|
request.api_port,
|
|
current_user["id"],
|
|
),
|
|
)
|
|
scan_range = cur.fetchone()
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
|
VALUES (%s, 'scan_range.created', 'scan_range', %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
scan_range["id"],
|
|
psycopg.types.json.Jsonb(json_safe(dict(scan_range))),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
return api_response(data=dict(scan_range))
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.patch("/api/scan-ranges/{scan_range_id}")
|
|
def update_scan_range(scan_range_id: str, request: UpdateScanRangeRequest, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
room_id::text,
|
|
name,
|
|
range_type,
|
|
cidr,
|
|
host(start_ip) AS start_ip,
|
|
host(end_ip) AS end_ip,
|
|
enabled,
|
|
scan_interval_seconds,
|
|
timeout_ms,
|
|
retry_limit,
|
|
parallelism_limit,
|
|
stagger_ms,
|
|
api_port
|
|
FROM scan_ranges
|
|
WHERE id = %s;
|
|
""",
|
|
(scan_range_id,),
|
|
)
|
|
old_range = cur.fetchone()
|
|
|
|
if not old_range:
|
|
raise HTTPException(status_code=404, detail="Scan range not found")
|
|
|
|
new_room_id = request.room_id if request.room_id is not None else old_range["room_id"]
|
|
new_name = request.name.strip() if request.name is not None else old_range["name"]
|
|
new_range_type = request.range_type if request.range_type is not None else old_range["range_type"]
|
|
new_cidr = request.cidr if request.cidr is not None else old_range["cidr"]
|
|
new_start_ip = request.start_ip if request.start_ip is not None else old_range["start_ip"]
|
|
new_end_ip = request.end_ip if request.end_ip is not None else old_range["end_ip"]
|
|
new_enabled = request.enabled if request.enabled is not None else old_range["enabled"]
|
|
new_scan_interval_seconds = request.scan_interval_seconds if request.scan_interval_seconds is not None else old_range["scan_interval_seconds"]
|
|
new_timeout_ms = request.timeout_ms if request.timeout_ms is not None else old_range["timeout_ms"]
|
|
new_retry_limit = request.retry_limit if request.retry_limit is not None else old_range["retry_limit"]
|
|
new_parallelism_limit = request.parallelism_limit if request.parallelism_limit is not None else old_range["parallelism_limit"]
|
|
new_stagger_ms = request.stagger_ms if request.stagger_ms is not None else old_range["stagger_ms"]
|
|
new_api_port = request.api_port if request.api_port is not None else old_range["api_port"]
|
|
|
|
validate_scan_range_input(new_range_type, new_cidr, new_start_ip, new_end_ip)
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE scan_ranges
|
|
SET room_id = %s,
|
|
name = %s,
|
|
range_type = %s,
|
|
cidr = %s,
|
|
start_ip = %s,
|
|
end_ip = %s,
|
|
enabled = %s,
|
|
scan_interval_seconds = %s,
|
|
timeout_ms = %s,
|
|
retry_limit = %s,
|
|
parallelism_limit = %s,
|
|
stagger_ms = %s,
|
|
api_port = %s,
|
|
updated_at = now()
|
|
WHERE id = %s
|
|
RETURNING
|
|
id::text,
|
|
room_id::text,
|
|
name,
|
|
range_type,
|
|
cidr,
|
|
host(start_ip) AS start_ip,
|
|
host(end_ip) AS end_ip,
|
|
enabled,
|
|
scan_interval_seconds,
|
|
timeout_ms,
|
|
retry_limit,
|
|
parallelism_limit,
|
|
stagger_ms,
|
|
api_port,
|
|
last_scan_at,
|
|
next_scan_at,
|
|
created_at,
|
|
updated_at;
|
|
""",
|
|
(
|
|
new_room_id,
|
|
new_name,
|
|
new_range_type,
|
|
new_cidr,
|
|
new_start_ip,
|
|
new_end_ip,
|
|
new_enabled,
|
|
new_scan_interval_seconds,
|
|
new_timeout_ms,
|
|
new_retry_limit,
|
|
new_parallelism_limit,
|
|
new_stagger_ms,
|
|
new_api_port,
|
|
scan_range_id,
|
|
),
|
|
)
|
|
scan_range = cur.fetchone()
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json, new_value_json)
|
|
VALUES (%s, 'scan_range.updated', 'scan_range', %s, %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
scan_range["id"],
|
|
psycopg.types.json.Jsonb(json_safe(dict(old_range))),
|
|
psycopg.types.json.Jsonb(json_safe(dict(scan_range))),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
return api_response(data=dict(scan_range))
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.delete("/api/scan-ranges/{scan_range_id}")
|
|
def delete_scan_range(scan_range_id: str, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text, name, range_type, cidr, host(start_ip) AS start_ip, end_ip::text
|
|
FROM scan_ranges
|
|
WHERE id = %s;
|
|
""",
|
|
(scan_range_id,),
|
|
)
|
|
old_range = cur.fetchone()
|
|
|
|
if not old_range:
|
|
raise HTTPException(status_code=404, detail="Scan range not found")
|
|
|
|
cur.execute("DELETE FROM scan_ranges WHERE id = %s;", (scan_range_id,))
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json)
|
|
VALUES (%s, 'scan_range.deleted', 'scan_range', %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
old_range["id"],
|
|
psycopg.types.json.Jsonb(json_safe(dict(old_range))),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
return api_response(data={"deleted": True, "scan_range_id": scan_range_id})
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
@app.get("/api/scan-runs")
|
|
def list_scan_runs(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
scan_runs.id::text,
|
|
scan_runs.scan_range_id::text,
|
|
scan_ranges.name AS scan_range_name,
|
|
rooms.name AS room_name,
|
|
scan_runs.triggered_by,
|
|
scan_runs.triggered_by_user_id::text,
|
|
scan_runs.started_at,
|
|
scan_runs.finished_at,
|
|
scan_runs.status,
|
|
scan_runs.total_ips,
|
|
scan_runs.scanned_ips,
|
|
scan_runs.miners_found,
|
|
scan_runs.errors_count,
|
|
scan_runs.notes
|
|
FROM scan_runs
|
|
LEFT JOIN scan_ranges ON scan_ranges.id = scan_runs.scan_range_id
|
|
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
|
|
ORDER BY scan_runs.started_at DESC
|
|
LIMIT 100;
|
|
"""
|
|
)
|
|
scan_runs = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=scan_runs)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/scan-runs/{scan_run_id}")
|
|
def get_scan_run(scan_run_id: str, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
scan_runs.id::text,
|
|
scan_runs.scan_range_id::text,
|
|
scan_ranges.name AS scan_range_name,
|
|
rooms.name AS room_name,
|
|
scan_runs.triggered_by,
|
|
scan_runs.triggered_by_user_id::text,
|
|
scan_runs.started_at,
|
|
scan_runs.finished_at,
|
|
scan_runs.status,
|
|
scan_runs.total_ips,
|
|
scan_runs.scanned_ips,
|
|
scan_runs.miners_found,
|
|
scan_runs.errors_count,
|
|
scan_runs.notes
|
|
FROM scan_runs
|
|
LEFT JOIN scan_ranges ON scan_ranges.id = scan_runs.scan_range_id
|
|
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
|
|
WHERE scan_runs.id = %s;
|
|
""",
|
|
(scan_run_id,),
|
|
)
|
|
scan_run = cur.fetchone()
|
|
|
|
if not scan_run:
|
|
raise HTTPException(status_code=404, detail="Scan run not found")
|
|
|
|
return api_response(data=dict(scan_run))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/scan-runs/{scan_run_id}/results")
|
|
def list_scan_run_results(scan_run_id: str, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
scan_run_id::text,
|
|
host(ip_address) AS ip_address,
|
|
port,
|
|
detected,
|
|
mac_address,
|
|
response_ms,
|
|
status,
|
|
error_message,
|
|
raw_summary_json,
|
|
raw_version_json,
|
|
raw_pools_json,
|
|
raw_error_json,
|
|
created_at
|
|
FROM scan_results
|
|
WHERE scan_run_id = %s
|
|
ORDER BY ip_address
|
|
LIMIT 2000;
|
|
""",
|
|
(scan_run_id,),
|
|
)
|
|
results = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=results)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
@app.get("/api/scan-runs")
|
|
def list_scan_runs(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
scan_runs.id::text,
|
|
scan_runs.scan_range_id::text,
|
|
scan_ranges.name AS scan_range_name,
|
|
rooms.name AS room_name,
|
|
scan_runs.triggered_by,
|
|
scan_runs.triggered_by_user_id::text,
|
|
scan_runs.started_at,
|
|
scan_runs.finished_at,
|
|
scan_runs.status,
|
|
scan_runs.total_ips,
|
|
scan_runs.scanned_ips,
|
|
scan_runs.miners_found,
|
|
scan_runs.errors_count,
|
|
scan_runs.notes
|
|
FROM scan_runs
|
|
LEFT JOIN scan_ranges ON scan_ranges.id = scan_runs.scan_range_id
|
|
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
|
|
ORDER BY scan_runs.started_at DESC
|
|
LIMIT 100;
|
|
"""
|
|
)
|
|
scan_runs = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=scan_runs)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/scan-runs/{scan_run_id}")
|
|
def get_scan_run(scan_run_id: str, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
scan_runs.id::text,
|
|
scan_runs.scan_range_id::text,
|
|
scan_ranges.name AS scan_range_name,
|
|
rooms.name AS room_name,
|
|
scan_runs.triggered_by,
|
|
scan_runs.triggered_by_user_id::text,
|
|
scan_runs.started_at,
|
|
scan_runs.finished_at,
|
|
scan_runs.status,
|
|
scan_runs.total_ips,
|
|
scan_runs.scanned_ips,
|
|
scan_runs.miners_found,
|
|
scan_runs.errors_count,
|
|
scan_runs.notes
|
|
FROM scan_runs
|
|
LEFT JOIN scan_ranges ON scan_ranges.id = scan_runs.scan_range_id
|
|
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
|
|
WHERE scan_runs.id = %s;
|
|
""",
|
|
(scan_run_id,),
|
|
)
|
|
scan_run = cur.fetchone()
|
|
|
|
if not scan_run:
|
|
raise HTTPException(status_code=404, detail="Scan run not found")
|
|
|
|
return api_response(data=dict(scan_run))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/scan-runs/{scan_run_id}/results")
|
|
def list_scan_run_results(scan_run_id: str, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
scan_run_id::text,
|
|
host(ip_address) AS ip_address,
|
|
port,
|
|
detected,
|
|
mac_address,
|
|
response_ms,
|
|
status,
|
|
error_message,
|
|
raw_summary_json,
|
|
raw_version_json,
|
|
raw_pools_json,
|
|
raw_error_json,
|
|
created_at
|
|
FROM scan_results
|
|
WHERE scan_run_id = %s
|
|
ORDER BY ip_address
|
|
LIMIT 2000;
|
|
""",
|
|
(scan_run_id,),
|
|
)
|
|
results = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=results)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
import socket
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
|
|
|
class ScanNowRequest(BaseModel):
|
|
max_ips: int = Field(default=25, ge=1, le=2000)
|
|
specific_ips: list[str] | None = None
|
|
|
|
|
|
def expand_scan_range_for_scan(scan_range: dict, max_ips: int, specific_ips: list[str] | None = None) -> list[str]:
|
|
if specific_ips:
|
|
validated_ips = []
|
|
for ip in specific_ips:
|
|
try:
|
|
validated_ips.append(str(ipaddress.ip_address(ip)))
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail=f"Invalid IP address {ip}: {exc}")
|
|
return validated_ips[:max_ips]
|
|
|
|
range_type = scan_range["range_type"]
|
|
|
|
if range_type == "cidr":
|
|
network = ipaddress.ip_network(scan_range["cidr"], strict=False)
|
|
return [str(ip) for ip in list(network.hosts())[:max_ips]]
|
|
|
|
if range_type == "start_end":
|
|
start = ipaddress.ip_address(scan_range["start_ip"])
|
|
end = ipaddress.ip_address(scan_range["end_ip"])
|
|
|
|
ips = []
|
|
current = int(start)
|
|
end_int = int(end)
|
|
|
|
while current <= end_int and len(ips) < max_ips:
|
|
ips.append(str(ipaddress.ip_address(current)))
|
|
current += 1
|
|
|
|
return ips
|
|
|
|
raise HTTPException(status_code=422, detail="Unsupported scan range type")
|
|
|
|
|
|
def tcp_check_ip(ip_address: str, port: int, timeout_ms: int) -> dict:
|
|
started = time.perf_counter()
|
|
|
|
try:
|
|
with socket.create_connection((ip_address, port), timeout=timeout_ms / 1000):
|
|
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
|
return {
|
|
"ip_address": ip_address,
|
|
"port": port,
|
|
"detected": True,
|
|
"response_ms": elapsed_ms,
|
|
"status": "port_open",
|
|
"error_message": None,
|
|
}
|
|
except socket.timeout:
|
|
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
|
return {
|
|
"ip_address": ip_address,
|
|
"port": port,
|
|
"detected": False,
|
|
"response_ms": elapsed_ms,
|
|
"status": "timeout",
|
|
"error_message": "Connection timed out",
|
|
}
|
|
except OSError as exc:
|
|
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
|
return {
|
|
"ip_address": ip_address,
|
|
"port": port,
|
|
"detected": False,
|
|
"response_ms": elapsed_ms,
|
|
"status": "no_response",
|
|
"error_message": str(exc),
|
|
}
|
|
|
|
|
|
@app.post("/api/scan-ranges/{scan_range_id}/scan-now")
|
|
def scan_range_now(scan_range_id: str, request: ScanNowRequest, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
room_id::text,
|
|
name,
|
|
range_type,
|
|
cidr,
|
|
host(start_ip) AS start_ip,
|
|
host(end_ip) AS end_ip,
|
|
enabled,
|
|
timeout_ms,
|
|
retry_limit,
|
|
parallelism_limit,
|
|
stagger_ms,
|
|
api_port,
|
|
scan_interval_seconds
|
|
FROM scan_ranges
|
|
WHERE id = %s;
|
|
""",
|
|
(scan_range_id,),
|
|
)
|
|
scan_range = cur.fetchone()
|
|
|
|
if not scan_range:
|
|
raise HTTPException(status_code=404, detail="Scan range not found")
|
|
|
|
if not scan_range["enabled"]:
|
|
raise HTTPException(status_code=409, detail="Scan range is disabled")
|
|
|
|
ip_list = expand_scan_range_for_scan(dict(scan_range), request.max_ips, request.specific_ips)
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO scan_runs (
|
|
scan_range_id,
|
|
triggered_by,
|
|
triggered_by_user_id,
|
|
status,
|
|
total_ips,
|
|
scanned_ips,
|
|
miners_found,
|
|
errors_count,
|
|
notes
|
|
)
|
|
VALUES (%s, 'manual', %s, 'running', %s, 0, 0, 0, %s)
|
|
RETURNING id::text;
|
|
""",
|
|
(
|
|
scan_range_id,
|
|
current_user["id"],
|
|
len(ip_list),
|
|
"TCP port 4028 reachability scan only",
|
|
),
|
|
)
|
|
scan_run_id = cur.fetchone()["id"]
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
|
VALUES (%s, 'scan.started', 'scan_run', %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
scan_run_id,
|
|
psycopg.types.json.Jsonb(
|
|
{
|
|
"scan_range_id": scan_range_id,
|
|
"scan_range_name": scan_range["name"],
|
|
"total_ips": len(ip_list),
|
|
"mode": "tcp_4028_only",
|
|
}
|
|
),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
results = []
|
|
detected_count = 0
|
|
errors_count = 0
|
|
|
|
max_workers = min(scan_range["parallelism_limit"], len(ip_list))
|
|
|
|
if max_workers < 1:
|
|
max_workers = 1
|
|
|
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
future_map = {
|
|
executor.submit(
|
|
tcp_check_ip,
|
|
ip,
|
|
scan_range["api_port"],
|
|
scan_range["timeout_ms"],
|
|
): ip
|
|
for ip in ip_list
|
|
}
|
|
|
|
for future in as_completed(future_map):
|
|
result = future.result()
|
|
results.append(result)
|
|
|
|
if result["detected"]:
|
|
detected_count += 1
|
|
|
|
if result["status"] not in ("port_open", "timeout", "no_response"):
|
|
errors_count += 1
|
|
|
|
results = sorted(results, key=lambda item: ipaddress.ip_address(item["ip_address"]))
|
|
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
for result in results:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO scan_results (
|
|
scan_run_id,
|
|
ip_address,
|
|
port,
|
|
detected,
|
|
response_ms,
|
|
status,
|
|
error_message
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s);
|
|
""",
|
|
(
|
|
scan_run_id,
|
|
result["ip_address"],
|
|
result["port"],
|
|
result["detected"],
|
|
result["response_ms"],
|
|
result["status"],
|
|
result["error_message"],
|
|
),
|
|
)
|
|
|
|
final_status = "completed"
|
|
if errors_count > 0:
|
|
final_status = "completed_with_errors"
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE scan_runs
|
|
SET finished_at = now(),
|
|
status = %s,
|
|
scanned_ips = %s,
|
|
miners_found = %s,
|
|
errors_count = %s
|
|
WHERE id = %s
|
|
RETURNING
|
|
id::text,
|
|
scan_range_id::text,
|
|
triggered_by,
|
|
started_at,
|
|
finished_at,
|
|
status,
|
|
total_ips,
|
|
scanned_ips,
|
|
miners_found,
|
|
errors_count,
|
|
notes;
|
|
""",
|
|
(
|
|
final_status,
|
|
len(results),
|
|
detected_count,
|
|
errors_count,
|
|
scan_run_id,
|
|
),
|
|
)
|
|
scan_run = cur.fetchone()
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE scan_ranges
|
|
SET last_scan_at = now(),
|
|
next_scan_at = now() + make_interval(secs => scan_interval_seconds),
|
|
updated_at = now()
|
|
WHERE id = %s;
|
|
""",
|
|
(scan_range_id,),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
|
VALUES (%s, 'scan.completed', 'scan_run', %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
scan_run_id,
|
|
psycopg.types.json.Jsonb(
|
|
{
|
|
"scan_range_id": scan_range_id,
|
|
"total_ips": len(ip_list),
|
|
"scanned_ips": len(results),
|
|
"port_4028_open": detected_count,
|
|
"errors_count": errors_count,
|
|
}
|
|
),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
return api_response(
|
|
data={
|
|
"scan_run": dict(scan_run),
|
|
"summary": {
|
|
"scan_range_id": scan_range_id,
|
|
"scan_range_name": scan_range["name"],
|
|
"total_ips": len(ip_list),
|
|
"scanned_ips": len(results),
|
|
"port_4028_open": detected_count,
|
|
"errors_count": errors_count,
|
|
},
|
|
}
|
|
)
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
class WhatsminerReadTestRequest(BaseModel):
|
|
ip_address: str
|
|
port: int = Field(default=4028, ge=1, le=65535)
|
|
command: str = Field(default="summary", min_length=1, max_length=80)
|
|
params: dict[str, Any] | None = None
|
|
timeout_ms: int = Field(default=5000, ge=500, le=30000)
|
|
|
|
|
|
def read_from_socket_until_closed(sock: socket.socket, max_bytes: int = 1048576) -> bytes:
|
|
chunks = []
|
|
total = 0
|
|
|
|
while True:
|
|
chunk = sock.recv(65536)
|
|
if not chunk:
|
|
break
|
|
|
|
chunks.append(chunk)
|
|
total += len(chunk)
|
|
|
|
if total >= max_bytes:
|
|
break
|
|
|
|
return b"".join(chunks)
|
|
|
|
|
|
def send_whatsminer_read_command(ip_address: str, port: int, command: str, timeout_ms: int, params: dict[str, Any] | None = None) -> dict:
|
|
try:
|
|
ipaddress.ip_address(ip_address)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail=f"Invalid IP address: {exc}")
|
|
|
|
import json
|
|
|
|
started = time.perf_counter()
|
|
|
|
params = params or {}
|
|
|
|
command_payload = {"command": command}
|
|
command_payload.update(params)
|
|
|
|
cmd_payload = {"cmd": command}
|
|
cmd_payload.update(params)
|
|
|
|
command_payloads = [
|
|
{
|
|
"name": "json_command_plain",
|
|
"payload": json.dumps(command_payload, separators=(",", ":")).encode("utf-8"),
|
|
},
|
|
{
|
|
"name": "json_command_newline",
|
|
"payload": (json.dumps(command_payload, separators=(",", ":")) + "\n").encode("utf-8"),
|
|
},
|
|
{
|
|
"name": "json_cmd_plain",
|
|
"payload": json.dumps(cmd_payload, separators=(",", ":")).encode("utf-8"),
|
|
},
|
|
{
|
|
"name": "json_cmd_newline",
|
|
"payload": (json.dumps(cmd_payload, separators=(",", ":")) + "\n").encode("utf-8"),
|
|
},
|
|
{
|
|
"name": "plain_command",
|
|
"payload": command.encode("utf-8"),
|
|
},
|
|
{
|
|
"name": "plain_command_newline",
|
|
"payload": (command + "\n").encode("utf-8"),
|
|
},
|
|
]
|
|
|
|
attempts = []
|
|
|
|
for command_payload in command_payloads:
|
|
payload_name = command_payload["name"]
|
|
payload = command_payload["payload"]
|
|
|
|
try:
|
|
with socket.create_connection((ip_address, port), timeout=timeout_ms / 1000) as sock:
|
|
sock.settimeout(timeout_ms / 1000)
|
|
sock.sendall(payload)
|
|
raw_response = read_from_socket_until_closed(sock)
|
|
|
|
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
|
response_text = raw_response.decode("utf-8", errors="replace").strip()
|
|
|
|
attempts.append(
|
|
{
|
|
"payload_type": payload_name,
|
|
"bytes_received": len(raw_response),
|
|
"response_preview": response_text[:1000],
|
|
}
|
|
)
|
|
|
|
if response_text:
|
|
parsed_json = None
|
|
parse_error = None
|
|
|
|
try:
|
|
parsed_json = json.loads(response_text)
|
|
except Exception as exc:
|
|
parse_error = str(exc)
|
|
|
|
return {
|
|
"success": True,
|
|
"ip_address": ip_address,
|
|
"port": port,
|
|
"command": command,
|
|
"payload_type": payload_name,
|
|
"response_ms": elapsed_ms,
|
|
"bytes_received": len(raw_response),
|
|
"response_text": response_text,
|
|
"parsed_json": parsed_json,
|
|
"parse_error": parse_error,
|
|
"attempts": attempts,
|
|
}
|
|
|
|
except Exception as exc:
|
|
attempts.append(
|
|
{
|
|
"payload_type": payload_name,
|
|
"bytes_received": 0,
|
|
"error": str(exc),
|
|
}
|
|
)
|
|
|
|
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
|
|
|
return {
|
|
"success": False,
|
|
"ip_address": ip_address,
|
|
"port": port,
|
|
"command": command,
|
|
"response_ms": elapsed_ms,
|
|
"attempts": attempts,
|
|
}
|
|
|
|
|
|
@app.post("/api/tools/whatsminer-read-test")
|
|
def whatsminer_read_test(request: WhatsminerReadTestRequest, current_user: dict = Depends(get_current_user)):
|
|
result = send_whatsminer_read_command(
|
|
ip_address=request.ip_address,
|
|
port=request.port,
|
|
command=request.command,
|
|
timeout_ms=request.timeout_ms,
|
|
params=request.params,
|
|
)
|
|
|
|
return api_response(data=result)
|
|
|
|
@app.get("/api/miners")
|
|
def list_miners(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
miners.id::text,
|
|
miners.site_id::text,
|
|
sites.name AS site_name,
|
|
miners.room_id::text,
|
|
rooms.name AS room_name,
|
|
miners.asset_tag,
|
|
miners.current_mac,
|
|
host(miners.current_ip) AS current_ip,
|
|
miners.hostname,
|
|
miners.miner_sn,
|
|
miners.power_sn,
|
|
miners.model,
|
|
miners.miner_type,
|
|
miners.api_version,
|
|
miners.firmware_version,
|
|
miners.platform,
|
|
miners.chip,
|
|
miners.status,
|
|
miners.first_seen_at,
|
|
miners.last_seen_at,
|
|
miners.updated_at
|
|
FROM miners
|
|
LEFT JOIN sites ON sites.id = miners.site_id
|
|
LEFT JOIN rooms ON rooms.id = miners.room_id
|
|
ORDER BY rooms.sort_order, miners.current_ip, miners.current_mac;
|
|
"""
|
|
)
|
|
miners = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=miners)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/miners/{miner_id}")
|
|
def get_miner(miner_id: str, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
miners.id::text,
|
|
miners.site_id::text,
|
|
sites.name AS site_name,
|
|
miners.room_id::text,
|
|
rooms.name AS room_name,
|
|
miners.asset_tag,
|
|
miners.current_mac,
|
|
host(miners.current_ip) AS current_ip,
|
|
miners.hostname,
|
|
miners.miner_sn,
|
|
miners.power_sn,
|
|
miners.model,
|
|
miners.miner_type,
|
|
miners.api_version,
|
|
miners.firmware_version,
|
|
miners.platform,
|
|
miners.chip,
|
|
miners.status,
|
|
miners.notes,
|
|
miners.first_seen_at,
|
|
miners.last_seen_at,
|
|
miners.created_at,
|
|
miners.updated_at
|
|
FROM miners
|
|
LEFT JOIN sites ON sites.id = miners.site_id
|
|
LEFT JOIN rooms ON rooms.id = miners.room_id
|
|
WHERE miners.id = %s;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
miner = cur.fetchone()
|
|
|
|
if not miner:
|
|
raise HTTPException(status_code=404, detail="Miner not found")
|
|
|
|
return api_response(data=dict(miner))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/miners/{miner_id}/network-identities")
|
|
def list_miner_network_identities(miner_id: str, current_user: dict = Depends(require_permission("miners.view"))):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
miner_id::text,
|
|
mac_address,
|
|
host(ip_address) AS ip_address,
|
|
hostname,
|
|
source,
|
|
is_current,
|
|
first_seen_at,
|
|
last_seen_at,
|
|
created_at,
|
|
updated_at
|
|
FROM miner_network_identities
|
|
WHERE miner_id = %s
|
|
ORDER BY is_current DESC, last_seen_at DESC;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
identities = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=identities)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/miners/{miner_id}/hashboards")
|
|
def list_miner_hashboards(miner_id: str, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
miner_id::text,
|
|
slot,
|
|
pcb_sn,
|
|
chip_data,
|
|
temperature_c,
|
|
chip_frequency,
|
|
mhs_av,
|
|
mhs_1m,
|
|
mhs_15m,
|
|
hs_rt,
|
|
factory_ghs,
|
|
effective_chips,
|
|
upfreq_complete,
|
|
first_seen_at,
|
|
last_seen_at,
|
|
updated_at
|
|
FROM miner_hashboards
|
|
WHERE miner_id = %s
|
|
ORDER BY slot;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
hashboards = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=hashboards)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/miners/{miner_id}/status-snapshots")
|
|
def list_miner_status_snapshots(
|
|
miner_id: str,
|
|
current_user: dict = Depends(require_permission("miners.view")),
|
|
):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
miner_id::text,
|
|
host(observed_ip) AS observed_ip,
|
|
observed_at,
|
|
mhs_av,
|
|
mhs_1m,
|
|
mhs_15m,
|
|
hs_rt,
|
|
power_w,
|
|
power_rate_w_per_th,
|
|
env_temp_c,
|
|
chip_temp_min_c,
|
|
chip_temp_max_c,
|
|
chip_temp_avg_c,
|
|
fan_speed_in_rpm,
|
|
fan_speed_out_rpm,
|
|
uptime_seconds,
|
|
hash_stable,
|
|
pool_rejected_percent,
|
|
COALESCE(
|
|
raw_summary_json #>> '{Msg,Power Mode}',
|
|
raw_summary_json #>> '{MSG,Power Mode}',
|
|
raw_summary_json #>> '{msg,Power Mode}',
|
|
raw_summary_json ->> 'Power Mode',
|
|
raw_summary_json ->> 'PowerMode',
|
|
raw_summary_json ->> 'power_mode'
|
|
) AS performance_mode,
|
|
raw_summary_json,
|
|
created_at
|
|
FROM miner_status_snapshots
|
|
WHERE miner_id = %s
|
|
ORDER BY observed_at DESC
|
|
LIMIT 100;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
snapshots = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=snapshots)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
|
|
@app.get("/api/miners/{miner_id}/configured-performance-mode")
|
|
def get_miner_configured_performance_mode(miner_id: str, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
WITH latest AS (
|
|
SELECT
|
|
COALESCE(
|
|
raw_summary_json #>> '{Msg,Power Mode}',
|
|
raw_summary_json #>> '{MSG,Power Mode}',
|
|
raw_summary_json #>> '{msg,Power Mode}',
|
|
raw_summary_json ->> 'Power Mode',
|
|
raw_summary_json ->> 'PowerMode',
|
|
raw_summary_json ->> 'power_mode'
|
|
) AS runtime_power_state,
|
|
created_at AS runtime_power_state_at
|
|
FROM miner_status_snapshots
|
|
WHERE miner_id = %s
|
|
ORDER BY observed_at DESC
|
|
LIMIT 1
|
|
)
|
|
SELECT
|
|
miners.id::text AS miner_id,
|
|
INITCAP(miners.configured_performance_mode) AS configured_performance_mode,
|
|
miners.configured_performance_mode_source,
|
|
miners.configured_performance_mode_updated_at,
|
|
latest.runtime_power_state,
|
|
latest.runtime_power_state_at
|
|
FROM miners
|
|
LEFT JOIN latest ON true
|
|
WHERE miners.id = %s;
|
|
""",
|
|
(miner_id, miner_id),
|
|
)
|
|
row = cur.fetchone()
|
|
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="Miner not found.")
|
|
|
|
return api_response(data=dict(row))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
class DiscoverOneMinerRequest(BaseModel):
|
|
ip_address: str
|
|
port: int = Field(default=4028, ge=1, le=65535)
|
|
scan_range_id: str | None = None
|
|
timeout_ms: int = Field(default=5000, ge=500, le=30000)
|
|
|
|
|
|
def clean_ip_text(value: Any) -> str | None:
|
|
if value is None:
|
|
return None
|
|
return str(value).split("/")[0]
|
|
|
|
|
|
def to_bool_or_none(value: Any) -> bool | None:
|
|
if value is None:
|
|
return None
|
|
|
|
if isinstance(value, bool):
|
|
return value
|
|
|
|
text = str(value).strip().lower()
|
|
|
|
if text in ("true", "1", "yes", "y", "on"):
|
|
return True
|
|
|
|
if text in ("false", "0", "no", "n", "off"):
|
|
return False
|
|
|
|
return None
|
|
|
|
|
|
def get_msg_dict(result: dict, command_name: str) -> dict:
|
|
if not result.get("success"):
|
|
raise HTTPException(status_code=502, detail=f"{command_name} failed: {result}")
|
|
|
|
parsed_json = result.get("parsed_json")
|
|
|
|
if not isinstance(parsed_json, dict):
|
|
raise HTTPException(status_code=502, detail=f"{command_name} did not return JSON")
|
|
|
|
if parsed_json.get("STATUS") == "E":
|
|
raise HTTPException(status_code=502, detail=f"{command_name} returned error: {parsed_json}")
|
|
|
|
msg = parsed_json.get("Msg", {})
|
|
|
|
if isinstance(msg, dict):
|
|
return msg
|
|
|
|
return {}
|
|
|
|
|
|
def find_scan_range_context(cur, ip_address: str, scan_range_id: str | None) -> dict:
|
|
if scan_range_id:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
scan_ranges.id::text AS scan_range_id,
|
|
scan_ranges.room_id::text,
|
|
rooms.site_id::text,
|
|
rooms.name AS room_name,
|
|
scan_ranges.name AS scan_range_name
|
|
FROM scan_ranges
|
|
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
|
|
WHERE scan_ranges.id = %s;
|
|
""",
|
|
(scan_range_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
|
|
if row:
|
|
return dict(row)
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
scan_ranges.id::text AS scan_range_id,
|
|
scan_ranges.room_id::text,
|
|
rooms.site_id::text,
|
|
rooms.name AS room_name,
|
|
scan_ranges.name AS scan_range_name,
|
|
scan_ranges.range_type,
|
|
scan_ranges.cidr,
|
|
host(scan_ranges.start_ip) AS start_ip,
|
|
scan_ranges.end_ip::text
|
|
FROM scan_ranges
|
|
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
|
|
WHERE scan_ranges.enabled = true;
|
|
"""
|
|
)
|
|
|
|
ip_obj = ipaddress.ip_address(ip_address)
|
|
|
|
for row in cur.fetchall():
|
|
row_dict = dict(row)
|
|
|
|
if row_dict["range_type"] == "cidr" and row_dict["cidr"]:
|
|
network = ipaddress.ip_network(row_dict["cidr"], strict=False)
|
|
if ip_obj in network:
|
|
return row_dict
|
|
|
|
if row_dict["range_type"] == "start_end" and row_dict["start_ip"] and row_dict["end_ip"]:
|
|
start_ip = ipaddress.ip_address(clean_ip_text(row_dict["start_ip"]))
|
|
end_ip = ipaddress.ip_address(clean_ip_text(row_dict["end_ip"]))
|
|
|
|
if int(start_ip) <= int(ip_obj) <= int(end_ip):
|
|
return row_dict
|
|
|
|
return {
|
|
"scan_range_id": scan_range_id,
|
|
"room_id": None,
|
|
"site_id": None,
|
|
"room_name": None,
|
|
"scan_range_name": None,
|
|
}
|
|
|
|
|
|
def find_existing_miner(cur, miner_sn: str | None, mac_address: str | None, ip_address: str) -> dict | None:
|
|
if miner_sn:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text
|
|
FROM miners
|
|
WHERE miner_sn = %s
|
|
LIMIT 1;
|
|
""",
|
|
(miner_sn,),
|
|
)
|
|
row = cur.fetchone()
|
|
if row:
|
|
return dict(row)
|
|
|
|
if mac_address:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text
|
|
FROM miners
|
|
WHERE lower(current_mac) = lower(%s)
|
|
LIMIT 1;
|
|
""",
|
|
(mac_address,),
|
|
)
|
|
row = cur.fetchone()
|
|
if row:
|
|
return dict(row)
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text
|
|
FROM miners
|
|
WHERE current_ip = %s
|
|
LIMIT 1;
|
|
""",
|
|
(ip_address,),
|
|
)
|
|
row = cur.fetchone()
|
|
|
|
if row:
|
|
return dict(row)
|
|
|
|
return None
|
|
|
|
|
|
@app.post("/api/miners/discover-one")
|
|
def discover_one_miner(request: DiscoverOneMinerRequest, current_user: dict = Depends(get_current_user)):
|
|
try:
|
|
ip_address = str(ipaddress.ip_address(request.ip_address))
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail=f"Invalid IP address: {exc}")
|
|
|
|
miner_info_result = send_whatsminer_read_command(
|
|
ip_address=ip_address,
|
|
port=request.port,
|
|
command="get_miner_info",
|
|
timeout_ms=request.timeout_ms,
|
|
params={
|
|
"info": "ip,proto,netmask,gateway,dns,hostname,mac,ledstat,minersn,powersn"
|
|
},
|
|
)
|
|
|
|
version_result = send_whatsminer_read_command(
|
|
ip_address=ip_address,
|
|
port=request.port,
|
|
command="get_version",
|
|
timeout_ms=request.timeout_ms,
|
|
)
|
|
|
|
summary_result = send_whatsminer_read_command(
|
|
ip_address=ip_address,
|
|
port=request.port,
|
|
command="summary",
|
|
timeout_ms=request.timeout_ms,
|
|
)
|
|
|
|
edevs_result = send_whatsminer_read_command(
|
|
ip_address=ip_address,
|
|
port=request.port,
|
|
command="edevs",
|
|
timeout_ms=request.timeout_ms,
|
|
)
|
|
|
|
pools_result = send_whatsminer_read_command(
|
|
ip_address=ip_address,
|
|
port=request.port,
|
|
command="pools",
|
|
timeout_ms=request.timeout_ms,
|
|
)
|
|
|
|
miner_info_json = miner_info_result.get("parsed_json") or {}
|
|
version_json = version_result.get("parsed_json") or {}
|
|
summary_json = summary_result.get("parsed_json") or {}
|
|
edevs_json = edevs_result.get("parsed_json") or {}
|
|
pools_json = pools_result.get("parsed_json") or {}
|
|
|
|
miner_info_msg = get_msg_dict(miner_info_result, "get_miner_info")
|
|
version_msg = get_msg_dict(version_result, "get_version")
|
|
try:
|
|
summary_msg = get_msg_dict(summary_result, "summary")
|
|
summary_warning = None
|
|
except HTTPException as exc:
|
|
summary_msg = {}
|
|
summary_warning = exc.detail
|
|
|
|
mac_address = miner_info_msg.get("mac")
|
|
if mac_address:
|
|
mac_address = str(mac_address).upper()
|
|
|
|
hostname = miner_info_msg.get("hostname")
|
|
miner_sn = miner_info_msg.get("minersn")
|
|
power_sn = miner_info_msg.get("powersn")
|
|
|
|
miner_type = version_msg.get("miner_type")
|
|
model = miner_type.split("_")[0] if miner_type else None
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
context = find_scan_range_context(cur, ip_address, request.scan_range_id)
|
|
|
|
existing_miner = find_existing_miner(
|
|
cur=cur,
|
|
miner_sn=miner_sn,
|
|
mac_address=mac_address,
|
|
ip_address=ip_address,
|
|
)
|
|
|
|
if existing_miner:
|
|
miner_id = existing_miner["id"]
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE miners
|
|
SET site_id = COALESCE(%s, site_id),
|
|
room_id = COALESCE(%s, room_id),
|
|
current_mac = %s,
|
|
current_ip = %s,
|
|
hostname = %s,
|
|
miner_sn = %s,
|
|
power_sn = %s,
|
|
model = %s,
|
|
miner_type = %s,
|
|
api_version = %s,
|
|
firmware_version = %s,
|
|
platform = %s,
|
|
chip = %s,
|
|
status = 'online',
|
|
last_seen_at = now(),
|
|
updated_at = now()
|
|
WHERE id = %s
|
|
RETURNING id::text;
|
|
""",
|
|
(
|
|
context.get("site_id"),
|
|
context.get("room_id"),
|
|
mac_address,
|
|
ip_address,
|
|
hostname,
|
|
miner_sn,
|
|
power_sn,
|
|
model,
|
|
miner_type,
|
|
version_msg.get("api_ver"),
|
|
version_msg.get("fw_ver"),
|
|
version_msg.get("platform"),
|
|
version_msg.get("chip"),
|
|
miner_id,
|
|
),
|
|
)
|
|
miner_id = cur.fetchone()["id"]
|
|
action = "miner.updated"
|
|
|
|
else:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO miners (
|
|
site_id,
|
|
room_id,
|
|
current_mac,
|
|
current_ip,
|
|
hostname,
|
|
miner_sn,
|
|
power_sn,
|
|
model,
|
|
miner_type,
|
|
api_version,
|
|
firmware_version,
|
|
platform,
|
|
chip,
|
|
status
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'online')
|
|
RETURNING id::text;
|
|
""",
|
|
(
|
|
context.get("site_id"),
|
|
context.get("room_id"),
|
|
mac_address,
|
|
ip_address,
|
|
hostname,
|
|
miner_sn,
|
|
power_sn,
|
|
model,
|
|
miner_type,
|
|
version_msg.get("api_ver"),
|
|
version_msg.get("fw_ver"),
|
|
version_msg.get("platform"),
|
|
version_msg.get("chip"),
|
|
),
|
|
)
|
|
miner_id = cur.fetchone()["id"]
|
|
action = "miner.created"
|
|
|
|
if mac_address:
|
|
cur.execute(
|
|
"""
|
|
UPDATE miner_network_identities
|
|
SET is_current = false,
|
|
updated_at = now()
|
|
WHERE miner_id = %s;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text
|
|
FROM miner_network_identities
|
|
WHERE miner_id = %s
|
|
AND lower(mac_address) = lower(%s)
|
|
LIMIT 1;
|
|
""",
|
|
(miner_id, mac_address),
|
|
)
|
|
identity = cur.fetchone()
|
|
|
|
if identity:
|
|
cur.execute(
|
|
"""
|
|
UPDATE miner_network_identities
|
|
SET ip_address = %s,
|
|
hostname = %s,
|
|
source = 'scanner',
|
|
is_current = true,
|
|
last_seen_at = now(),
|
|
updated_at = now()
|
|
WHERE id = %s;
|
|
""",
|
|
(ip_address, hostname, identity["id"]),
|
|
)
|
|
else:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO miner_network_identities (
|
|
miner_id,
|
|
mac_address,
|
|
ip_address,
|
|
hostname,
|
|
source,
|
|
is_current
|
|
)
|
|
VALUES (%s, %s, %s, %s, 'scanner', true);
|
|
""",
|
|
(miner_id, mac_address, ip_address, hostname),
|
|
)
|
|
|
|
for board in edevs_json.get("DEVS", []):
|
|
slot = board.get("Slot")
|
|
|
|
if slot is None:
|
|
continue
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO miner_hashboards (
|
|
miner_id,
|
|
slot,
|
|
pcb_sn,
|
|
chip_data,
|
|
temperature_c,
|
|
chip_frequency,
|
|
mhs_av,
|
|
mhs_1m,
|
|
mhs_15m,
|
|
hs_rt,
|
|
factory_ghs,
|
|
effective_chips,
|
|
upfreq_complete,
|
|
raw_json
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
ON CONFLICT ON CONSTRAINT uq_miner_hashboards_miner_slot
|
|
DO UPDATE SET
|
|
pcb_sn = EXCLUDED.pcb_sn,
|
|
chip_data = EXCLUDED.chip_data,
|
|
temperature_c = EXCLUDED.temperature_c,
|
|
chip_frequency = EXCLUDED.chip_frequency,
|
|
mhs_av = EXCLUDED.mhs_av,
|
|
mhs_1m = EXCLUDED.mhs_1m,
|
|
mhs_15m = EXCLUDED.mhs_15m,
|
|
hs_rt = EXCLUDED.hs_rt,
|
|
factory_ghs = EXCLUDED.factory_ghs,
|
|
effective_chips = EXCLUDED.effective_chips,
|
|
upfreq_complete = EXCLUDED.upfreq_complete,
|
|
raw_json = EXCLUDED.raw_json,
|
|
last_seen_at = now(),
|
|
updated_at = now();
|
|
""",
|
|
(
|
|
miner_id,
|
|
int(slot),
|
|
board.get("PCB SN"),
|
|
board.get("Chip Data"),
|
|
board.get("Temperature"),
|
|
board.get("Chip Frequency"),
|
|
board.get("MHS av"),
|
|
board.get("MHS 1m"),
|
|
board.get("MHS 15m"),
|
|
board.get("HS RT"),
|
|
board.get("Factory GHS"),
|
|
board.get("Effective Chips"),
|
|
board.get("Upfreq Complete"),
|
|
psycopg.types.json.Jsonb(json_safe(board)),
|
|
),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO miner_status_snapshots (
|
|
miner_id,
|
|
observed_ip,
|
|
mhs_av,
|
|
mhs_1m,
|
|
mhs_15m,
|
|
hs_rt,
|
|
power_w,
|
|
power_rate_w_per_th,
|
|
env_temp_c,
|
|
chip_temp_min_c,
|
|
chip_temp_max_c,
|
|
chip_temp_avg_c,
|
|
fan_speed_in_rpm,
|
|
fan_speed_out_rpm,
|
|
uptime_seconds,
|
|
hash_stable,
|
|
pool_rejected_percent,
|
|
raw_summary_json,
|
|
raw_version_json,
|
|
raw_miner_info_json
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);
|
|
""",
|
|
(
|
|
miner_id,
|
|
ip_address,
|
|
summary_msg.get("MHS av"),
|
|
summary_msg.get("MHS 1m"),
|
|
summary_msg.get("MHS 15m"),
|
|
summary_msg.get("HS RT"),
|
|
summary_msg.get("Power"),
|
|
summary_msg.get("Power Rate"),
|
|
summary_msg.get("Env Temp"),
|
|
summary_msg.get("Chip Temp Min"),
|
|
summary_msg.get("Chip Temp Max"),
|
|
summary_msg.get("Chip Temp Avg"),
|
|
summary_msg.get("Fan Speed In"),
|
|
summary_msg.get("Fan Speed Out"),
|
|
summary_msg.get("Uptime"),
|
|
to_bool_or_none(summary_msg.get("Hash Stable")),
|
|
summary_msg.get("Pool Rejected%"),
|
|
psycopg.types.json.Jsonb(json_safe(summary_json)),
|
|
psycopg.types.json.Jsonb(json_safe(version_json)),
|
|
psycopg.types.json.Jsonb(json_safe(miner_info_json)),
|
|
),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
|
VALUES (%s, %s, 'miner', %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
action,
|
|
miner_id,
|
|
psycopg.types.json.Jsonb(
|
|
{
|
|
"ip_address": ip_address,
|
|
"mac_address": mac_address,
|
|
"miner_sn": miner_sn,
|
|
"power_sn": power_sn,
|
|
"miner_type": miner_type,
|
|
"firmware_version": version_msg.get("fw_ver"),
|
|
"room_id": context.get("room_id"),
|
|
"scan_range_id": context.get("scan_range_id"),
|
|
}
|
|
),
|
|
),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
miners.id::text,
|
|
miners.site_id::text,
|
|
sites.name AS site_name,
|
|
miners.room_id::text,
|
|
rooms.name AS room_name,
|
|
miners.current_mac,
|
|
host(miners.current_ip) AS current_ip,
|
|
miners.hostname,
|
|
miners.miner_sn,
|
|
miners.power_sn,
|
|
miners.model,
|
|
miners.miner_type,
|
|
miners.api_version,
|
|
miners.firmware_version,
|
|
miners.platform,
|
|
miners.chip,
|
|
miners.status,
|
|
miners.first_seen_at,
|
|
miners.last_seen_at,
|
|
miners.updated_at
|
|
FROM miners
|
|
LEFT JOIN sites ON sites.id = miners.site_id
|
|
LEFT JOIN rooms ON rooms.id = miners.room_id
|
|
WHERE miners.id = %s;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
miner = dict(cur.fetchone())
|
|
|
|
conn.commit()
|
|
|
|
return api_response(
|
|
data={
|
|
"action": action,
|
|
"miner": miner,
|
|
"summary": {
|
|
"ip_address": ip_address,
|
|
"mac_address": mac_address,
|
|
"miner_sn": miner_sn,
|
|
"power_sn": power_sn,
|
|
"miner_type": miner_type,
|
|
"firmware_version": version_msg.get("fw_ver"),
|
|
"hashboards_seen": len(edevs_json.get("DEVS", [])),
|
|
"pools_seen": len(pools_json.get("POOLS", [])),
|
|
"hashrate_mhs_av": summary_msg.get("MHS av"),
|
|
"power_w": summary_msg.get("Power"),
|
|
"chip_temp_max_c": summary_msg.get("Chip Temp Max"),
|
|
"summary_warning": summary_warning,
|
|
},
|
|
}
|
|
)
|
|
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
@app.post("/api/scan-ranges/{scan_range_id}/discover-now")
|
|
def discover_scan_range_now(scan_range_id: str, request: ScanNowRequest, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
room_id::text,
|
|
name,
|
|
range_type,
|
|
cidr,
|
|
host(start_ip) AS start_ip,
|
|
host(end_ip) AS end_ip,
|
|
enabled,
|
|
timeout_ms,
|
|
retry_limit,
|
|
parallelism_limit,
|
|
stagger_ms,
|
|
api_port,
|
|
scan_interval_seconds
|
|
FROM scan_ranges
|
|
WHERE id = %s;
|
|
""",
|
|
(scan_range_id,),
|
|
)
|
|
scan_range = cur.fetchone()
|
|
|
|
if not scan_range:
|
|
raise HTTPException(status_code=404, detail="Scan range not found")
|
|
|
|
if not scan_range["enabled"]:
|
|
raise HTTPException(status_code=409, detail="Scan range is disabled")
|
|
|
|
ip_list = expand_scan_range_for_scan(dict(scan_range), request.max_ips, request.specific_ips)
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO scan_runs (
|
|
scan_range_id,
|
|
triggered_by,
|
|
triggered_by_user_id,
|
|
status,
|
|
total_ips,
|
|
scanned_ips,
|
|
miners_found,
|
|
errors_count,
|
|
notes
|
|
)
|
|
VALUES (%s, 'manual', %s, 'running', %s, 0, 0, 0, %s)
|
|
RETURNING id::text;
|
|
""",
|
|
(
|
|
scan_range_id,
|
|
current_user["id"],
|
|
len(ip_list),
|
|
"TCP port 4028 scan with read-only miner discovery",
|
|
),
|
|
)
|
|
scan_run_id = cur.fetchone()["id"]
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
|
VALUES (%s, 'scan.discovery.started', 'scan_run', %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
scan_run_id,
|
|
psycopg.types.json.Jsonb(
|
|
{
|
|
"scan_range_id": scan_range_id,
|
|
"scan_range_name": scan_range["name"],
|
|
"total_ips": len(ip_list),
|
|
"mode": "tcp_4028_then_whatsminer_read_only_discovery",
|
|
}
|
|
),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
results = []
|
|
detected_count = 0
|
|
|
|
max_workers = min(scan_range["parallelism_limit"], len(ip_list))
|
|
if max_workers < 1:
|
|
max_workers = 1
|
|
|
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
future_map = {
|
|
executor.submit(
|
|
tcp_check_ip,
|
|
ip,
|
|
scan_range["api_port"],
|
|
scan_range["timeout_ms"],
|
|
): ip
|
|
for ip in ip_list
|
|
}
|
|
|
|
for future in as_completed(future_map):
|
|
result = future.result()
|
|
results.append(result)
|
|
|
|
if result["detected"]:
|
|
detected_count += 1
|
|
|
|
results = sorted(results, key=lambda item: ipaddress.ip_address(item["ip_address"]))
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
for result in results:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO scan_results (
|
|
scan_run_id,
|
|
ip_address,
|
|
port,
|
|
detected,
|
|
response_ms,
|
|
status,
|
|
error_message
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s);
|
|
""",
|
|
(
|
|
scan_run_id,
|
|
result["ip_address"],
|
|
result["port"],
|
|
result["detected"],
|
|
result["response_ms"],
|
|
result["status"],
|
|
result["error_message"],
|
|
),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE scan_ranges
|
|
SET last_scan_at = now(),
|
|
next_scan_at = now() + make_interval(secs => scan_interval_seconds),
|
|
updated_at = now()
|
|
WHERE id = %s;
|
|
""",
|
|
(scan_range_id,),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE scan_runs
|
|
SET scanned_ips = %s,
|
|
miners_found = %s,
|
|
errors_count = 0
|
|
WHERE id = %s;
|
|
""",
|
|
(
|
|
len(results),
|
|
detected_count,
|
|
scan_run_id,
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
discoveries = []
|
|
discovery_errors = []
|
|
|
|
for result in results:
|
|
if not result["detected"]:
|
|
continue
|
|
|
|
try:
|
|
discovery_response = discover_one_miner(
|
|
DiscoverOneMinerRequest(
|
|
ip_address=result["ip_address"],
|
|
port=result["port"],
|
|
scan_range_id=scan_range_id,
|
|
timeout_ms=scan_range["timeout_ms"],
|
|
),
|
|
current_user=current_user,
|
|
)
|
|
|
|
discoveries.append(discovery_response["data"])
|
|
|
|
except HTTPException as exc:
|
|
discovery_errors.append(
|
|
{
|
|
"ip_address": result["ip_address"],
|
|
"status_code": exc.status_code,
|
|
"detail": exc.detail,
|
|
}
|
|
)
|
|
|
|
except Exception as exc:
|
|
discovery_errors.append(
|
|
{
|
|
"ip_address": result["ip_address"],
|
|
"status_code": 500,
|
|
"detail": str(exc),
|
|
}
|
|
)
|
|
|
|
final_status = "completed"
|
|
if discovery_errors:
|
|
final_status = "completed_with_errors"
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
UPDATE scan_runs
|
|
SET finished_at = now(),
|
|
status = %s,
|
|
miners_found = %s,
|
|
errors_count = %s,
|
|
notes = %s
|
|
WHERE id = %s
|
|
RETURNING
|
|
id::text,
|
|
scan_range_id::text,
|
|
triggered_by,
|
|
started_at,
|
|
finished_at,
|
|
status,
|
|
total_ips,
|
|
scanned_ips,
|
|
miners_found,
|
|
errors_count,
|
|
notes;
|
|
""",
|
|
(
|
|
final_status,
|
|
len(discoveries),
|
|
len(discovery_errors),
|
|
"TCP port 4028 scan with read-only miner discovery",
|
|
scan_run_id,
|
|
),
|
|
)
|
|
scan_run = cur.fetchone()
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
|
VALUES (%s, 'scan.discovery.completed', 'scan_run', %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
scan_run_id,
|
|
psycopg.types.json.Jsonb(
|
|
{
|
|
"scan_range_id": scan_range_id,
|
|
"total_ips": len(ip_list),
|
|
"scanned_ips": len(results),
|
|
"port_4028_open": detected_count,
|
|
"miners_discovered": len(discoveries),
|
|
"discovery_errors": len(discovery_errors),
|
|
}
|
|
),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
return api_response(
|
|
data={
|
|
"scan_run": dict(scan_run),
|
|
"summary": {
|
|
"scan_range_id": scan_range_id,
|
|
"scan_range_name": scan_range["name"],
|
|
"total_ips": len(ip_list),
|
|
"scanned_ips": len(results),
|
|
"port_4028_open": detected_count,
|
|
"miners_discovered": len(discoveries),
|
|
"discovery_errors": len(discovery_errors),
|
|
},
|
|
"discoveries": discoveries,
|
|
"discovery_errors": discovery_errors,
|
|
}
|
|
)
|
|
|
|
@app.get("/api/dashboard/summary")
|
|
def dashboard_summary(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
COUNT(*) AS total_miners,
|
|
COUNT(*) FILTER (WHERE status = 'online') AS online_miners,
|
|
COUNT(*) FILTER (WHERE current_mac IS NULL OR current_mac = '') AS missing_mac_count,
|
|
COUNT(*) FILTER (WHERE miner_type IS NULL OR miner_type = '') AS missing_miner_type_count,
|
|
COUNT(*) FILTER (WHERE firmware_version IS NULL OR firmware_version = '') AS missing_firmware_count,
|
|
COUNT(*) FILTER (WHERE last_seen_at >= now() - interval '30 minutes') AS seen_last_30_minutes,
|
|
COUNT(*) FILTER (WHERE last_seen_at < now() - interval '30 minutes') AS not_seen_last_30_minutes
|
|
FROM miners;
|
|
"""
|
|
)
|
|
totals = dict(cur.fetchone())
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
COALESCE(rooms.name, 'Unassigned') AS room_name,
|
|
COUNT(miners.id) AS miner_count,
|
|
COUNT(miners.id) FILTER (WHERE miners.status = 'online') AS online_count,
|
|
COUNT(miners.id) FILTER (WHERE miners.last_seen_at >= now() - interval '30 minutes') AS seen_last_30_minutes
|
|
FROM miners
|
|
LEFT JOIN rooms ON rooms.id = miners.room_id
|
|
GROUP BY rooms.name, rooms.sort_order
|
|
ORDER BY rooms.sort_order, rooms.name;
|
|
"""
|
|
)
|
|
by_room = [dict(row) for row in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
COALESCE(NULLIF(miner_type, ''), NULLIF(model, ''), 'Unknown') AS model,
|
|
COUNT(*) AS miner_count
|
|
FROM miners
|
|
GROUP BY COALESCE(NULLIF(miner_type, ''), NULLIF(model, ''), 'Unknown')
|
|
ORDER BY miner_count DESC, model;
|
|
"""
|
|
)
|
|
by_model = [dict(row) for row in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
COALESCE(NULLIF(firmware_version, ''), 'Unknown') AS firmware_version,
|
|
COUNT(*) AS miner_count
|
|
FROM miners
|
|
GROUP BY COALESCE(NULLIF(firmware_version, ''), 'Unknown')
|
|
ORDER BY miner_count DESC, firmware_version;
|
|
"""
|
|
)
|
|
by_firmware = [dict(row) for row in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
COALESCE(NULLIF(status, ''), 'Unknown') AS status,
|
|
COUNT(*) AS miner_count
|
|
FROM miners
|
|
GROUP BY COALESCE(NULLIF(status, ''), 'Unknown')
|
|
ORDER BY miner_count DESC, status;
|
|
"""
|
|
)
|
|
by_status = [dict(row) for row in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
WITH latest AS (
|
|
SELECT DISTINCT ON (miner_id)
|
|
miner_id,
|
|
observed_at,
|
|
power_w,
|
|
power_rate_w_per_th,
|
|
chip_temp_max_c,
|
|
chip_temp_avg_c,
|
|
mhs_av,
|
|
hash_stable
|
|
FROM miner_status_snapshots
|
|
ORDER BY miner_id, observed_at DESC
|
|
)
|
|
SELECT
|
|
COUNT(*) AS miners_with_snapshots,
|
|
ROUND(AVG(power_w)::numeric, 2)::float AS avg_power_w,
|
|
ROUND(SUM(power_w)::numeric, 2)::float AS total_power_w,
|
|
ROUND(AVG(power_rate_w_per_th)::numeric, 2)::float AS avg_power_rate_w_per_th,
|
|
ROUND(MAX(chip_temp_max_c)::numeric, 2)::float AS hottest_chip_temp_c,
|
|
ROUND(AVG(chip_temp_avg_c)::numeric, 2)::float AS avg_chip_temp_c,
|
|
ROUND(SUM(mhs_av)::numeric / 1000000, 2)::float AS total_hashrate_ths,
|
|
COUNT(*) FILTER (WHERE chip_temp_max_c >= 90) AS miners_chip_temp_90c_or_higher,
|
|
COUNT(*) FILTER (WHERE hash_stable = false) AS miners_hash_unstable
|
|
FROM latest;
|
|
"""
|
|
)
|
|
telemetry = dict(cur.fetchone())
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
COUNT(DISTINCT miner_id) AS miners_with_hashboards,
|
|
COUNT(*) AS total_hashboards,
|
|
COUNT(*) FILTER (WHERE temperature_c >= 90) AS hashboards_90c_or_higher,
|
|
COUNT(*) FILTER (WHERE pcb_sn IS NULL OR pcb_sn = '') AS hashboards_missing_pcb_sn
|
|
FROM miner_hashboards;
|
|
"""
|
|
)
|
|
hashboards = dict(cur.fetchone())
|
|
|
|
return api_response(
|
|
data={
|
|
"totals": totals,
|
|
"by_room": by_room,
|
|
"by_model": by_model,
|
|
"by_firmware": by_firmware,
|
|
"by_status": by_status,
|
|
"telemetry": telemetry,
|
|
"hashboards": hashboards,
|
|
}
|
|
)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
from fastapi import File, Query, UploadFile
|
|
import csv
|
|
import io
|
|
|
|
|
|
def csv_value(row: dict, possible_names: list[str]) -> str | None:
|
|
for name in possible_names:
|
|
if name in row and row[name] is not None:
|
|
value = str(row[name]).strip()
|
|
if value:
|
|
return value
|
|
return None
|
|
|
|
|
|
def normalize_mac_for_match(mac: str | None) -> str | None:
|
|
if not mac:
|
|
return None
|
|
|
|
cleaned = "".join(ch for ch in mac.lower() if ch in "0123456789abcdef")
|
|
|
|
if len(cleaned) != 12:
|
|
return None
|
|
|
|
return cleaned
|
|
|
|
|
|
def model_from_miner_type(miner_type: str | None) -> str | None:
|
|
if not miner_type:
|
|
return None
|
|
|
|
if "_" in miner_type:
|
|
return miner_type.split("_", 1)[0]
|
|
|
|
return miner_type
|
|
|
|
|
|
@app.post("/api/imports/whatsminer-tool-scan")
|
|
async def import_whatsminer_tool_scan(
|
|
dry_run: bool = Query(default=True),
|
|
update_existing: bool = Query(default=False),
|
|
file: UploadFile = File(...),
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
file_bytes = await file.read()
|
|
|
|
try:
|
|
csv_text = file_bytes.decode("utf-8-sig")
|
|
except UnicodeDecodeError:
|
|
csv_text = file_bytes.decode("latin-1")
|
|
|
|
reader = csv.DictReader(io.StringIO(csv_text))
|
|
rows = list(reader)
|
|
|
|
if not rows:
|
|
raise HTTPException(status_code=422, detail="CSV file has no rows")
|
|
|
|
matched_count = 0
|
|
unmatched_count = 0
|
|
rows_with_updates = 0
|
|
updates_applied = 0
|
|
skipped_no_useful_data = 0
|
|
|
|
update_samples = []
|
|
unmatched_samples = []
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
for row_number, row in enumerate(rows, start=2):
|
|
ip_address = csv_value(row, ["IP", "IP Address", "ip", "ip_address"])
|
|
mac_address = csv_value(row, ["MAC Addr", "MAC", "Mac", "mac", "mac_address"])
|
|
miner_type = csv_value(row, ["Miner Type", "MinerType", "Model", "model", "miner_type"])
|
|
firmware_version = csv_value(row, ["Firmware", "Firmware Version", "FW Version", "fw_ver", "firmware_version"])
|
|
|
|
if ip_address:
|
|
ip_address = clean_ip_text(ip_address)
|
|
|
|
normalized_mac = normalize_mac_for_match(mac_address)
|
|
model = model_from_miner_type(miner_type)
|
|
|
|
if not ip_address and not normalized_mac:
|
|
skipped_no_useful_data += 1
|
|
continue
|
|
|
|
miner = None
|
|
|
|
if normalized_mac:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
host(current_ip) AS current_ip,
|
|
current_mac,
|
|
model,
|
|
miner_type,
|
|
firmware_version
|
|
FROM miners
|
|
WHERE regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g') = %s
|
|
LIMIT 1;
|
|
""",
|
|
(normalized_mac,),
|
|
)
|
|
miner = cur.fetchone()
|
|
|
|
if not miner and ip_address:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
host(current_ip) AS current_ip,
|
|
current_mac,
|
|
model,
|
|
miner_type,
|
|
firmware_version
|
|
FROM miners
|
|
WHERE host(current_ip) = %s
|
|
LIMIT 1;
|
|
""",
|
|
(ip_address,),
|
|
)
|
|
miner = cur.fetchone()
|
|
|
|
if not miner:
|
|
unmatched_count += 1
|
|
|
|
if len(unmatched_samples) < 20:
|
|
unmatched_samples.append(
|
|
{
|
|
"row_number": row_number,
|
|
"ip_address": ip_address,
|
|
"mac_address": mac_address,
|
|
"miner_type": miner_type,
|
|
"firmware_version": firmware_version,
|
|
}
|
|
)
|
|
|
|
continue
|
|
|
|
matched_count += 1
|
|
|
|
set_clauses = []
|
|
params = []
|
|
proposed_changes = {}
|
|
|
|
if miner_type and (update_existing or not miner["miner_type"]):
|
|
set_clauses.append("miner_type = %s")
|
|
params.append(miner_type)
|
|
proposed_changes["miner_type"] = {
|
|
"old": miner["miner_type"],
|
|
"new": miner_type,
|
|
}
|
|
|
|
if model and (update_existing or not miner["model"]):
|
|
set_clauses.append("model = %s")
|
|
params.append(model)
|
|
proposed_changes["model"] = {
|
|
"old": miner["model"],
|
|
"new": model,
|
|
}
|
|
|
|
if firmware_version and (update_existing or not miner["firmware_version"]):
|
|
set_clauses.append("firmware_version = %s")
|
|
params.append(firmware_version)
|
|
proposed_changes["firmware_version"] = {
|
|
"old": miner["firmware_version"],
|
|
"new": firmware_version,
|
|
}
|
|
|
|
if not set_clauses:
|
|
continue
|
|
|
|
rows_with_updates += 1
|
|
|
|
if len(update_samples) < 50:
|
|
update_samples.append(
|
|
{
|
|
"row_number": row_number,
|
|
"miner_id": miner["id"],
|
|
"ip_address": miner["current_ip"],
|
|
"current_mac": miner["current_mac"],
|
|
"changes": proposed_changes,
|
|
}
|
|
)
|
|
|
|
if not dry_run:
|
|
set_sql = ", ".join(set_clauses)
|
|
params.append(miner["id"])
|
|
|
|
cur.execute(
|
|
f"""
|
|
UPDATE miners
|
|
SET {set_sql},
|
|
updated_at = now()
|
|
WHERE id = %s;
|
|
""",
|
|
tuple(params),
|
|
)
|
|
|
|
updates_applied += 1
|
|
|
|
if not dry_run:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
|
VALUES (%s, 'import.whatsminertool_csv.applied', 'import', NULL, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
psycopg.types.json.Jsonb(
|
|
{
|
|
"filename": file.filename,
|
|
"rows_read": len(rows),
|
|
"matched_count": matched_count,
|
|
"unmatched_count": unmatched_count,
|
|
"rows_with_updates": rows_with_updates,
|
|
"updates_applied": updates_applied,
|
|
"update_existing": update_existing,
|
|
}
|
|
),
|
|
),
|
|
)
|
|
|
|
if dry_run:
|
|
conn.rollback()
|
|
else:
|
|
conn.commit()
|
|
|
|
return api_response(
|
|
data={
|
|
"filename": file.filename,
|
|
"dry_run": dry_run,
|
|
"update_existing": update_existing,
|
|
"rows_read": len(rows),
|
|
"matched_count": matched_count,
|
|
"unmatched_count": unmatched_count,
|
|
"skipped_no_useful_data": skipped_no_useful_data,
|
|
"rows_with_updates": rows_with_updates,
|
|
"updates_applied": updates_applied,
|
|
"update_samples": update_samples,
|
|
"unmatched_samples": unmatched_samples,
|
|
}
|
|
)
|
|
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
from fastapi.responses import HTMLResponse, Response
|
|
|
|
|
|
@app.get("/tools/whatsminer-import", response_class=HTMLResponse)
|
|
def whatsminer_import_tool_page():
|
|
return """
|
|
<!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()
|
|
|
|
|
|
app.state.agent_command_before_claim = minerops_process_due_scheduled_bulk_actions
|
|
|
|
|
|
def minerops_reconcile_scheduled_bulk_action_statuses():
|
|
minerops_ensure_scheduled_bulk_actions_table()
|
|
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
s.id,
|
|
COUNT(q.id) AS total_commands,
|
|
COUNT(q.id) FILTER (
|
|
WHERE q.command_status NOT IN ('completed', 'failed', 'cancelled')
|
|
) AS active_commands,
|
|
COUNT(q.id) FILTER (WHERE q.command_status = 'completed') AS completed_commands,
|
|
COUNT(q.id) FILTER (WHERE q.command_status = 'failed') AS failed_commands,
|
|
COUNT(q.id) FILTER (WHERE q.command_status = 'cancelled') AS cancelled_commands
|
|
FROM minerops_scheduled_bulk_actions s
|
|
LEFT JOIN minerops_agent_command_queue q
|
|
ON q.request_payload ->> 'scheduled_action_id' = s.id
|
|
WHERE s.status IN ('queued', 'running')
|
|
GROUP BY s.id;
|
|
"""
|
|
)
|
|
rows = [dict(row) for row in cur.fetchall()]
|
|
|
|
for row in rows:
|
|
total = int(row.get("total_commands") or 0)
|
|
active = int(row.get("active_commands") or 0)
|
|
completed = int(row.get("completed_commands") or 0)
|
|
failed = int(row.get("failed_commands") or 0)
|
|
cancelled = int(row.get("cancelled_commands") or 0)
|
|
|
|
if total <= 0:
|
|
continue
|
|
|
|
if active > 0:
|
|
continue
|
|
|
|
if completed == total:
|
|
new_status = "completed"
|
|
last_error = None
|
|
else:
|
|
new_status = "completed_with_errors"
|
|
last_error = f"Completed with errors: {completed} completed, {failed} failed, {cancelled} cancelled."
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE minerops_scheduled_bulk_actions
|
|
SET status = %s,
|
|
processed_at = COALESCE(processed_at, now()),
|
|
updated_at = now(),
|
|
last_error = %s
|
|
WHERE id = %s
|
|
AND status IN ('queued', 'running');
|
|
""",
|
|
(new_status, last_error, row["id"]),
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
|
|
class M30CommandTestRequest(BaseModel):
|
|
miner_id: str
|
|
test_action: str
|
|
|
|
|
|
|
|
|
|
def minerops_ensure_pool_info_table():
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS miner_pool_snapshots (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
miner_id UUID,
|
|
ip_address INET,
|
|
mac_address TEXT,
|
|
model TEXT,
|
|
pool_index INTEGER,
|
|
url TEXT,
|
|
worker_user TEXT,
|
|
pool_status TEXT,
|
|
stratum_active BOOLEAN,
|
|
accepted DOUBLE PRECISION,
|
|
rejected DOUBLE PRECISION,
|
|
stale DOUBLE PRECISION,
|
|
get_failures DOUBLE PRECISION,
|
|
remote_failures DOUBLE PRECISION,
|
|
pool_rejected_percent DOUBLE PRECISION,
|
|
pool_stale_percent DOUBLE PRECISION,
|
|
last_share_time_epoch DOUBLE PRECISION,
|
|
raw_pool_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
agent_id TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
DO $$
|
|
DECLARE
|
|
id_type TEXT;
|
|
BEGIN
|
|
SELECT data_type INTO id_type
|
|
FROM information_schema.columns
|
|
WHERE table_schema = current_schema()
|
|
AND table_name = 'miner_pool_snapshots'
|
|
AND column_name = 'id';
|
|
|
|
IF id_type = 'text' THEN
|
|
ALTER TABLE miner_pool_snapshots
|
|
ALTER COLUMN id SET DEFAULT (gen_random_uuid())::text;
|
|
ELSIF id_type = 'uuid' THEN
|
|
ALTER TABLE miner_pool_snapshots
|
|
ALTER COLUMN id SET DEFAULT gen_random_uuid();
|
|
END IF;
|
|
END $$;
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_miner_created
|
|
ON miner_pool_snapshots (miner_id, created_at DESC);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_ip_created
|
|
ON miner_pool_snapshots (ip_address, created_at DESC);
|
|
"""
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/agent/pool-snapshots")
|
|
async def agent_pool_snapshots(
|
|
request: Request,
|
|
authenticated_agent_id: str = Depends(require_agent_identity),
|
|
zone_service: AgentZoneService = Depends(get_agent_zone_service),
|
|
):
|
|
import json as _json
|
|
import uuid as _uuid
|
|
|
|
routing = zone_service.routing_context(authenticated_agent_id)
|
|
if routing.routing_reason or routing.agent_zone_id is None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=(
|
|
"Agent routing unavailable: "
|
|
+ (routing.routing_reason or "agent_zone_unassigned")
|
|
),
|
|
)
|
|
|
|
body = await request.json()
|
|
|
|
agent_id = authenticated_agent_id
|
|
snapshots = body.get("pool_snapshots") or body.get("snapshots") or []
|
|
|
|
minerops_ensure_pool_info_table()
|
|
|
|
inserted = 0
|
|
matched = 0
|
|
|
|
conn = None
|
|
|
|
def fnum(value):
|
|
try:
|
|
if value is None or value == "":
|
|
return None
|
|
return float(value)
|
|
except Exception:
|
|
return None
|
|
|
|
def fint(value):
|
|
try:
|
|
if value is None or value == "":
|
|
return None
|
|
return int(float(value))
|
|
except Exception:
|
|
return None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
for snap in snapshots:
|
|
ip = (snap.get("ip_address") or snap.get("ip") or "").strip()
|
|
mac = (snap.get("mac_address") or snap.get("mac") or "").strip()
|
|
model = snap.get("model") or ""
|
|
pools = snap.get("pools") or []
|
|
|
|
if isinstance(pools, dict):
|
|
pools = [pools]
|
|
|
|
if not isinstance(pools, list):
|
|
pools = []
|
|
|
|
if not ip or not pools:
|
|
continue
|
|
|
|
miner_id = None
|
|
|
|
if mac:
|
|
cur.execute(
|
|
"""
|
|
SELECT id
|
|
FROM miners
|
|
WHERE agent_zone_id = %s
|
|
AND (
|
|
upper(current_mac) = upper(%s)
|
|
OR host(current_ip) = %s
|
|
)
|
|
LIMIT 1;
|
|
""",
|
|
(routing.agent_zone_id, mac, ip),
|
|
)
|
|
else:
|
|
cur.execute(
|
|
"""
|
|
SELECT id
|
|
FROM miners
|
|
WHERE agent_zone_id = %s
|
|
AND host(current_ip) = %s
|
|
LIMIT 1;
|
|
""",
|
|
(routing.agent_zone_id, ip),
|
|
)
|
|
|
|
row = cur.fetchone()
|
|
|
|
if not row:
|
|
continue
|
|
|
|
miner_id = row["id"]
|
|
matched += 1
|
|
|
|
for pool in pools:
|
|
if not isinstance(pool, dict):
|
|
continue
|
|
|
|
pool_index = fint(pool.get("POOL") or pool.get("pool") or pool.get("Pool"))
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO miner_pool_snapshots (
|
|
miner_id,
|
|
ip_address,
|
|
mac_address,
|
|
model,
|
|
pool_index,
|
|
url,
|
|
worker_user,
|
|
pool_status,
|
|
stratum_active,
|
|
accepted,
|
|
rejected,
|
|
stale,
|
|
get_failures,
|
|
remote_failures,
|
|
pool_rejected_percent,
|
|
pool_stale_percent,
|
|
last_share_time_epoch,
|
|
raw_pool_json,
|
|
agent_id
|
|
)
|
|
VALUES (
|
|
%s,
|
|
%s::inet,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s::jsonb,
|
|
%s
|
|
);
|
|
""",
|
|
(
|
|
miner_id,
|
|
ip,
|
|
mac,
|
|
model,
|
|
pool_index,
|
|
pool.get("URL") or pool.get("url"),
|
|
pool.get("User") or pool.get("user"),
|
|
pool.get("Status") or pool.get("status"),
|
|
pool.get("Stratum Active") if "Stratum Active" in pool else pool.get("stratum_active"),
|
|
fnum(pool.get("Accepted")),
|
|
fnum(pool.get("Rejected")),
|
|
fnum(pool.get("Stale")),
|
|
fnum(pool.get("Get Failures")),
|
|
fnum(pool.get("Remote Failures")),
|
|
fnum(pool.get("Pool Rejected%")),
|
|
fnum(pool.get("Pool Stale%")),
|
|
fnum(pool.get("Last Share Time")),
|
|
_json.dumps(pool),
|
|
agent_id,
|
|
),
|
|
)
|
|
|
|
inserted += 1
|
|
|
|
conn.commit()
|
|
|
|
return api_response(
|
|
data={
|
|
"inserted": inserted,
|
|
"matched": matched,
|
|
"received_miners": len(snapshots),
|
|
},
|
|
meta={"message": "Pool snapshots saved."},
|
|
)
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/dashboard/pool-info-latest")
|
|
def dashboard_pool_info_latest(current_user: dict = Depends(get_current_user)):
|
|
minerops_ensure_pool_info_table()
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
WITH latest_pool AS (
|
|
SELECT DISTINCT ON (miner_id, ip_address, pool_index)
|
|
*
|
|
FROM miner_pool_snapshots
|
|
ORDER BY miner_id, ip_address, pool_index, created_at DESC
|
|
)
|
|
SELECT
|
|
lp.id,
|
|
lp.miner_id::text AS miner_id,
|
|
rooms.name AS room_name,
|
|
host(COALESCE(m.current_ip, lp.ip_address)) AS current_ip,
|
|
COALESCE(m.current_mac, lp.mac_address) AS current_mac,
|
|
COALESCE(m.miner_type, m.model, lp.model) AS model,
|
|
lp.pool_index,
|
|
lp.url,
|
|
lp.worker_user,
|
|
lp.pool_status,
|
|
lp.stratum_active,
|
|
lp.accepted,
|
|
lp.rejected,
|
|
lp.stale,
|
|
lp.get_failures,
|
|
lp.remote_failures,
|
|
lp.pool_rejected_percent,
|
|
lp.pool_stale_percent,
|
|
to_timestamp(lp.last_share_time_epoch) AS last_share_time,
|
|
lp.created_at
|
|
FROM latest_pool lp
|
|
LEFT JOIN miners m ON m.id = lp.miner_id
|
|
LEFT JOIN rooms ON rooms.id = m.room_id
|
|
ORDER BY rooms.name NULLS LAST, COALESCE(m.current_mac, lp.mac_address), lp.pool_index;
|
|
"""
|
|
)
|
|
|
|
rows = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data={"pool_info": rows})
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/dashboard/runtime-fields-latest")
|
|
def dashboard_runtime_fields_latest(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
m.id::text AS miner_id,
|
|
rooms.name AS room_name,
|
|
host(m.current_ip) AS current_ip,
|
|
m.current_mac,
|
|
m.status,
|
|
COALESCE(m.miner_type, m.model) AS model,
|
|
m.miner_sn,
|
|
s.created_at AS latest_snapshot,
|
|
s.fan_speed_in,
|
|
s.fan_speed_out,
|
|
s.env_temp_c,
|
|
s.uptime_seconds,
|
|
s.elapsed_seconds
|
|
FROM miners m
|
|
LEFT JOIN rooms ON rooms.id = m.room_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT *
|
|
FROM miner_status_snapshots snap
|
|
WHERE snap.miner_id = m.id
|
|
ORDER BY snap.created_at DESC
|
|
LIMIT 1
|
|
) s ON true
|
|
ORDER BY rooms.name NULLS LAST, COALESCE(m.miner_type, m.model), m.current_mac;
|
|
"""
|
|
)
|
|
|
|
rows = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data={"runtime_fields": rows})
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/dashboard/m30-command-test-lab")
|
|
def dashboard_m30_command_test_lab(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
q.id,
|
|
q.command_status,
|
|
q.requested_by_username,
|
|
q.request_payload ->> 'test_action' AS test_action,
|
|
q.request_payload ->> 'action' AS action,
|
|
q.request_payload ->> 'performance_mode' AS performance_mode,
|
|
q.created_at,
|
|
q.claimed_at,
|
|
q.completed_at,
|
|
q.error_message,
|
|
left(COALESCE(q.result_payload::text, ''), 700) AS result_preview,
|
|
rooms.name AS room_name,
|
|
m.current_mac,
|
|
host(m.current_ip) AS current_ip,
|
|
COALESCE(m.miner_type, m.model) AS model
|
|
FROM minerops_agent_command_queue q
|
|
LEFT JOIN miners m
|
|
ON m.id = CASE
|
|
WHEN q.request_payload ->> 'miner_id' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
THEN (q.request_payload ->> 'miner_id')::uuid
|
|
ELSE NULL
|
|
END
|
|
LEFT JOIN rooms ON rooms.id = m.room_id
|
|
WHERE q.command_type = 'miner_control'
|
|
AND q.request_payload ->> 'test_lab' = 'm30'
|
|
ORDER BY q.created_at DESC
|
|
LIMIT 40;
|
|
"""
|
|
)
|
|
recent_tests = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data={"recent_tests": recent_tests})
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/dashboard/m30-command-test-lab/run")
|
|
def run_dashboard_m30_command_test_lab(request: M30CommandTestRequest, current_user: dict = Depends(get_current_user)):
|
|
import json as _json
|
|
import uuid as _uuid
|
|
|
|
miner_id = (request.miner_id or "").strip()
|
|
test_action = (request.test_action or "").strip().lower()
|
|
|
|
action = test_action
|
|
performance_mode = ""
|
|
control_method = "api3"
|
|
legacy_4028_command = ""
|
|
legacy_4028_json = ""
|
|
|
|
legacy_4028_actions = {
|
|
"legacy_4028_summary": "summary",
|
|
"legacy_4028_version": "version",
|
|
"legacy_4028_pools": "pools",
|
|
"legacy_4028_devs": "devs",
|
|
"legacy_4028_stats": "stats",
|
|
"legacy_4028_reboot": "reboot",
|
|
"legacy_4028_restart": "restart",
|
|
}
|
|
|
|
if test_action == "low":
|
|
action = "performance_mode"
|
|
performance_mode = "low"
|
|
elif test_action == "normal":
|
|
action = "performance_mode"
|
|
performance_mode = "normal"
|
|
elif test_action == "high":
|
|
action = "performance_mode"
|
|
performance_mode = "high"
|
|
elif test_action in legacy_4028_actions:
|
|
action = "legacy_4028"
|
|
control_method = "legacy_4028"
|
|
legacy_4028_command = legacy_4028_actions[test_action]
|
|
legacy_4028_json = _json.dumps({"command": legacy_4028_command})
|
|
|
|
if action not in ("reboot", "suspend", "resume", "performance_mode", "legacy_4028"):
|
|
raise HTTPException(status_code=400, detail="Invalid M30 test action.")
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
miners.id::text AS miner_id,
|
|
host(miners.current_ip) AS ip_address,
|
|
miners.current_mac AS mac_address,
|
|
rooms.name AS room_name,
|
|
COALESCE(miners.miner_type, miners.model) AS model
|
|
FROM miners
|
|
LEFT JOIN rooms ON rooms.id = miners.room_id
|
|
WHERE miners.id = %s::uuid;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
miner = cur.fetchone()
|
|
|
|
if miner is None:
|
|
raise HTTPException(status_code=404, detail="Miner not found.")
|
|
|
|
if not miner.get("ip_address"):
|
|
raise HTTPException(status_code=400, detail="Miner has no current IP address.")
|
|
|
|
payload = {
|
|
"miner_id": miner["miner_id"],
|
|
"action": action,
|
|
"performance_mode": performance_mode,
|
|
"control_method": control_method,
|
|
"legacy_4028_command": legacy_4028_command,
|
|
"legacy_4028_json": legacy_4028_json,
|
|
"ip_address": miner["ip_address"],
|
|
"current_ip": miner["ip_address"],
|
|
"mac_address": miner["mac_address"],
|
|
"dry_run": False,
|
|
"test_lab": "m30",
|
|
"test_action": test_action,
|
|
"model": miner.get("model") or "",
|
|
}
|
|
|
|
command_id = str(_uuid.uuid4())
|
|
username = current_user.get("username") or current_user.get("email") or "unknown"
|
|
user_id = str(current_user.get("id") or "")
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO minerops_agent_command_queue (
|
|
id,
|
|
agent_id,
|
|
command_type,
|
|
command_status,
|
|
requested_by_user_id,
|
|
requested_by_username,
|
|
request_payload
|
|
)
|
|
VALUES (%s, 'plexus-windows-agent', 'miner_control', 'pending', %s, %s, %s::jsonb);
|
|
""",
|
|
(
|
|
command_id,
|
|
user_id,
|
|
username,
|
|
_json.dumps(payload),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
return api_response(
|
|
data={
|
|
"command_id": command_id,
|
|
"miner": dict(miner),
|
|
"test_action": test_action,
|
|
},
|
|
meta={"message": "M30 command test queued."},
|
|
)
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/dashboard/scheduled-bulk-actions")
|
|
def dashboard_scheduled_bulk_actions(current_user: dict = Depends(get_current_user)):
|
|
minerops_ensure_scheduled_bulk_actions_table()
|
|
minerops_reconcile_scheduled_bulk_action_statuses()
|
|
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id,
|
|
name,
|
|
target_type,
|
|
target_value,
|
|
action,
|
|
performance_mode,
|
|
repeat_rule,
|
|
scheduled_for,
|
|
status,
|
|
queued_command_count,
|
|
last_queued_command_count,
|
|
run_count,
|
|
last_error,
|
|
requested_by_username,
|
|
created_at,
|
|
updated_at,
|
|
processed_at
|
|
FROM minerops_scheduled_bulk_actions
|
|
ORDER BY
|
|
CASE WHEN status = 'pending' THEN 0 ELSE 1 END,
|
|
scheduled_for ASC,
|
|
created_at DESC
|
|
LIMIT 80;
|
|
"""
|
|
)
|
|
schedules = [dict(row) for row in cur.fetchall()]
|
|
|
|
schedule_ids = [row.get("id") for row in schedules if row.get("id")]
|
|
command_counts_by_schedule = {}
|
|
|
|
if schedule_ids:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
request_payload ->> 'scheduled_action_id' AS scheduled_action_id,
|
|
COUNT(*) AS command_total_count,
|
|
COUNT(*) FILTER (WHERE command_status = 'pending') AS command_pending_count,
|
|
COUNT(*) FILTER (WHERE command_status = 'completed') AS command_completed_count,
|
|
COUNT(*) FILTER (WHERE command_status = 'failed') AS command_failed_count,
|
|
COUNT(*) FILTER (WHERE command_status = 'cancelled') AS command_cancelled_count,
|
|
COUNT(*) FILTER (WHERE command_status NOT IN ('completed', 'failed', 'cancelled')) AS command_active_count
|
|
FROM minerops_agent_command_queue
|
|
WHERE request_payload ->> 'scheduled_action_id' = ANY(%s)
|
|
GROUP BY request_payload ->> 'scheduled_action_id';
|
|
""",
|
|
(schedule_ids,),
|
|
)
|
|
|
|
command_counts_by_schedule = {
|
|
row["scheduled_action_id"]: dict(row)
|
|
for row in cur.fetchall()
|
|
}
|
|
|
|
for schedule in schedules:
|
|
counts = command_counts_by_schedule.get(schedule.get("id"), {})
|
|
schedule["command_total_count"] = int(counts.get("command_total_count") or 0)
|
|
schedule["command_pending_count"] = int(counts.get("command_pending_count") or 0)
|
|
schedule["command_completed_count"] = int(counts.get("command_completed_count") or 0)
|
|
schedule["command_failed_count"] = int(counts.get("command_failed_count") or 0)
|
|
schedule["command_cancelled_count"] = int(counts.get("command_cancelled_count") or 0)
|
|
schedule["command_active_count"] = int(counts.get("command_active_count") or 0)
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT name
|
|
FROM rooms
|
|
ORDER BY sort_order, name;
|
|
"""
|
|
)
|
|
units = [row["name"] for row in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT DISTINCT COALESCE(miner_type, model) AS model_name
|
|
FROM miners
|
|
WHERE COALESCE(miner_type, model) IS NOT NULL
|
|
AND COALESCE(miner_type, model) <> ''
|
|
ORDER BY COALESCE(miner_type, model);
|
|
"""
|
|
)
|
|
models = [row["model_name"] for row in cur.fetchall()]
|
|
|
|
return api_response(
|
|
data={
|
|
"schedules": schedules,
|
|
"units": units,
|
|
"models": models,
|
|
}
|
|
)
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/dashboard/scheduled-bulk-actions")
|
|
def create_dashboard_scheduled_bulk_action(request: ScheduledBulkActionCreateRequest, current_user: dict = Depends(get_current_user)):
|
|
import datetime as _datetime
|
|
import uuid as _uuid
|
|
|
|
minerops_ensure_scheduled_bulk_actions_table()
|
|
|
|
target_type = (request.target_type or "").strip().lower()
|
|
target_value = (request.target_value or "").strip()
|
|
command_action = (getattr(request, "action", None) or "performance_mode").strip().lower()
|
|
performance_mode = (request.performance_mode or "").strip().lower()
|
|
repeat_rule = (getattr(request, "repeat_rule", None) or "once").strip().lower()
|
|
|
|
if target_type not in ("all", "unit", "model", "configured_mode", "selected_miners"):
|
|
raise HTTPException(status_code=400, detail="Invalid target type.")
|
|
|
|
if target_type != "all" and not target_value:
|
|
raise HTTPException(status_code=400, detail="Target value is required.")
|
|
|
|
if command_action not in ("performance_mode", "suspend", "resume"):
|
|
raise HTTPException(status_code=400, detail="Invalid action.")
|
|
|
|
if command_action == "performance_mode" and performance_mode not in ("low", "normal", "high"):
|
|
raise HTTPException(status_code=400, detail="Invalid performance mode.")
|
|
|
|
if command_action in ("suspend", "resume"):
|
|
performance_mode = ""
|
|
|
|
if repeat_rule not in ("once", "daily", "weekdays", "weekends"):
|
|
raise HTTPException(status_code=400, detail="Invalid repeat rule.")
|
|
|
|
try:
|
|
raw_dt = (request.scheduled_for or "").strip().replace("Z", "+00:00")
|
|
scheduled_for = _datetime.datetime.fromisoformat(raw_dt)
|
|
if scheduled_for.tzinfo is None:
|
|
scheduled_for = scheduled_for.replace(tzinfo=_datetime.timezone.utc)
|
|
except Exception:
|
|
raise HTTPException(status_code=400, detail="Invalid scheduled date/time.")
|
|
|
|
action_id = str(_uuid.uuid4())
|
|
username = current_user.get("username") or current_user.get("email") or "unknown"
|
|
user_id = str(current_user.get("id") or "")
|
|
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO minerops_scheduled_bulk_actions (
|
|
id,
|
|
name,
|
|
target_type,
|
|
target_value,
|
|
action,
|
|
performance_mode,
|
|
repeat_rule,
|
|
scheduled_for,
|
|
status,
|
|
requested_by_user_id,
|
|
requested_by_username
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', %s, %s);
|
|
""",
|
|
(
|
|
action_id,
|
|
request.name or "",
|
|
target_type,
|
|
target_value,
|
|
command_action,
|
|
performance_mode,
|
|
repeat_rule,
|
|
scheduled_for,
|
|
user_id,
|
|
username,
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
return api_response(
|
|
data={"id": action_id},
|
|
meta={"message": "Scheduled bulk action created."},
|
|
)
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/dashboard/scheduled-bulk-actions/{action_id}/cancel")
|
|
def cancel_dashboard_scheduled_bulk_action(action_id: str, current_user: dict = Depends(get_current_user)):
|
|
minerops_ensure_scheduled_bulk_actions_table()
|
|
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
UPDATE minerops_agent_command_queue
|
|
SET
|
|
command_status = 'cancelled',
|
|
completed_at = now(),
|
|
error_message = COALESCE(error_message, '') || ' Cancelled by admin from scheduled action.'
|
|
WHERE request_payload ->> 'scheduled_action_id' = %s
|
|
AND command_status = 'pending';
|
|
""",
|
|
(action_id,),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE minerops_scheduled_bulk_actions
|
|
SET status = 'cancelled',
|
|
updated_at = now(),
|
|
last_error = CASE
|
|
WHEN COALESCE(last_error, '') = '' THEN 'Cancelled by admin.'
|
|
ELSE last_error
|
|
END
|
|
WHERE id = %s
|
|
AND status IN ('pending', 'queued', 'running', 'failed')
|
|
RETURNING id;
|
|
""",
|
|
(action_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
|
|
conn.commit()
|
|
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="Scheduled action not found or cannot be cancelled.")
|
|
|
|
return api_response(meta={"message": "Scheduled action cancelled."})
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/dashboard/scheduled-bulk-actions/{action_id}/delete")
|
|
def delete_dashboard_scheduled_bulk_action(action_id: str, current_user: dict = Depends(get_current_user)):
|
|
minerops_ensure_scheduled_bulk_actions_table()
|
|
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
UPDATE minerops_agent_command_queue
|
|
SET
|
|
command_status = 'cancelled',
|
|
completed_at = now(),
|
|
error_message = COALESCE(error_message, '') || ' Cancelled because scheduled action was deleted.'
|
|
WHERE request_payload ->> 'scheduled_action_id' = %s
|
|
AND command_status = 'pending';
|
|
""",
|
|
(action_id,),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
DELETE FROM minerops_scheduled_bulk_actions
|
|
WHERE id = %s
|
|
RETURNING id;
|
|
""",
|
|
(action_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
|
|
conn.commit()
|
|
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="Scheduled action not found.")
|
|
|
|
return api_response(meta={"message": "Scheduled action deleted."})
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/dashboard/scheduled-bulk-actions/process-due")
|
|
def process_due_dashboard_scheduled_bulk_actions(current_user: dict = Depends(get_current_user)):
|
|
processed = minerops_process_due_scheduled_bulk_actions()
|
|
return api_response(data={"processed": processed})
|
|
|
|
|
|
@app.get("/api/dashboard/bulk-action-status")
|
|
def dashboard_bulk_action_status(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
COALESCE(INITCAP(configured_performance_mode), 'Unknown') AS configured_mode,
|
|
COALESCE(configured_performance_mode_source, 'none') AS source,
|
|
COUNT(*) AS miner_count
|
|
FROM miners
|
|
GROUP BY configured_performance_mode, configured_performance_mode_source
|
|
ORDER BY configured_mode, source;
|
|
"""
|
|
)
|
|
mode_counts = [dict(row) for row in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
COALESCE(q.request_payload ->> 'action', 'unknown') AS action,
|
|
COALESCE(q.request_payload ->> 'performance_mode', '') AS performance_mode,
|
|
q.command_status,
|
|
COUNT(*) AS command_count
|
|
FROM minerops_agent_command_queue q
|
|
WHERE q.command_type = 'miner_control'
|
|
AND q.created_at >= now() - interval '6 hours'
|
|
GROUP BY
|
|
q.request_payload ->> 'action',
|
|
q.request_payload ->> 'performance_mode',
|
|
q.command_status
|
|
ORDER BY command_count DESC, action, performance_mode, command_status;
|
|
"""
|
|
)
|
|
recent_summary = [dict(row) for row in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
q.id,
|
|
q.command_status,
|
|
q.requested_by_username,
|
|
q.request_payload ->> 'action' AS action,
|
|
q.request_payload ->> 'performance_mode' AS performance_mode,
|
|
q.created_at,
|
|
q.claimed_at,
|
|
q.completed_at,
|
|
q.error_message,
|
|
rooms.name AS room_name,
|
|
m.current_mac,
|
|
host(m.current_ip) AS current_ip
|
|
FROM minerops_agent_command_queue q
|
|
LEFT JOIN miners m
|
|
ON m.id = CASE
|
|
WHEN q.request_payload ->> 'miner_id' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
THEN (q.request_payload ->> 'miner_id')::uuid
|
|
ELSE NULL
|
|
END
|
|
LEFT JOIN rooms ON rooms.id = m.room_id
|
|
WHERE q.command_type = 'miner_control'
|
|
ORDER BY q.created_at DESC
|
|
LIMIT 40;
|
|
"""
|
|
)
|
|
recent_commands = [dict(row) for row in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
q.id,
|
|
q.command_status,
|
|
q.requested_by_username,
|
|
q.request_payload ->> 'action' AS action,
|
|
q.request_payload ->> 'performance_mode' AS performance_mode,
|
|
q.created_at,
|
|
q.completed_at,
|
|
q.error_message,
|
|
rooms.name AS room_name,
|
|
m.current_mac,
|
|
host(m.current_ip) AS current_ip
|
|
FROM minerops_agent_command_queue q
|
|
LEFT JOIN miners m
|
|
ON m.id = CASE
|
|
WHEN q.request_payload ->> 'miner_id' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
THEN (q.request_payload ->> 'miner_id')::uuid
|
|
ELSE NULL
|
|
END
|
|
LEFT JOIN rooms ON rooms.id = m.room_id
|
|
WHERE q.command_type = 'miner_control'
|
|
AND (
|
|
q.command_status <> 'completed'
|
|
OR COALESCE(q.error_message, '') <> ''
|
|
)
|
|
ORDER BY q.created_at DESC
|
|
LIMIT 12;
|
|
"""
|
|
)
|
|
recent_failures = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(
|
|
data={
|
|
"configured_mode_counts": mode_counts,
|
|
"recent_summary": recent_summary,
|
|
"recent_commands": recent_commands,
|
|
"recent_failures": recent_failures,
|
|
}
|
|
)
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/dashboard/miner-table")
|
|
def dashboard_miner_table(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
WITH latest AS (
|
|
SELECT DISTINCT ON (miner_id)
|
|
miner_id,
|
|
observed_at,
|
|
observed_ip,
|
|
mhs_av,
|
|
power_w,
|
|
power_rate_w_per_th,
|
|
chip_temp_max_c,
|
|
chip_temp_avg_c,
|
|
fan_speed_in_rpm,
|
|
fan_speed_out_rpm,
|
|
hash_stable,
|
|
pool_rejected_percent,
|
|
COALESCE(
|
|
raw_summary_json #>> '{Msg,Power Mode}',
|
|
raw_summary_json #>> '{MSG,Power Mode}',
|
|
raw_summary_json #>> '{msg,Power Mode}',
|
|
raw_summary_json ->> 'Power Mode',
|
|
raw_summary_json ->> 'PowerMode',
|
|
raw_summary_json ->> 'power_mode'
|
|
) AS runtime_power_state
|
|
FROM miner_status_snapshots
|
|
ORDER BY miner_id, observed_at DESC
|
|
)
|
|
SELECT
|
|
miners.id::text,
|
|
COALESCE(rooms.name, 'Unassigned') AS room_name,
|
|
host(miners.current_ip) AS current_ip,
|
|
miners.current_mac,
|
|
miners.hostname,
|
|
miners.miner_sn,
|
|
miners.power_sn,
|
|
miners.model,
|
|
miners.miner_type,
|
|
miners.firmware_version,
|
|
miners.status,
|
|
miners.last_seen_at,
|
|
latest.observed_at,
|
|
ROUND((latest.mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths,
|
|
ROUND(latest.power_w::numeric, 2)::float AS power_w,
|
|
ROUND(latest.power_rate_w_per_th::numeric, 2)::float AS power_rate_w_per_th,
|
|
ROUND(latest.chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c,
|
|
ROUND(latest.chip_temp_avg_c::numeric, 2)::float AS chip_temp_avg_c,
|
|
latest.fan_speed_in_rpm,
|
|
latest.fan_speed_out_rpm,
|
|
latest.hash_stable,
|
|
INITCAP(miners.configured_performance_mode) AS performance_mode,
|
|
miners.configured_performance_mode_source,
|
|
miners.configured_performance_mode_updated_at,
|
|
latest.runtime_power_state,
|
|
ROUND(latest.pool_rejected_percent::numeric, 4)::float AS pool_rejected_percent
|
|
FROM miners
|
|
LEFT JOIN rooms ON rooms.id = miners.room_id
|
|
LEFT JOIN latest ON latest.miner_id = miners.id
|
|
ORDER BY rooms.sort_order, miners.current_ip;
|
|
"""
|
|
)
|
|
rows = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=rows)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/dashboard/hot-miners")
|
|
def dashboard_hot_miners(
|
|
min_temp_c: float = 90,
|
|
limit: int = 100,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
WITH latest AS (
|
|
SELECT DISTINCT ON (miner_id)
|
|
miner_id,
|
|
observed_at,
|
|
chip_temp_max_c,
|
|
chip_temp_avg_c,
|
|
power_w,
|
|
mhs_av,
|
|
hash_stable
|
|
FROM miner_status_snapshots
|
|
ORDER BY miner_id, observed_at DESC
|
|
)
|
|
SELECT
|
|
miners.id::text,
|
|
COALESCE(rooms.name, 'Unassigned') AS room_name,
|
|
host(miners.current_ip) AS current_ip,
|
|
miners.current_mac,
|
|
miners.model,
|
|
miners.miner_type,
|
|
miners.firmware_version,
|
|
miners.status,
|
|
latest.observed_at,
|
|
ROUND(latest.chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c,
|
|
ROUND(latest.chip_temp_avg_c::numeric, 2)::float AS chip_temp_avg_c,
|
|
ROUND(latest.power_w::numeric, 2)::float AS power_w,
|
|
ROUND((latest.mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths,
|
|
latest.hash_stable
|
|
FROM miners
|
|
LEFT JOIN rooms ON rooms.id = miners.room_id
|
|
JOIN latest ON latest.miner_id = miners.id
|
|
WHERE latest.chip_temp_max_c >= %s
|
|
ORDER BY latest.chip_temp_max_c DESC
|
|
LIMIT %s;
|
|
""",
|
|
(min_temp_c, limit),
|
|
)
|
|
rows = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=rows)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/dashboard/unstable-miners")
|
|
def dashboard_unstable_miners(
|
|
limit: int = 100,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
WITH latest AS (
|
|
SELECT DISTINCT ON (miner_id)
|
|
miner_id,
|
|
observed_at,
|
|
chip_temp_max_c,
|
|
power_w,
|
|
mhs_av,
|
|
hash_stable,
|
|
pool_rejected_percent
|
|
FROM miner_status_snapshots
|
|
ORDER BY miner_id, observed_at DESC
|
|
)
|
|
SELECT
|
|
miners.id::text,
|
|
COALESCE(rooms.name, 'Unassigned') AS room_name,
|
|
host(miners.current_ip) AS current_ip,
|
|
miners.current_mac,
|
|
miners.model,
|
|
miners.miner_type,
|
|
miners.firmware_version,
|
|
miners.status,
|
|
latest.observed_at,
|
|
ROUND((latest.mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths,
|
|
ROUND(latest.power_w::numeric, 2)::float AS power_w,
|
|
ROUND(latest.chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c,
|
|
latest.hash_stable,
|
|
ROUND(latest.pool_rejected_percent::numeric, 4)::float AS pool_rejected_percent
|
|
FROM miners
|
|
LEFT JOIN rooms ON rooms.id = miners.room_id
|
|
JOIN latest ON latest.miner_id = miners.id
|
|
WHERE latest.hash_stable = false
|
|
OR latest.pool_rejected_percent >= 1
|
|
ORDER BY latest.hash_stable ASC, latest.pool_rejected_percent DESC NULLS LAST, latest.chip_temp_max_c DESC NULLS LAST
|
|
LIMIT %s;
|
|
""",
|
|
(limit,),
|
|
)
|
|
rows = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=rows)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/dashboard/stale-miners")
|
|
def dashboard_stale_miners(
|
|
minutes: int = 30,
|
|
limit: int = 200,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
miners.id::text,
|
|
COALESCE(rooms.name, 'Unassigned') AS room_name,
|
|
host(miners.current_ip) AS current_ip,
|
|
miners.current_mac,
|
|
miners.model,
|
|
miners.miner_type,
|
|
miners.firmware_version,
|
|
miners.status,
|
|
miners.last_seen_at,
|
|
ROUND(EXTRACT(EPOCH FROM (now() - miners.last_seen_at)) / 60.0, 1)::float AS minutes_since_seen
|
|
FROM miners
|
|
LEFT JOIN rooms ON rooms.id = miners.room_id
|
|
WHERE miners.last_seen_at < now() - make_interval(mins => %s)
|
|
ORDER BY miners.last_seen_at ASC
|
|
LIMIT %s;
|
|
""",
|
|
(minutes, limit),
|
|
)
|
|
rows = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=rows)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
MINEROPS_UNIFIED_CSS = r"""
|
|
:root {
|
|
--minerops-bg: #f4f7fb;
|
|
--minerops-card: #ffffff;
|
|
--minerops-border: #d9e2ec;
|
|
--minerops-text: #102a43;
|
|
--minerops-muted: #52606d;
|
|
--minerops-blue: #0b63ce;
|
|
--minerops-blue-dark: #084b9a;
|
|
--minerops-green: #0f766e;
|
|
--minerops-red: #dc2626;
|
|
}
|
|
|
|
body {
|
|
background: var(--minerops-bg) !important;
|
|
color: var(--minerops-text) !important;
|
|
font-family: "Segoe UI", Arial, sans-serif !important;
|
|
margin: 0 !important;
|
|
padding: 20px !important;
|
|
}
|
|
|
|
/* Hide old page headers/navs that made the pages look duplicated */
|
|
body > header:not(.minerops-shell-header) {
|
|
display: none !important;
|
|
}
|
|
|
|
body > h1 {
|
|
display: none !important;
|
|
}
|
|
|
|
body > .topbar {
|
|
display: none !important;
|
|
}
|
|
|
|
.app-header {
|
|
display: none !important;
|
|
}
|
|
|
|
#dashboardLocationMapLink {
|
|
display: none !important;
|
|
}
|
|
|
|
.minerops-shell-header {
|
|
background: linear-gradient(135deg, #0b63ce, #0f766e);
|
|
color: white;
|
|
border-radius: 16px;
|
|
padding: 18px 20px;
|
|
margin-bottom: 16px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 16px;
|
|
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.18);
|
|
}
|
|
|
|
.minerops-kicker {
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
opacity: 0.86;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.minerops-shell-header h1 {
|
|
display: block !important;
|
|
margin: 0;
|
|
color: white;
|
|
font-size: 28px;
|
|
line-height: 1.1;
|
|
}
|
|
|
|
.minerops-subtitle {
|
|
margin-top: 6px;
|
|
font-size: 14px;
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.minerops-nav {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
}
|
|
|
|
.minerops-nav-link {
|
|
display: inline-block;
|
|
border: 1px solid rgba(255, 255, 255, 0.45);
|
|
background: rgba(255, 255, 255, 0.14);
|
|
color: white !important;
|
|
border-radius: 999px;
|
|
padding: 8px 12px;
|
|
font-weight: 800;
|
|
font-size: 13px;
|
|
text-decoration: none !important;
|
|
}
|
|
|
|
.minerops-nav-link:hover {
|
|
background: rgba(255, 255, 255, 0.24);
|
|
}
|
|
|
|
.minerops-nav-link.active {
|
|
background: white;
|
|
color: var(--minerops-blue) !important;
|
|
}
|
|
|
|
.card {
|
|
background: var(--minerops-card) !important;
|
|
border: 1px solid var(--minerops-border) !important;
|
|
border-radius: 14px !important;
|
|
padding: 16px !important;
|
|
margin-bottom: 16px !important;
|
|
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06) !important;
|
|
}
|
|
|
|
.card h2 {
|
|
color: var(--minerops-text) !important;
|
|
margin-top: 0 !important;
|
|
}
|
|
|
|
.small,
|
|
.muted {
|
|
color: var(--minerops-muted) !important;
|
|
}
|
|
|
|
button,
|
|
input[type="button"],
|
|
input[type="submit"],
|
|
.issue-action-button {
|
|
background: var(--minerops-blue) !important;
|
|
color: white !important;
|
|
border: 0 !important;
|
|
border-radius: 8px !important;
|
|
padding: 8px 11px !important;
|
|
font-weight: 800 !important;
|
|
cursor: pointer !important;
|
|
text-decoration: none !important;
|
|
}
|
|
|
|
button:hover,
|
|
input[type="button"]:hover,
|
|
input[type="submit"]:hover,
|
|
.issue-action-button:hover {
|
|
background: var(--minerops-blue-dark) !important;
|
|
}
|
|
|
|
button.secondary {
|
|
background: #64748b !important;
|
|
}
|
|
|
|
button.danger,
|
|
.danger {
|
|
background: var(--minerops-red) !important;
|
|
color: white !important;
|
|
}
|
|
|
|
input,
|
|
select,
|
|
textarea {
|
|
border: 1px solid #bcccdc !important;
|
|
border-radius: 8px !important;
|
|
padding: 8px !important;
|
|
background: white !important;
|
|
color: var(--minerops-text) !important;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
background: white;
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
th {
|
|
background: #eef2ff !important;
|
|
color: #1e293b !important;
|
|
text-align: left;
|
|
font-size: 13px;
|
|
}
|
|
|
|
th,
|
|
td {
|
|
border-bottom: 1px solid #e2e8f0 !important;
|
|
padding: 8px !important;
|
|
vertical-align: top;
|
|
}
|
|
|
|
tr:hover td {
|
|
background: #f8fafc !important;
|
|
}
|
|
|
|
.status {
|
|
border-radius: 10px !important;
|
|
padding: 10px 12px !important;
|
|
margin-top: 10px !important;
|
|
border: 1px solid var(--minerops-border) !important;
|
|
background: #f8fafc !important;
|
|
}
|
|
|
|
.status.ok {
|
|
background: #ecfdf5 !important;
|
|
border-color: #6ee7b7 !important;
|
|
}
|
|
|
|
.status.warn {
|
|
background: #fff7ed !important;
|
|
border-color: #fdba74 !important;
|
|
}
|
|
|
|
.status.bad {
|
|
background: #fef2f2 !important;
|
|
border-color: #fca5a5 !important;
|
|
}
|
|
|
|
.sticky-mini-toolbar,
|
|
.card-toolbar {
|
|
background: rgba(255, 255, 255, 0.96) !important;
|
|
border: 1px solid var(--minerops-border) !important;
|
|
border-radius: 14px !important;
|
|
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.10) !important;
|
|
padding: 10px !important;
|
|
}
|
|
|
|
.sticky-mini-toolbar {
|
|
top: 8px !important;
|
|
}
|
|
|
|
/* Step 80: Header auth controls */
|
|
.minerops-auth {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 6px;
|
|
margin-left: 8px;
|
|
}
|
|
|
|
.minerops-auth input {
|
|
width: 115px !important;
|
|
min-width: 90px !important;
|
|
padding: 7px 8px !important;
|
|
border-radius: 999px !important;
|
|
border: 1px solid rgba(255, 255, 255, 0.55) !important;
|
|
background: rgba(255, 255, 255, 0.92) !important;
|
|
font-size: 12px !important;
|
|
}
|
|
|
|
.minerops-auth button {
|
|
border-radius: 999px !important;
|
|
padding: 7px 10px !important;
|
|
font-size: 12px !important;
|
|
background: rgba(255, 255, 255, 0.18) !important;
|
|
border: 1px solid rgba(255, 255, 255, 0.45) !important;
|
|
color: white !important;
|
|
}
|
|
|
|
.minerops-auth button:hover {
|
|
background: rgba(255, 255, 255, 0.28) !important;
|
|
}
|
|
|
|
.minerops-auth-user {
|
|
color: white;
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
padding: 7px 10px;
|
|
border-radius: 999px;
|
|
background: rgba(255, 255, 255, 0.14);
|
|
border: 1px solid rgba(255, 255, 255, 0.30);
|
|
}
|
|
|
|
.minerops-auth-message {
|
|
color: white;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
opacity: 0.92;
|
|
}
|
|
|
|
.minerops-hidden-login-card {
|
|
display: none !important;
|
|
}
|
|
|
|
@media (max-width: 950px) {
|
|
.minerops-auth {
|
|
width: 100%;
|
|
margin-left: 0;
|
|
}
|
|
|
|
.minerops-auth input {
|
|
width: 140px !important;
|
|
}
|
|
}
|
|
|
|
/* Step 83: Global miner search */
|
|
.minerops-global-search {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
margin-left: 8px;
|
|
}
|
|
|
|
.minerops-global-search input {
|
|
width: 230px !important;
|
|
min-width: 180px !important;
|
|
border-radius: 999px !important;
|
|
padding: 8px 12px !important;
|
|
border: 1px solid rgba(255, 255, 255, 0.55) !important;
|
|
background: rgba(255, 255, 255, 0.95) !important;
|
|
font-size: 13px !important;
|
|
}
|
|
|
|
.minerops-search-results {
|
|
position: absolute;
|
|
top: 40px;
|
|
right: 0;
|
|
z-index: 5000;
|
|
width: min(680px, 92vw);
|
|
max-height: 440px;
|
|
overflow: auto;
|
|
background: white;
|
|
color: var(--minerops-text);
|
|
border: 1px solid var(--minerops-border);
|
|
border-radius: 14px;
|
|
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.28);
|
|
padding: 8px;
|
|
display: none;
|
|
}
|
|
|
|
.minerops-search-results.open {
|
|
display: block;
|
|
}
|
|
|
|
.minerops-search-result {
|
|
display: grid;
|
|
grid-template-columns: 150px 110px 1fr;
|
|
gap: 8px;
|
|
padding: 9px;
|
|
border-radius: 10px;
|
|
cursor: pointer;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.minerops-search-result:hover,
|
|
.minerops-search-result.active {
|
|
background: #eff6ff;
|
|
}
|
|
|
|
.minerops-search-result-main {
|
|
font-weight: 900;
|
|
color: #0f172a;
|
|
}
|
|
|
|
.minerops-search-result-sub {
|
|
font-size: 12px;
|
|
color: #475569;
|
|
}
|
|
|
|
.minerops-search-empty {
|
|
padding: 10px;
|
|
color: #475569;
|
|
font-size: 13px;
|
|
}
|
|
|
|
@media (max-width: 950px) {
|
|
.minerops-global-search {
|
|
width: 100%;
|
|
margin-left: 0;
|
|
}
|
|
|
|
.minerops-global-search input {
|
|
width: 100% !important;
|
|
}
|
|
|
|
.minerops-search-results {
|
|
left: 0;
|
|
right: auto;
|
|
width: 92vw;
|
|
}
|
|
}
|
|
|
|
/* Step 84: Global scan action */
|
|
.minerops-header-scan {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
margin-left: 4px;
|
|
}
|
|
|
|
.minerops-scan-button {
|
|
background: rgba(255, 255, 255, 0.18) !important;
|
|
border: 1px solid rgba(255, 255, 255, 0.45) !important;
|
|
color: white !important;
|
|
border-radius: 999px !important;
|
|
padding: 8px 12px !important;
|
|
font-size: 13px !important;
|
|
font-weight: 800 !important;
|
|
}
|
|
|
|
.minerops-scan-button:hover {
|
|
background: rgba(255, 255, 255, 0.28) !important;
|
|
}
|
|
|
|
.minerops-scan-button.running {
|
|
background: #f59e0b !important;
|
|
color: #111827 !important;
|
|
}
|
|
|
|
.minerops-scan-status {
|
|
color: white;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
opacity: 0.92;
|
|
max-width: 260px;
|
|
}
|
|
|
|
@media (max-width: 950px) {
|
|
.minerops-header-scan {
|
|
width: 100%;
|
|
margin-left: 0;
|
|
}
|
|
|
|
.minerops-scan-status {
|
|
max-width: 100%;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 800px) {
|
|
body {
|
|
padding: 12px !important;
|
|
}
|
|
|
|
.minerops-shell-header {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.minerops-nav {
|
|
justify-content: flex-start;
|
|
}
|
|
}
|
|
"""
|
|
|
|
@app.get("/assets/minerops-unified.css")
|
|
async def minerops_unified_css():
|
|
return HTMLResponse(content=MINEROPS_UNIFIED_CSS, media_type="text/css")
|
|
|
|
MINEROPS_SHELL_AUTH_JS = r"""
|
|
(function () {
|
|
const TOKEN_KEYS = [
|
|
"minerops_token",
|
|
"access_token",
|
|
"token",
|
|
"auth_token",
|
|
"authToken"
|
|
];
|
|
|
|
const USERNAME_KEY = "minerops_username";
|
|
|
|
|
|
async function apiPatch(url, body) {
|
|
const response = await fetch(url, {
|
|
method: "PATCH",
|
|
credentials: "same-origin",
|
|
headers: authHeaders({
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json"
|
|
}),
|
|
body: JSON.stringify(body || {})
|
|
});
|
|
|
|
const payload = await response.json().catch(function () {
|
|
return null;
|
|
});
|
|
|
|
if (!response.ok || (payload && payload.error)) {
|
|
throw new Error((payload && payload.error) || "Request failed");
|
|
}
|
|
|
|
return payload ? payload.data : null;
|
|
}
|
|
|
|
async function apiDelete(url) {
|
|
const response = await fetch(url, {
|
|
method: "DELETE",
|
|
credentials: "same-origin",
|
|
headers: authHeaders({
|
|
"Accept": "application/json"
|
|
})
|
|
});
|
|
|
|
const payload = await response.json().catch(function () {
|
|
return null;
|
|
});
|
|
|
|
if (!response.ok || (payload && payload.error)) {
|
|
throw new Error((payload && payload.error) || "Request failed");
|
|
}
|
|
|
|
return payload ? payload.data : null;
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value || "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function looksLikeToken(value) {
|
|
if (!value || typeof value !== "string") {
|
|
return false;
|
|
}
|
|
|
|
if (value.split(".").length === 3 && value.length > 30) {
|
|
return true;
|
|
}
|
|
|
|
return value.length > 40 && !value.includes(" ");
|
|
}
|
|
|
|
function findTokenRecursive(value) {
|
|
if (!value) {
|
|
return "";
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
return looksLikeToken(value) ? value : "";
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
const found = findTokenRecursive(item);
|
|
|
|
if (found) {
|
|
return found;
|
|
}
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
if (typeof value === "object") {
|
|
const preferredKeys = [
|
|
"access_token",
|
|
"accessToken",
|
|
"token",
|
|
"jwt",
|
|
"auth_token",
|
|
"authToken"
|
|
];
|
|
|
|
for (const key of preferredKeys) {
|
|
if (looksLikeToken(value[key])) {
|
|
return value[key];
|
|
}
|
|
}
|
|
|
|
for (const key of Object.keys(value)) {
|
|
if (key.toLowerCase().includes("token")) {
|
|
const found = findTokenRecursive(value[key]);
|
|
|
|
if (found) {
|
|
return found;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const key of Object.keys(value)) {
|
|
const found = findTokenRecursive(value[key]);
|
|
|
|
if (found) {
|
|
return found;
|
|
}
|
|
}
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function getToken() {
|
|
for (const key of TOKEN_KEYS) {
|
|
const value = localStorage.getItem(key);
|
|
|
|
if (value) {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function setToken(token) {
|
|
for (const key of TOKEN_KEYS) {
|
|
localStorage.setItem(key, token);
|
|
}
|
|
}
|
|
|
|
function clearToken() {
|
|
for (const key of TOKEN_KEYS) {
|
|
localStorage.removeItem(key);
|
|
}
|
|
|
|
localStorage.removeItem(USERNAME_KEY);
|
|
}
|
|
|
|
function authHeaders() {
|
|
const token = getToken();
|
|
|
|
if (!token) {
|
|
return {};
|
|
}
|
|
|
|
return {
|
|
"Authorization": "Bearer " + token
|
|
};
|
|
}
|
|
|
|
function findLegacyLoginContainer(heading) {
|
|
let node = heading.parentElement;
|
|
let best = null;
|
|
|
|
for (let i = 0; i < 7 && node && node !== document.body; i++) {
|
|
const hasPassword = node.querySelector && node.querySelector('input[type="password"]');
|
|
const hasClearToken = node.textContent && node.textContent.toLowerCase().includes("clear token");
|
|
const isCard = node.classList && (node.classList.contains("card") || node.classList.contains("panel"));
|
|
|
|
if (hasPassword || hasClearToken) {
|
|
best = node;
|
|
}
|
|
|
|
if (isCard) {
|
|
best = node;
|
|
break;
|
|
}
|
|
|
|
node = node.parentElement;
|
|
}
|
|
|
|
return best || heading.parentElement;
|
|
}
|
|
|
|
function hideLegacyLoginCards() {
|
|
document.querySelectorAll("h1, h2, h3").forEach(heading => {
|
|
if (heading.closest(".minerops-shell-header")) {
|
|
return;
|
|
}
|
|
|
|
const title = heading.textContent.trim().toLowerCase();
|
|
|
|
if (
|
|
title === "login" ||
|
|
title === "login / refresh" ||
|
|
title === "login/refresh"
|
|
) {
|
|
const container = findLegacyLoginContainer(heading);
|
|
|
|
if (container && !container.closest(".minerops-shell-header")) {
|
|
container.classList.add("minerops-hidden-login-card");
|
|
container.style.display = "none";
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function setLegacyLoginInputs(username, password) {
|
|
const usernameInputs = Array.from(document.querySelectorAll('input[type="text"], input:not([type])'))
|
|
.filter(input => !input.closest(".minerops-shell-header"));
|
|
|
|
const passwordInputs = Array.from(document.querySelectorAll('input[type="password"]'))
|
|
.filter(input => !input.closest(".minerops-shell-header"));
|
|
|
|
for (const input of usernameInputs) {
|
|
const idName = ((input.id || "") + " " + (input.name || "") + " " + (input.placeholder || "")).toLowerCase();
|
|
|
|
if (idName.includes("user") || idName.includes("admin") || idName.includes("login")) {
|
|
input.value = username;
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
}
|
|
}
|
|
|
|
for (const input of passwordInputs) {
|
|
input.value = password;
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
}
|
|
}
|
|
|
|
async function tryLegacyPageLogin(username, password) {
|
|
setLegacyLoginInputs(username, password);
|
|
|
|
const functionNames = [
|
|
"login",
|
|
"doLogin",
|
|
"loginUser",
|
|
"dashboardLogin",
|
|
"locationLogin"
|
|
];
|
|
|
|
for (const functionName of functionNames) {
|
|
if (typeof window[functionName] === "function") {
|
|
try {
|
|
const result = window[functionName]();
|
|
await Promise.resolve(result);
|
|
await delay(800);
|
|
|
|
if (getToken()) {
|
|
return true;
|
|
}
|
|
} catch (error) {
|
|
// Keep trying other methods.
|
|
}
|
|
}
|
|
}
|
|
|
|
const buttons = Array.from(document.querySelectorAll("button"))
|
|
.filter(button => !button.closest(".minerops-shell-header"))
|
|
.filter(button => button.textContent.trim().toLowerCase() === "login");
|
|
|
|
for (const button of buttons) {
|
|
try {
|
|
button.click();
|
|
await delay(1000);
|
|
|
|
if (getToken()) {
|
|
return true;
|
|
}
|
|
} catch (error) {
|
|
// Keep trying.
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function parseLoginResponse(response) {
|
|
const text = await response.text();
|
|
let data = null;
|
|
|
|
try {
|
|
data = text ? JSON.parse(text) : {};
|
|
} catch (error) {
|
|
data = text;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const message = data && typeof data === "object"
|
|
? (data.detail || data.message || data.error || "Login failed.")
|
|
: (text || "Login failed.");
|
|
|
|
throw new Error(message);
|
|
}
|
|
|
|
return findTokenRecursive(data) || findTokenRecursive(text);
|
|
}
|
|
|
|
async function tryJsonLogin(username, password) {
|
|
const response = await fetch("/api/auth/login", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
username: username,
|
|
password: password
|
|
})
|
|
});
|
|
|
|
return await parseLoginResponse(response);
|
|
}
|
|
|
|
async function tryFormLogin(username, password) {
|
|
const form = new URLSearchParams();
|
|
form.set("username", username);
|
|
form.set("password", password);
|
|
|
|
const response = await fetch("/api/auth/login", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded"
|
|
},
|
|
body: form.toString()
|
|
});
|
|
|
|
return await parseLoginResponse(response);
|
|
}
|
|
|
|
async function getCurrentUser() {
|
|
try {
|
|
const response = await fetch("/api/auth/me", {
|
|
credentials: "same-origin",
|
|
headers: authHeaders()
|
|
});
|
|
|
|
if (!response.ok) {
|
|
window.location.replace("/login?next=" + encodeURIComponent(window.location.pathname));
|
|
return null;
|
|
}
|
|
|
|
const payload = await response.json();
|
|
const user = payload && payload.data ? payload.data : payload;
|
|
|
|
if (user && !getToken()) {
|
|
setToken("cookie-session");
|
|
}
|
|
|
|
return user;
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function userDisplayName(user) {
|
|
const stored = localStorage.getItem(USERNAME_KEY);
|
|
|
|
const candidates = [
|
|
stored,
|
|
user && user.username,
|
|
user && user.user_name,
|
|
user && user.name,
|
|
user && user.email
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
if (!candidate) {
|
|
continue;
|
|
}
|
|
|
|
const value = String(candidate).trim();
|
|
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
|
|
if (value.toLowerCase() === "signed in") {
|
|
continue;
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
async function loginFromHeader() {
|
|
const username = document.getElementById("mineropsHeaderUsername")?.value || "";
|
|
const password = document.getElementById("mineropsHeaderPassword")?.value || "";
|
|
const message = document.getElementById("mineropsHeaderAuthMessage");
|
|
|
|
if (!username || !password) {
|
|
if (message) {
|
|
message.textContent = "Enter username and password.";
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (message) {
|
|
message.textContent = "Signing in...";
|
|
}
|
|
|
|
let token = "";
|
|
let lastError = null;
|
|
|
|
try {
|
|
token = await tryJsonLogin(username, password);
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
|
|
if (!token) {
|
|
try {
|
|
token = await tryFormLogin(username, password);
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
}
|
|
|
|
if (token) {
|
|
setToken(token);
|
|
localStorage.setItem(USERNAME_KEY, username);
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
|
|
if (message) {
|
|
message.textContent = "Trying page login...";
|
|
}
|
|
|
|
const legacyWorked = await tryLegacyPageLogin(username, password);
|
|
|
|
if (legacyWorked || getToken()) {
|
|
localStorage.setItem(USERNAME_KEY, username);
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
|
|
if (message) {
|
|
message.textContent = lastError ? lastError.message : "Login failed. No token was returned.";
|
|
}
|
|
}
|
|
|
|
async function logoutFromHeader() {
|
|
try {
|
|
await fetch("/api/auth/logout", {
|
|
method: "POST",
|
|
credentials: "same-origin"
|
|
});
|
|
} finally {
|
|
clearToken();
|
|
window.location.replace("/login");
|
|
}
|
|
}
|
|
|
|
async function renderHeaderAuth() {
|
|
const nav = document.querySelector(".minerops-nav");
|
|
|
|
if (!nav) {
|
|
return;
|
|
}
|
|
|
|
let authArea = document.getElementById("mineropsHeaderAuth");
|
|
|
|
if (!authArea) {
|
|
authArea = document.createElement("div");
|
|
authArea.id = "mineropsHeaderAuth";
|
|
authArea.className = "minerops-auth";
|
|
nav.appendChild(authArea);
|
|
}
|
|
|
|
const user = await getCurrentUser();
|
|
|
|
if (user) {
|
|
const displayName = userDisplayName(user);
|
|
|
|
authArea.innerHTML = displayName
|
|
? `
|
|
<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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function normalize(value) {
|
|
return String(value || "")
|
|
.toLowerCase()
|
|
.replaceAll(":", "")
|
|
.replaceAll("-", "")
|
|
.replaceAll(" ", "")
|
|
.trim();
|
|
}
|
|
|
|
function firstValue(obj, keys) {
|
|
for (const key of keys) {
|
|
if (obj && obj[key] !== null && obj[key] !== undefined && obj[key] !== "") {
|
|
return obj[key];
|
|
}
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function valueFromNested(obj, paths) {
|
|
for (const path of paths) {
|
|
let current = obj;
|
|
let found = true;
|
|
|
|
for (const part of path.split(".")) {
|
|
if (!current || current[part] === null || current[part] === undefined || current[part] === "") {
|
|
found = false;
|
|
break;
|
|
}
|
|
|
|
current = current[part];
|
|
}
|
|
|
|
if (found) {
|
|
return current;
|
|
}
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function displayValue(value, fallback) {
|
|
if (value === null || value === undefined || value === "") {
|
|
return fallback || "";
|
|
}
|
|
|
|
return String(value);
|
|
}
|
|
|
|
async function fetchJson(url) {
|
|
const response = await fetch(url, {
|
|
headers: authHeaders()
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(url + " returned HTTP " + response.status);
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
function addSearchRow(rows, item, sourceName) {
|
|
if (!item || typeof item !== "object") {
|
|
return;
|
|
}
|
|
|
|
const minerId = firstValue(item, [
|
|
"miner_id",
|
|
"id",
|
|
"minerId",
|
|
"miner_uuid",
|
|
"uuid"
|
|
]);
|
|
|
|
const mac = firstValue(item, [
|
|
"current_mac",
|
|
"current_mac_address",
|
|
"mac_address",
|
|
"mac",
|
|
"expected_mac",
|
|
"network_mac",
|
|
"macAddress",
|
|
"primary_mac"
|
|
]);
|
|
|
|
const ip = firstValue(item, [
|
|
"current_ip",
|
|
"current_ip_address",
|
|
"ip_address",
|
|
"ip",
|
|
"network_ip",
|
|
"ipAddress",
|
|
"host",
|
|
"hostname"
|
|
]);
|
|
|
|
const model = firstValue(item, [
|
|
"model",
|
|
"miner_model",
|
|
"miner_type",
|
|
"hardware_model",
|
|
"machine_model",
|
|
"platform"
|
|
]);
|
|
|
|
const room = firstValue(item, [
|
|
"room_name",
|
|
"room",
|
|
"unit",
|
|
"unit_name"
|
|
]);
|
|
|
|
const rack = firstValue(item, [
|
|
"rack_name",
|
|
"rack"
|
|
]);
|
|
|
|
const shelf = firstValue(item, [
|
|
"shelf_name",
|
|
"shelf"
|
|
]);
|
|
|
|
const position = firstValue(item, [
|
|
"position_index",
|
|
"position",
|
|
"slot",
|
|
"slot_index"
|
|
]);
|
|
|
|
const serial = firstValue(item, [
|
|
"serial_number",
|
|
"miner_serial",
|
|
"serial",
|
|
"sn",
|
|
"control_board_serial",
|
|
"control_board_sn",
|
|
"psu_serial",
|
|
"hashboard_serial"
|
|
]) || valueFromNested(item, [
|
|
"latest_status.serial_number",
|
|
"latest_snapshot.serial_number",
|
|
"identity.serial_number"
|
|
]);
|
|
|
|
if (!minerId && !mac && !ip && !serial && !room && !rack) {
|
|
return;
|
|
}
|
|
|
|
const locationParts = [];
|
|
|
|
if (room) {
|
|
locationParts.push(room);
|
|
}
|
|
|
|
if (rack) {
|
|
locationParts.push(rack);
|
|
}
|
|
|
|
if (shelf) {
|
|
locationParts.push(shelf);
|
|
}
|
|
|
|
if (position) {
|
|
locationParts.push("Pos " + position);
|
|
}
|
|
|
|
const location = locationParts.join(" / ");
|
|
|
|
const hashrate = firstValue(item, [
|
|
"hashrate_ths",
|
|
"hashrate",
|
|
"avg_hashrate_ths",
|
|
"current_hashrate_ths"
|
|
]);
|
|
|
|
const temp = firstValue(item, [
|
|
"chip_temp_max_c",
|
|
"max_temp_c",
|
|
"temperature_c",
|
|
"temp_c"
|
|
]);
|
|
|
|
const haystack = [
|
|
minerId,
|
|
mac,
|
|
ip,
|
|
model,
|
|
room,
|
|
rack,
|
|
shelf,
|
|
position,
|
|
serial,
|
|
location,
|
|
sourceName
|
|
].map(value => String(value || "")).join(" ");
|
|
|
|
rows.push({
|
|
miner_id: minerId,
|
|
mac: mac,
|
|
ip: ip,
|
|
model: model,
|
|
room: room,
|
|
rack: rack,
|
|
shelf: shelf,
|
|
position: position,
|
|
serial: serial,
|
|
location: location,
|
|
hashrate: hashrate,
|
|
temp: temp,
|
|
source: sourceName,
|
|
haystack: haystack,
|
|
normalized: normalize(haystack)
|
|
});
|
|
}
|
|
|
|
function addRowsFromArray(rows, arrayValue, sourceName) {
|
|
if (!Array.isArray(arrayValue)) {
|
|
return;
|
|
}
|
|
|
|
for (const item of arrayValue) {
|
|
addSearchRow(rows, item, sourceName);
|
|
}
|
|
}
|
|
|
|
function collectRowsFromPayload(rows, payload, sourceName) {
|
|
if (!payload) {
|
|
return;
|
|
}
|
|
|
|
if (Array.isArray(payload)) {
|
|
addRowsFromArray(rows, payload, sourceName);
|
|
return;
|
|
}
|
|
|
|
if (typeof payload !== "object") {
|
|
return;
|
|
}
|
|
|
|
addRowsFromArray(rows, payload.positions, sourceName + ".positions");
|
|
addRowsFromArray(rows, payload.virtual_miners, sourceName + ".virtual_miners");
|
|
addRowsFromArray(rows, payload.miners, sourceName + ".miners");
|
|
addRowsFromArray(rows, payload.items, sourceName + ".items");
|
|
addRowsFromArray(rows, payload.results, sourceName + ".results");
|
|
addRowsFromArray(rows, payload.rows, sourceName + ".rows");
|
|
addRowsFromArray(rows, payload.data, sourceName + ".data");
|
|
|
|
addSearchRow(rows, payload, sourceName);
|
|
}
|
|
|
|
function dedupeRows(rows) {
|
|
const seen = new Set();
|
|
const output = [];
|
|
|
|
for (const row of rows) {
|
|
const key = row.miner_id || normalize(row.mac) || row.ip || row.serial;
|
|
|
|
if (!key) {
|
|
continue;
|
|
}
|
|
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
|
|
seen.add(key);
|
|
output.push(row);
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
async function loadSearchCache() {
|
|
if (searchCache) {
|
|
return searchCache;
|
|
}
|
|
|
|
const rows = [];
|
|
const attempts = [];
|
|
|
|
const urls = [
|
|
"/api/location/map",
|
|
"/api/miners",
|
|
"/api/dashboard/miner-table",
|
|
"/api/dashboard/hot-miners",
|
|
"/api/dashboard/unstable-miners",
|
|
"/api/dashboard/stale-miners"
|
|
];
|
|
|
|
for (const url of urls) {
|
|
try {
|
|
const payload = await fetchJson(url);
|
|
const beforeCount = rows.length;
|
|
|
|
collectRowsFromPayload(rows, payload, url);
|
|
|
|
attempts.push(url + ": +" + (rows.length - beforeCount));
|
|
} catch (error) {
|
|
attempts.push(url + ": " + error.message);
|
|
}
|
|
}
|
|
|
|
searchCache = dedupeRows(rows);
|
|
lastLoadSummary = "Loaded " + searchCache.length + " searchable miners. " + attempts.join(" | ");
|
|
|
|
return searchCache;
|
|
}
|
|
|
|
function resultScore(row, rawQuery, normalizedQuery) {
|
|
const q = rawQuery.toLowerCase();
|
|
|
|
if (String(row.mac || "").toLowerCase().includes(q)) {
|
|
return 100;
|
|
}
|
|
|
|
if (normalize(row.mac).includes(normalizedQuery)) {
|
|
return 98;
|
|
}
|
|
|
|
if (String(row.ip || "").toLowerCase().includes(q)) {
|
|
return 92;
|
|
}
|
|
|
|
if (String(row.serial || "").toLowerCase().includes(q)) {
|
|
return 88;
|
|
}
|
|
|
|
if (String(row.location || "").toLowerCase().includes(q)) {
|
|
return 75;
|
|
}
|
|
|
|
if (String(row.room || "").toLowerCase().includes(q)) {
|
|
return 72;
|
|
}
|
|
|
|
if (String(row.rack || "").toLowerCase().includes(q)) {
|
|
return 70;
|
|
}
|
|
|
|
if (String(row.model || "").toLowerCase().includes(q)) {
|
|
return 62;
|
|
}
|
|
|
|
if (row.normalized.includes(normalizedQuery)) {
|
|
return 50;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
function setResultsMessage(message) {
|
|
const resultsBox = document.getElementById("mineropsGlobalSearchResults");
|
|
|
|
if (!resultsBox) {
|
|
return;
|
|
}
|
|
|
|
resultsBox.classList.add("open");
|
|
resultsBox.innerHTML = `<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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function minerIdFromPage() {
|
|
const card = document.getElementById("minerControlCard");
|
|
|
|
if (card && card.dataset.minerId) {
|
|
return card.dataset.minerId;
|
|
}
|
|
|
|
const match = window.location.pathname.match(/\/miners\/([^\/]+)\/detail/);
|
|
|
|
return match ? decodeURIComponent(match[1]) : "";
|
|
}
|
|
|
|
|
|
function getMinerOpsTokenForAgentControl() {
|
|
const keys = [
|
|
"minerops_token",
|
|
"mineropsToken",
|
|
"access_token",
|
|
"token",
|
|
"auth_token",
|
|
"jwt"
|
|
];
|
|
|
|
for (const storage of [window.localStorage, window.sessionStorage]) {
|
|
if (!storage) {
|
|
continue;
|
|
}
|
|
|
|
for (const key of keys) {
|
|
const value = storage.getItem(key);
|
|
|
|
if (value && value.trim()) {
|
|
return value.trim();
|
|
}
|
|
}
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function getMinerIdForAgentControl() {
|
|
const pathMatch = window.location.pathname.match(/\/miners\/([^\/]+)\/detail/);
|
|
|
|
if (pathMatch && pathMatch[1]) {
|
|
return decodeURIComponent(pathMatch[1]);
|
|
}
|
|
|
|
const dataId =
|
|
document.body?.dataset?.minerId ||
|
|
document.documentElement?.dataset?.minerId ||
|
|
"";
|
|
|
|
return dataId;
|
|
}
|
|
|
|
async function postAgentMinerControl(action, dryRun, label) {
|
|
const minerId = getMinerIdForAgentControl();
|
|
|
|
if (!minerId) {
|
|
throw new Error("Could not determine miner ID from this page.");
|
|
}
|
|
|
|
const headers = {
|
|
"Content-Type": "application/json"
|
|
};
|
|
|
|
const token = getMinerOpsTokenForAgentControl();
|
|
|
|
if (token) {
|
|
headers.Authorization = "Bearer " + token;
|
|
}
|
|
|
|
const response = await fetch(`/api/miners/${encodeURIComponent(minerId)}/agent-control`, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({
|
|
action,
|
|
dry_run: !!dryRun,
|
|
performance_mode: window.__mineropsPerformanceMode || null
|
|
})
|
|
});
|
|
|
|
let data = {};
|
|
|
|
try {
|
|
data = await response.json();
|
|
} catch {
|
|
data = {};
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const detail = data.detail || data.error || data.message || response.statusText;
|
|
throw new Error(detail);
|
|
}
|
|
|
|
setMinerControlStatus(
|
|
`${label} command queued for Windows Agent. Command ID: ${data.command_id || "unknown"}`,
|
|
"ok"
|
|
);
|
|
|
|
return data;
|
|
}
|
|
|
|
|
|
function setMinerControlStatus(message, className) {
|
|
const status = document.getElementById("minerControlStatus");
|
|
|
|
if (!status) {
|
|
return;
|
|
}
|
|
|
|
status.textContent = message;
|
|
status.className = "status " + (className || "warn");
|
|
}
|
|
|
|
async function fetchJson(url, options) {
|
|
const response = await fetch(url, options || {});
|
|
const text = await response.text();
|
|
|
|
let data = null;
|
|
|
|
try {
|
|
data = text ? JSON.parse(text) : {};
|
|
} catch (error) {
|
|
data = text;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
let message = text || "Request failed.";
|
|
|
|
if (data && typeof data === "object") {
|
|
if (typeof data.detail === "string") {
|
|
message = data.detail;
|
|
} else if (data.detail && data.detail.message) {
|
|
message = data.detail.message;
|
|
} else if (data.message) {
|
|
message = data.message;
|
|
} else {
|
|
message = JSON.stringify(data);
|
|
}
|
|
}
|
|
|
|
throw new Error(message);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
async function postRebootCommand(dryRun, confirmText) {
|
|
const label = dryRun ? "Dry-run reboot" : "Reboot";
|
|
|
|
try {
|
|
setMinerControlStatus(`${label} command is being queued for Windows Agent...`, "warn");
|
|
await postAgentMinerControl("reboot", dryRun, label);
|
|
} catch (err) {
|
|
setMinerControlStatus(`${label} failed: ${err.message}`, "bad");
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function extractPerformanceModeFromSnapshot(snapshot) {
|
|
if (!snapshot) {
|
|
return "";
|
|
}
|
|
|
|
const raw = snapshot.raw_summary_json || snapshot.raw_summary || snapshot.summary || {};
|
|
const msg = raw.Msg || raw.MSG || raw.msg || {};
|
|
|
|
return (
|
|
msg["Power Mode"] ||
|
|
msg.PowerMode ||
|
|
msg.power_mode ||
|
|
raw["Power Mode"] ||
|
|
raw.PowerMode ||
|
|
raw.power_mode ||
|
|
""
|
|
);
|
|
}
|
|
|
|
function setCurrentPerformanceModeBadge(mode, suffix) {
|
|
const badge = document.getElementById("currentPerformanceModeBadge");
|
|
|
|
if (!badge) {
|
|
return;
|
|
}
|
|
|
|
const cleanMode = mode ? String(mode).trim() : "Unknown";
|
|
const extra = suffix ? " " + suffix : "";
|
|
|
|
badge.textContent = "Configured Mode: " + cleanMode + extra;
|
|
|
|
const lowered = cleanMode.toLowerCase();
|
|
|
|
if (lowered === "low") {
|
|
badge.style.background = "#dcfce7";
|
|
badge.style.color = "#166534";
|
|
} else if (lowered === "normal") {
|
|
badge.style.background = "#dbeafe";
|
|
badge.style.color = "#1e40af";
|
|
} else if (lowered === "high") {
|
|
badge.style.background = "#fee2e2";
|
|
badge.style.color = "#991b1b";
|
|
} else {
|
|
badge.style.background = "#e5e7eb";
|
|
badge.style.color = "#111827";
|
|
}
|
|
}
|
|
|
|
async function loadCurrentPerformanceModeBadge() {
|
|
try {
|
|
const minerId = getMinerIdForAgentControl();
|
|
|
|
if (!minerId) {
|
|
setCurrentPerformanceModeBadge("Unknown");
|
|
return;
|
|
}
|
|
|
|
const headers = {};
|
|
const token = getMinerOpsTokenForAgentControl();
|
|
|
|
if (token) {
|
|
headers.Authorization = "Bearer " + token;
|
|
}
|
|
|
|
const response = await fetch(`/api/miners/${encodeURIComponent(minerId)}/configured-performance-mode`, {
|
|
method: "GET",
|
|
headers
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(response.statusText || "Snapshot request failed");
|
|
}
|
|
|
|
const body = await response.json();
|
|
const data = body.data || body || {};
|
|
const mode = data.configured_performance_mode || "Unknown";
|
|
const runtimeState = data.runtime_power_state ? "runtime: " + data.runtime_power_state : "";
|
|
setCurrentPerformanceModeBadge(mode || "Unknown", runtimeState ? "(" + runtimeState + ")" : "");
|
|
} catch (err) {
|
|
setCurrentPerformanceModeBadge("Unknown");
|
|
}
|
|
}
|
|
|
|
|
|
async function setPerformanceMode() {
|
|
const select = document.getElementById("performanceModeSelect");
|
|
const mode = (select && select.value ? select.value : "normal").toLowerCase();
|
|
|
|
let warning = "Set this miner to " + mode.toUpperCase() + " performance mode?";
|
|
|
|
if (mode === "high") {
|
|
warning += "\n\nHigh mode can increase heat and power use. Avoid this on miners with temperature errors.";
|
|
}
|
|
|
|
if (!confirm(warning)) {
|
|
setMinerControlStatus("Performance mode change cancelled.", "warn");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
window.__mineropsPerformanceMode = mode;
|
|
setMinerControlStatus("Performance mode command is being queued for Windows Agent...", "warn");
|
|
await postAgentMinerControl("performance_mode", false, "Performance mode " + mode.toUpperCase());
|
|
setCurrentPerformanceModeBadge(mode, "(queued)");
|
|
} catch (err) {
|
|
setMinerControlStatus("Performance mode failed: " + err.message, "bad");
|
|
} finally {
|
|
window.__mineropsPerformanceMode = null;
|
|
}
|
|
}
|
|
|
|
|
|
async function dryRunMinerReboot() {
|
|
await postRebootCommand(true, "DRYRUN");
|
|
}
|
|
|
|
async function realRebootMiner() {
|
|
const confirmed = confirm(
|
|
"Reboot this miner now?\n\nThis will send the reboot command immediately."
|
|
);
|
|
|
|
if (!confirmed) {
|
|
setMinerControlStatus("Real reboot cancelled.", "warn");
|
|
return;
|
|
}
|
|
|
|
await postRebootCommand(false, "REBOOT");
|
|
}
|
|
|
|
async function postPowerCommand(action, label) {
|
|
try {
|
|
setMinerControlStatus(`${label} command is being queued for Windows Agent...`, "warn");
|
|
await postAgentMinerControl(action, false, label);
|
|
} catch (err) {
|
|
setMinerControlStatus(`${label} failed: ${err.message}`, "bad");
|
|
}
|
|
}
|
|
|
|
async function powerOffMiner() {
|
|
const confirmed = confirm(
|
|
"Suspend this miner now?\n\nThis will use the authenticated WhatsMiner API3 service stop command."
|
|
);
|
|
|
|
if (!confirmed) {
|
|
setMinerControlStatus("Suspend cancelled.", "warn");
|
|
return;
|
|
}
|
|
|
|
await postPowerCommand("suspend", "Suspend");
|
|
}
|
|
|
|
async function powerOnMiner() {
|
|
const confirmed = confirm(
|
|
"Resume this miner now?\n\nThis will use the authenticated WhatsMiner API3 service start command."
|
|
);
|
|
|
|
if (!confirmed) {
|
|
setMinerControlStatus("Resume cancelled.", "warn");
|
|
return;
|
|
}
|
|
|
|
await postPowerCommand("resume", "Resume");
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) {
|
|
return "-";
|
|
}
|
|
|
|
try {
|
|
return new Date(value).toLocaleString();
|
|
} catch (error) {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
async function loadMinerCommandHistory() {
|
|
const minerId = minerIdFromPage();
|
|
const body = document.getElementById("minerCommandHistoryBody");
|
|
|
|
if (!minerId || !body) {
|
|
return;
|
|
}
|
|
|
|
const token = getToken();
|
|
|
|
if (!token) {
|
|
body.innerHTML = '<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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
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(" ")
|
|
: "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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
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(" ")
|
|
: "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">← 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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}}
|
|
|
|
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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
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:
|
|
return settings.minerops_agent_shared_secret.strip()
|
|
|
|
|
|
def minerops_require_agent_secret(request: Request):
|
|
import hmac
|
|
|
|
expected = minerops_agent_secret_expected()
|
|
provided = str(request.headers.get("X-MinerOps-Agent-Secret", "") or "").strip()
|
|
|
|
if not expected:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="MINEROPS_AGENT_SHARED_SECRET is not configured on the hub.",
|
|
)
|
|
|
|
if not provided or not hmac.compare_digest(provided, expected):
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Invalid or missing agent secret.",
|
|
)
|
|
|
|
|
|
async def ensure_minerops_agent_tables():
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
await conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS minerops_site_agents (
|
|
agent_id TEXT PRIMARY KEY,
|
|
site_name TEXT NOT NULL,
|
|
hostname TEXT,
|
|
version TEXT,
|
|
ip_address TEXT,
|
|
last_checkin_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
last_payload JSONB NOT NULL DEFAULT '{}'::jsonb
|
|
)
|
|
"""
|
|
)
|
|
|
|
await conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS minerops_agent_snapshots (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
agent_id TEXT NOT NULL,
|
|
site_name TEXT NOT NULL,
|
|
snapshot_kind TEXT NOT NULL DEFAULT 'miner_poll',
|
|
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
miner_count INTEGER NOT NULL DEFAULT 0,
|
|
payload JSONB NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
|
|
await conn.execute(
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS idx_minerops_agent_snapshots_received
|
|
ON minerops_agent_snapshots (received_at DESC)
|
|
"""
|
|
)
|
|
|
|
await conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS minerops_agent_command_results (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
agent_id TEXT NOT NULL,
|
|
site_name TEXT NOT NULL,
|
|
command_id TEXT,
|
|
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
status TEXT NOT NULL,
|
|
payload JSONB NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
|
|
await conn.execute(
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS idx_minerops_agent_command_results_received
|
|
ON minerops_agent_command_results (received_at DESC)
|
|
"""
|
|
)
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def startup_ensure_minerops_agent_tables():
|
|
await ensure_minerops_agent_tables()
|
|
|
|
|
|
|
|
@app.get("/api/agent/config")
|
|
async def minerops_agent_config(request: Request):
|
|
minerops_require_agent_secret(request)
|
|
|
|
return {
|
|
"ok": True,
|
|
"site_name": "Plexus",
|
|
"poll_interval_seconds": 120,
|
|
"hub_url": "https://plexus.lussier.cc",
|
|
"ip_ranges": [
|
|
{
|
|
"name": "Unit 4",
|
|
"cidr": "10.30.0.0/22",
|
|
"enabled": True,
|
|
"api_port": 4028,
|
|
"api3_port": 4433,
|
|
},
|
|
{
|
|
"name": "Unit 6",
|
|
"cidr": "10.80.0.0/22",
|
|
"enabled": True,
|
|
"api_port": 4028,
|
|
"api3_port": 4433,
|
|
},
|
|
{
|
|
"name": "Unit 12",
|
|
"cidr": "10.90.0.0/22",
|
|
"enabled": False,
|
|
"api_port": 4028,
|
|
"api3_port": 4433,
|
|
},
|
|
],
|
|
"message": "Agent config from hub.",
|
|
}
|
|
|
|
|
|
@app.post("/api/agent/miner-snapshots")
|
|
async def minerops_agent_miner_snapshots(
|
|
request: Request,
|
|
authenticated_agent_id: str = Depends(require_agent_identity),
|
|
zone_service: AgentZoneService = Depends(get_agent_zone_service),
|
|
):
|
|
import json
|
|
|
|
await ensure_minerops_agent_tables()
|
|
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
|
|
zone_service.validate_agent_identity(
|
|
authenticated_agent_id, str(body.get("agent_id") or "").strip() or None
|
|
)
|
|
site_name = str(body.get("site_name") or "Unassigned").strip()
|
|
miners = body.get("miners")
|
|
if not isinstance(miners, list):
|
|
miners = []
|
|
|
|
miner_ids = []
|
|
for item in miners:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
try:
|
|
miner_ids.append(UUID(str(item.get("miner_id") or "")))
|
|
except ValueError:
|
|
continue
|
|
ownership = zone_service.associate_miners_from_agent(
|
|
authenticated_agent_id, miner_ids
|
|
)
|
|
|
|
conn = await minerops_command_db_connect()
|
|
try:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO minerops_agent_snapshots (
|
|
agent_id, agent_zone_id, site_name, snapshot_kind,
|
|
miner_count, payload
|
|
)
|
|
VALUES ($1, $2::uuid, $3, 'miner_poll', $4, $5::jsonb)
|
|
RETURNING id, received_at
|
|
""",
|
|
authenticated_agent_id,
|
|
str(ownership.agent_zone_id) if ownership.agent_zone_id else None,
|
|
site_name,
|
|
len(miners),
|
|
json.dumps(body),
|
|
)
|
|
finally:
|
|
await conn.close()
|
|
|
|
apply_result = await minerops_apply_agent_snapshot_to_live_tables(
|
|
body, ownership
|
|
)
|
|
return {
|
|
"ok": True,
|
|
"snapshot_id": row["id"] if row else None,
|
|
"received_at": row["received_at"].isoformat() if row and row["received_at"] else None,
|
|
"miner_count": len(miners),
|
|
"agent_zone_id": str(ownership.agent_zone_id) if ownership.agent_zone_id else None,
|
|
"routing_reason": ownership.routing_reason,
|
|
"apply_result": apply_result,
|
|
"message": "Miner snapshot received, staged, and applied to live tables.",
|
|
}
|
|
|
|
|
|
@app.get("/api/agent/commands/pending-disabled-old")
|
|
async def minerops_agent_commands_pending(request: Request):
|
|
minerops_require_agent_secret(request)
|
|
|
|
# Command queue will be wired in the next step.
|
|
return {
|
|
"ok": True,
|
|
"commands": [],
|
|
"message": "No pending commands.",
|
|
}
|
|
|
|
|
|
@app.post("/api/agent/commands/result-disabled-old")
|
|
async def minerops_agent_commands_result(request: Request):
|
|
import json
|
|
|
|
minerops_require_agent_secret(request)
|
|
await ensure_minerops_agent_tables()
|
|
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
|
|
agent_id = str(body.get("agent_id") or "plexus-windows-agent").strip()
|
|
site_name = str(body.get("site_name") or "Plexus").strip()
|
|
command_id = str(body.get("command_id") or "").strip()
|
|
status = str(body.get("status") or "unknown").strip()
|
|
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO minerops_agent_command_results (
|
|
agent_id,
|
|
site_name,
|
|
command_id,
|
|
status,
|
|
payload
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5::jsonb)
|
|
RETURNING id, received_at
|
|
""",
|
|
agent_id,
|
|
site_name,
|
|
command_id,
|
|
status,
|
|
json.dumps(body),
|
|
)
|
|
finally:
|
|
await conn.close()
|
|
|
|
return {
|
|
"ok": True,
|
|
"result_id": row["id"] if row else None,
|
|
"received_at": row["received_at"].isoformat() if row and row["received_at"] else None,
|
|
"message": "Command result received.",
|
|
}
|
|
|
|
|
|
|
|
# -----------------------------
|
|
# Phase 5H.1: Agent setup page
|
|
# -----------------------------
|
|
|
|
@app.get("/api/agent/setup-info")
|
|
async def minerops_agent_setup_info(current_user: dict = Depends(get_current_user)):
|
|
secret = minerops_agent_secret_expected()
|
|
|
|
return {
|
|
"ok": True,
|
|
"hub_url": "https://plexus.lussier.cc",
|
|
"agent_id": settings.minerops_agent_id,
|
|
"site_name": "Plexus",
|
|
"secret_configured": bool(secret),
|
|
"config_path": "C:\\MinerOpsAgent\\agent-config.json",
|
|
"message": "Agent credentials are provisioned locally and are never returned to browsers.",
|
|
}
|
|
|
|
|
|
@app.get("/agent/setup")
|
|
async def minerops_agent_setup_page():
|
|
html = """
|
|
<!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">
|
|
Agent credentials must be provisioned directly on the Windows host.
|
|
</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>
|
|
|
|
<p id="secretStatus" class="small"></p>
|
|
</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("secretStatus").textContent = data.secret_configured
|
|
? "A shared Agent secret is configured on the Hub. Its value is not exposed here."
|
|
: "The Hub does not currently have an Agent secret configured.";
|
|
|
|
status.className = "ok";
|
|
status.textContent = "Agent setup info loaded.";
|
|
}
|
|
|
|
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(
|
|
authenticated_agent_id: str = Depends(require_agent_identity),
|
|
zone_service: AgentZoneService = Depends(get_agent_zone_service),
|
|
):
|
|
routing = zone_service.routing_context(authenticated_agent_id)
|
|
if routing.routing_reason or routing.agent_zone_id is None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=(
|
|
"Agent routing unavailable: "
|
|
+ (routing.routing_reason or "agent_zone_unassigned")
|
|
),
|
|
)
|
|
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT
|
|
miners.id::text AS miner_id,
|
|
host(miners.current_ip) AS ip_address,
|
|
miners.current_mac AS mac_address,
|
|
COALESCE(rooms.name, 'Unassigned') AS room_name,
|
|
COALESCE(NULLIF(miners.miner_type, ''), NULLIF(miners.model, ''), '') AS model,
|
|
COALESCE(miners.status, '') AS status
|
|
FROM miners
|
|
LEFT JOIN rooms ON rooms.id = miners.room_id
|
|
WHERE miners.current_ip IS NOT NULL
|
|
AND miners.agent_zone_id = $1::uuid
|
|
ORDER BY rooms.sort_order NULLS LAST, rooms.name NULLS LAST, miners.current_ip
|
|
""",
|
|
str(routing.agent_zone_id),
|
|
)
|
|
finally:
|
|
await conn.close()
|
|
|
|
miners = []
|
|
|
|
for row in rows:
|
|
miners.append(
|
|
{
|
|
"miner_id": row["miner_id"],
|
|
"ip_address": row["ip_address"],
|
|
"mac_address": row["mac_address"],
|
|
"room_name": row["room_name"],
|
|
"model": row["model"],
|
|
"status": row["status"],
|
|
}
|
|
)
|
|
|
|
return {
|
|
"ok": True,
|
|
"count": len(miners),
|
|
"miners": miners,
|
|
"message": "Known miners for site agent polling.",
|
|
}
|
|
|
|
|
|
|
|
# -----------------------------
|
|
# Phase 5J: Apply Windows Agent snapshots to live miner tables
|
|
# -----------------------------
|
|
|
|
def minerops_agent_to_float(value):
|
|
if value is None:
|
|
return None
|
|
|
|
try:
|
|
text = str(value).strip()
|
|
|
|
if text == "":
|
|
return None
|
|
|
|
return float(text)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def minerops_agent_to_int(value):
|
|
number = minerops_agent_to_float(value)
|
|
|
|
if number is None:
|
|
return None
|
|
|
|
try:
|
|
return int(number)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def minerops_agent_to_bool(value):
|
|
if value is None:
|
|
return None
|
|
|
|
if isinstance(value, bool):
|
|
return value
|
|
|
|
text = str(value).strip().lower()
|
|
|
|
if text in {"true", "1", "yes", "y", "stable"}:
|
|
return True
|
|
|
|
if text in {"false", "0", "no", "n", "unstable"}:
|
|
return False
|
|
|
|
return None
|
|
|
|
|
|
def minerops_agent_parse_summary(miner_payload: dict) -> dict:
|
|
import json
|
|
|
|
responses = miner_payload.get("responses") or {}
|
|
summary = responses.get("summary") or {}
|
|
|
|
raw = summary.get("raw") or ""
|
|
parsed = summary.get("json")
|
|
|
|
if parsed is None and raw:
|
|
try:
|
|
parsed = json.loads(raw)
|
|
except Exception:
|
|
parsed = None
|
|
|
|
if not isinstance(parsed, dict):
|
|
parsed = {}
|
|
|
|
msg = parsed.get("Msg") or parsed.get("msg") or {}
|
|
|
|
if not isinstance(msg, dict):
|
|
msg = {}
|
|
|
|
return {
|
|
"raw": parsed,
|
|
"msg": msg,
|
|
}
|
|
|
|
|
|
async def minerops_apply_agent_snapshot_to_live_tables(
|
|
snapshot_body: dict, ownership: MinerZoneAssociationResult
|
|
) -> dict:
|
|
import json
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
|
|
miners = snapshot_body.get("miners")
|
|
if not isinstance(miners, list):
|
|
miners = []
|
|
|
|
now = datetime.now(timezone.utc)
|
|
applied = 0
|
|
online_count = 0
|
|
offline_count = 0
|
|
errors = []
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
for item in miners:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
|
|
miner_id = str(item.get("miner_id") or "").strip()
|
|
ip_address = str(item.get("ip_address") or "").strip()
|
|
online = bool(item.get("online"))
|
|
if not miner_id:
|
|
continue
|
|
try:
|
|
miner_uuid = UUID(miner_id)
|
|
except ValueError:
|
|
errors.append({"miner_id": miner_id, "error": "invalid_miner_id"})
|
|
continue
|
|
|
|
if ownership.routing_reason:
|
|
errors.append(
|
|
{"miner_id": miner_id, "error": ownership.routing_reason}
|
|
)
|
|
continue
|
|
zone_outcome = ownership.outcomes.get(miner_uuid, "miner_not_found")
|
|
if zone_outcome not in {"assigned", "preserved"}:
|
|
errors.append({"miner_id": miner_id, "error": zone_outcome})
|
|
continue
|
|
|
|
parsed = minerops_agent_parse_summary(item)
|
|
raw_summary = parsed["raw"]
|
|
msg = parsed["msg"]
|
|
status = "online" if online else "offline"
|
|
mhs_av = minerops_agent_to_float(msg.get("MHS av"))
|
|
mhs_1m = minerops_agent_to_float(msg.get("MHS 1m"))
|
|
mhs_15m = minerops_agent_to_float(msg.get("MHS 15m"))
|
|
hs_rt = minerops_agent_to_float(msg.get("HS RT"))
|
|
power_w = minerops_agent_to_float(msg.get("Power"))
|
|
power_rate = minerops_agent_to_float(msg.get("Power Rate"))
|
|
env_temp = minerops_agent_to_float(msg.get("Env Temp"))
|
|
chip_temp_min = minerops_agent_to_float(msg.get("Chip Temp Min"))
|
|
chip_temp_max = minerops_agent_to_float(msg.get("Chip Temp Max"))
|
|
chip_temp_avg = minerops_agent_to_float(msg.get("Chip Temp Avg"))
|
|
fan_in = minerops_agent_to_int(msg.get("Fan Speed In"))
|
|
fan_out = minerops_agent_to_int(msg.get("Fan Speed Out"))
|
|
uptime = minerops_agent_to_int(msg.get("Uptime"))
|
|
hash_stable = minerops_agent_to_bool(msg.get("Hash Stable"))
|
|
pool_rejected = minerops_agent_to_float(msg.get("Pool Rejected%"))
|
|
|
|
try:
|
|
if online:
|
|
online_count += 1
|
|
await conn.execute(
|
|
"""
|
|
UPDATE miners
|
|
SET status = $2,
|
|
current_ip = COALESCE(NULLIF($3, '')::inet, current_ip),
|
|
last_seen_at = $4,
|
|
updated_at = now()
|
|
WHERE id = $1::uuid
|
|
""",
|
|
miner_id,
|
|
status,
|
|
ip_address,
|
|
now,
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO miner_status_snapshots (
|
|
id, miner_id, observed_ip, observed_at, mhs_av,
|
|
mhs_1m, mhs_15m, hs_rt, power_w,
|
|
power_rate_w_per_th, env_temp_c, chip_temp_min_c,
|
|
chip_temp_max_c, chip_temp_avg_c, fan_speed_in_rpm,
|
|
fan_speed_out_rpm, uptime_seconds, hash_stable,
|
|
pool_rejected_percent, raw_summary_json, created_at
|
|
)
|
|
VALUES (
|
|
$1::uuid, $2::uuid, NULLIF($3, '')::inet, $4,
|
|
$5, $6, $7, $8, $9, $10, $11, $12, $13, $14,
|
|
$15, $16, $17, $18, $19, $20::jsonb, now()
|
|
)
|
|
""",
|
|
str(uuid.uuid4()),
|
|
miner_id,
|
|
ip_address,
|
|
now,
|
|
mhs_av,
|
|
mhs_1m,
|
|
mhs_15m,
|
|
hs_rt,
|
|
power_w,
|
|
power_rate,
|
|
env_temp,
|
|
chip_temp_min,
|
|
chip_temp_max,
|
|
chip_temp_avg,
|
|
fan_in,
|
|
fan_out,
|
|
uptime,
|
|
hash_stable,
|
|
pool_rejected,
|
|
json.dumps(raw_summary),
|
|
)
|
|
else:
|
|
offline_count += 1
|
|
await conn.execute(
|
|
"""
|
|
UPDATE miners
|
|
SET status = $2,
|
|
current_ip = COALESCE(NULLIF($3, '')::inet, current_ip),
|
|
updated_at = now()
|
|
WHERE id = $1::uuid
|
|
""",
|
|
miner_id,
|
|
status,
|
|
ip_address,
|
|
)
|
|
applied += 1
|
|
except Exception as exc:
|
|
if len(errors) < 20:
|
|
errors.append(
|
|
{"miner_id": miner_id, "ip_address": ip_address, "error": str(exc)}
|
|
)
|
|
finally:
|
|
await conn.close()
|
|
|
|
return {
|
|
"applied": applied,
|
|
"online": online_count,
|
|
"offline": offline_count,
|
|
"errors": errors,
|
|
}
|
|
|
|
|
|
# -----------------------------
|
|
# Phase 5K: Agent command queue
|
|
# -----------------------------
|
|
|
|
async def minerops_ensure_agent_command_queue_table():
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
await conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS minerops_agent_command_queue (
|
|
id TEXT PRIMARY KEY,
|
|
agent_id TEXT NOT NULL,
|
|
command_type TEXT NOT NULL,
|
|
command_status TEXT NOT NULL DEFAULT 'pending',
|
|
requested_by_user_id TEXT,
|
|
requested_by_username TEXT,
|
|
request_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
result_payload JSONB,
|
|
error_message TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
claimed_at TIMESTAMPTZ,
|
|
completed_at TIMESTAMPTZ
|
|
)
|
|
"""
|
|
)
|
|
|
|
await conn.execute(
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS idx_minerops_agent_command_queue_agent_status
|
|
ON minerops_agent_command_queue(agent_id, command_status, created_at)
|
|
"""
|
|
)
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def startup_ensure_minerops_agent_command_queue_table():
|
|
await minerops_ensure_agent_command_queue_table()
|
|
|
|
|
|
@app.post("/api/agent/commands/run-poll-now")
|
|
async def minerops_queue_agent_poll_now(current_user: dict = Depends(get_current_user)):
|
|
import uuid
|
|
import json
|
|
|
|
await minerops_ensure_agent_command_queue_table()
|
|
|
|
command_id = str(uuid.uuid4())
|
|
username = str(current_user.get("username") or current_user.get("email") or "unknown")
|
|
user_id = str(current_user.get("id") or "")
|
|
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO minerops_agent_command_queue (
|
|
id,
|
|
agent_id,
|
|
command_type,
|
|
command_status,
|
|
requested_by_user_id,
|
|
requested_by_username,
|
|
request_payload,
|
|
created_at
|
|
)
|
|
VALUES (
|
|
$1,
|
|
$2,
|
|
$3,
|
|
'pending',
|
|
$4,
|
|
$5,
|
|
$6::jsonb,
|
|
now()
|
|
)
|
|
""",
|
|
command_id,
|
|
"plexus-windows-agent",
|
|
"poll_known_miners_now",
|
|
user_id,
|
|
username,
|
|
json.dumps({"source": "web", "reason": "manual_poll_now"}),
|
|
)
|
|
finally:
|
|
await conn.close()
|
|
|
|
return {
|
|
"ok": True,
|
|
"command_id": command_id,
|
|
"command_type": "poll_known_miners_now",
|
|
"message": "Agent poll command queued.",
|
|
}
|
|
|
|
|
|
@app.get("/api/agent/commands/status")
|
|
async def minerops_agent_command_status(current_user: dict = Depends(get_current_user)):
|
|
await minerops_ensure_agent_command_queue_table()
|
|
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT
|
|
id,
|
|
agent_id,
|
|
command_type,
|
|
command_status,
|
|
requested_by_username,
|
|
error_message,
|
|
created_at,
|
|
claimed_at,
|
|
completed_at,
|
|
result_payload
|
|
FROM minerops_agent_command_queue
|
|
ORDER BY created_at DESC
|
|
LIMIT 20
|
|
"""
|
|
)
|
|
finally:
|
|
await conn.close()
|
|
|
|
commands = []
|
|
|
|
for row in rows:
|
|
commands.append(
|
|
{
|
|
"id": row["id"],
|
|
"agent_id": row["agent_id"],
|
|
"command_type": row["command_type"],
|
|
"command_status": row["command_status"],
|
|
"requested_by_username": row["requested_by_username"],
|
|
"error_message": row["error_message"],
|
|
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
|
"claimed_at": row["claimed_at"].isoformat() if row["claimed_at"] else None,
|
|
"completed_at": row["completed_at"].isoformat() if row["completed_at"] else None,
|
|
"result_payload": row["result_payload"],
|
|
}
|
|
)
|
|
|
|
return {
|
|
"ok": True,
|
|
"commands": commands,
|
|
}
|
|
|
|
|
|
|
|
# -----------------------------
|
|
# Phase 5K.5: Agent control web page
|
|
# -----------------------------
|
|
|
|
@app.get("/api/agent/status-summary")
|
|
async def minerops_agent_status_summary(current_user: dict = Depends(get_current_user)):
|
|
await minerops_ensure_agent_command_queue_table()
|
|
await ensure_minerops_agent_tables()
|
|
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
agent = await conn.fetchrow(
|
|
"""
|
|
SELECT
|
|
agent_id,
|
|
site_name,
|
|
hostname,
|
|
version,
|
|
ip_address,
|
|
last_checkin_at,
|
|
last_payload
|
|
FROM minerops_site_agents
|
|
WHERE agent_id = 'plexus-windows-agent'
|
|
ORDER BY last_checkin_at DESC
|
|
LIMIT 1
|
|
"""
|
|
)
|
|
|
|
snapshot = await conn.fetchrow(
|
|
"""
|
|
SELECT
|
|
id,
|
|
received_at,
|
|
miner_count,
|
|
payload
|
|
FROM minerops_agent_snapshots
|
|
WHERE agent_id = 'plexus-windows-agent'
|
|
ORDER BY id DESC
|
|
LIMIT 1
|
|
"""
|
|
)
|
|
|
|
latest_counts = await conn.fetchrow(
|
|
"""
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE status = 'online') AS online_count,
|
|
COUNT(*) FILTER (WHERE status = 'offline') AS offline_count,
|
|
COUNT(*) AS total_count
|
|
FROM miners
|
|
"""
|
|
)
|
|
|
|
recent_commands = await conn.fetch(
|
|
"""
|
|
SELECT
|
|
id,
|
|
command_type,
|
|
command_status,
|
|
requested_by_username,
|
|
created_at,
|
|
claimed_at,
|
|
completed_at,
|
|
error_message,
|
|
result_payload
|
|
FROM minerops_agent_command_queue
|
|
ORDER BY created_at DESC
|
|
LIMIT 10
|
|
"""
|
|
)
|
|
finally:
|
|
await conn.close()
|
|
|
|
commands = []
|
|
|
|
for row in recent_commands:
|
|
commands.append(
|
|
{
|
|
"id": row["id"],
|
|
"command_type": row["command_type"],
|
|
"command_status": row["command_status"],
|
|
"requested_by_username": row["requested_by_username"],
|
|
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
|
"claimed_at": row["claimed_at"].isoformat() if row["claimed_at"] else None,
|
|
"completed_at": row["completed_at"].isoformat() if row["completed_at"] else None,
|
|
"error_message": row["error_message"],
|
|
"result_payload": row["result_payload"],
|
|
}
|
|
)
|
|
|
|
latest_snapshot_payload = snapshot["payload"] if snapshot else {}
|
|
latest_miners = latest_snapshot_payload.get("miners") if isinstance(latest_snapshot_payload, dict) else []
|
|
latest_online = 0
|
|
latest_offline = 0
|
|
|
|
if isinstance(latest_miners, list):
|
|
for item in latest_miners:
|
|
if isinstance(item, dict) and item.get("online"):
|
|
latest_online += 1
|
|
else:
|
|
latest_offline += 1
|
|
|
|
return {
|
|
"ok": True,
|
|
"agent": {
|
|
"agent_id": agent["agent_id"] if agent else None,
|
|
"site_name": agent["site_name"] if agent else None,
|
|
"hostname": agent["hostname"] if agent else None,
|
|
"version": agent["version"] if agent else None,
|
|
"ip_address": agent["ip_address"] if agent else None,
|
|
"last_checkin_at": agent["last_checkin_at"].isoformat() if agent and agent["last_checkin_at"] else None,
|
|
},
|
|
"latest_snapshot": {
|
|
"id": snapshot["id"] if snapshot else None,
|
|
"received_at": snapshot["received_at"].isoformat() if snapshot and snapshot["received_at"] else None,
|
|
"miner_count": snapshot["miner_count"] if snapshot else 0,
|
|
"online": latest_online,
|
|
"offline": latest_offline,
|
|
},
|
|
"live_database_counts": {
|
|
"total": latest_counts["total_count"] if latest_counts else 0,
|
|
"online": latest_counts["online_count"] if latest_counts else 0,
|
|
"offline": latest_counts["offline_count"] if latest_counts else 0,
|
|
},
|
|
"recent_commands": commands,
|
|
}
|
|
|
|
|
|
@app.get("/agent/control")
|
|
async def minerops_agent_control_page():
|
|
html = """
|
|
<!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,
|
|
authenticated_agent_id: str = Depends(require_agent_identity),
|
|
zone_service: AgentZoneService = Depends(get_agent_zone_service),
|
|
):
|
|
import json
|
|
import uuid
|
|
|
|
await minerops_ensure_agent_discovery_table()
|
|
body = await request.json()
|
|
zone_service.validate_agent_identity(
|
|
authenticated_agent_id, str(body.get("agent_id") or "").strip() or None
|
|
)
|
|
site_name = str(body.get("site_name") or "Unassigned").strip()
|
|
discoveries = body.get("discoveries") or []
|
|
if not isinstance(discoveries, list):
|
|
discoveries = []
|
|
|
|
ownership_context = zone_service.associate_miners_from_agent(
|
|
authenticated_agent_id, []
|
|
)
|
|
conn = await minerops_command_db_connect()
|
|
inserted = 0
|
|
matched_existing = 0
|
|
staged_new = 0
|
|
errors = []
|
|
|
|
try:
|
|
for item in discoveries:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
ip_address = str(item.get("ip_address") or "").strip()
|
|
mac_address = str(item.get("mac_address") or "").strip().upper()
|
|
room_name = str(item.get("room_name") or "").strip()
|
|
model = str(item.get("model") or "").strip()
|
|
hostname = str(item.get("hostname") or "").strip()
|
|
miner_sn = str(item.get("miner_sn") or "").strip()
|
|
matched = None
|
|
|
|
if mac_address:
|
|
matched = await conn.fetchrow(
|
|
"SELECT id FROM miners WHERE upper(current_mac) = upper($1) LIMIT 1",
|
|
mac_address,
|
|
)
|
|
if not matched and ip_address:
|
|
matched = await conn.fetchrow(
|
|
"SELECT id FROM miners WHERE current_ip = NULLIF($1, '')::inet LIMIT 1",
|
|
ip_address,
|
|
)
|
|
|
|
status = "known" if matched else "new"
|
|
if matched:
|
|
matched_existing += 1
|
|
association = zone_service.associate_miners_from_agent(
|
|
authenticated_agent_id, [matched["id"]]
|
|
)
|
|
outcome = association.outcomes.get(matched["id"])
|
|
if outcome not in {"assigned", "preserved"}:
|
|
status = "zone_conflict" if outcome else "known"
|
|
if outcome:
|
|
errors.append(
|
|
{"miner_id": str(matched["id"]), "error": outcome}
|
|
)
|
|
else:
|
|
staged_new += 1
|
|
|
|
try:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO minerops_agent_discoveries (
|
|
id, agent_id, agent_zone_id, site_name, room_name,
|
|
ip_address, mac_address, model, hostname, miner_sn,
|
|
raw_payload, discovery_status, matched_miner_id,
|
|
first_seen_at, last_seen_at, created_at, updated_at
|
|
) VALUES (
|
|
$1, $2, $3::uuid, $4, NULLIF($5, ''),
|
|
NULLIF($6, '')::inet, NULLIF($7, ''), NULLIF($8, ''),
|
|
NULLIF($9, ''), NULLIF($10, ''), $11::jsonb, $12,
|
|
$13::uuid, now(), now(), now(), now()
|
|
)
|
|
ON CONFLICT (id) DO NOTHING
|
|
""",
|
|
str(uuid.uuid4()),
|
|
authenticated_agent_id,
|
|
str(ownership_context.agent_zone_id)
|
|
if ownership_context.agent_zone_id
|
|
else None,
|
|
site_name,
|
|
room_name,
|
|
ip_address,
|
|
mac_address,
|
|
model,
|
|
hostname,
|
|
miner_sn,
|
|
json.dumps(item),
|
|
status,
|
|
str(matched["id"]) if matched else None,
|
|
)
|
|
inserted += 1
|
|
except Exception as exc:
|
|
if len(errors) < 20:
|
|
errors.append(
|
|
{"ip_address": ip_address, "mac_address": mac_address, "error": str(exc)}
|
|
)
|
|
finally:
|
|
await conn.close()
|
|
|
|
return {
|
|
"ok": True,
|
|
"received": len(discoveries),
|
|
"inserted": inserted,
|
|
"matched_existing": matched_existing,
|
|
"staged_new": staged_new,
|
|
"agent_zone_id": str(ownership_context.agent_zone_id)
|
|
if ownership_context.agent_zone_id
|
|
else None,
|
|
"routing_reason": ownership_context.routing_reason,
|
|
"errors": errors,
|
|
"message": "Discovery results received.",
|
|
}
|
|
|
|
|
|
@app.get("/api/agent/discoveries")
|
|
async def minerops_agent_discoveries(current_user: dict = Depends(get_current_user)):
|
|
await minerops_ensure_agent_discovery_table()
|
|
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT
|
|
id,
|
|
agent_id,
|
|
site_name,
|
|
room_name,
|
|
ip_address::text AS ip_address,
|
|
mac_address,
|
|
model,
|
|
hostname,
|
|
miner_sn,
|
|
discovery_status,
|
|
matched_miner_id::text AS matched_miner_id,
|
|
first_seen_at,
|
|
last_seen_at,
|
|
created_at
|
|
FROM minerops_agent_discoveries
|
|
ORDER BY created_at DESC
|
|
LIMIT 100
|
|
"""
|
|
)
|
|
finally:
|
|
await conn.close()
|
|
|
|
items = []
|
|
|
|
for row in rows:
|
|
items.append(
|
|
{
|
|
"id": row["id"],
|
|
"agent_id": row["agent_id"],
|
|
"site_name": row["site_name"],
|
|
"room_name": row["room_name"],
|
|
"ip_address": row["ip_address"],
|
|
"mac_address": row["mac_address"],
|
|
"model": row["model"],
|
|
"hostname": row["hostname"],
|
|
"miner_sn": row["miner_sn"],
|
|
"discovery_status": row["discovery_status"],
|
|
"matched_miner_id": row["matched_miner_id"],
|
|
"first_seen_at": row["first_seen_at"].isoformat() if row["first_seen_at"] else None,
|
|
"last_seen_at": row["last_seen_at"].isoformat() if row["last_seen_at"] else None,
|
|
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"ok": True,
|
|
"discoveries": items,
|
|
}
|
|
|
|
|
|
|
|
@app.post("/api/agent/discoveries/review")
|
|
async def minerops_agent_discovery_review(
|
|
request: Request,
|
|
current_user: dict = Depends(get_current_user),
|
|
zone_service: AgentZoneService = Depends(get_agent_zone_service),
|
|
):
|
|
del current_user
|
|
payload = await request.json()
|
|
|
|
ip_address = str(payload.get("ip_address") or "").strip()
|
|
action = str(payload.get("action") or "").strip().lower()
|
|
|
|
if not ip_address:
|
|
raise HTTPException(status_code=400, detail="ip_address is required.")
|
|
|
|
valid_actions = {"add_to_inventory", "ignore", "non_miner"}
|
|
|
|
if action not in valid_actions:
|
|
raise HTTPException(status_code=400, detail="Invalid discovery action.")
|
|
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
discovery = await conn.fetchrow("""
|
|
SELECT
|
|
id,
|
|
agent_id,
|
|
agent_zone_id,
|
|
site_name,
|
|
room_name,
|
|
ip_address::text AS ip_address,
|
|
mac_address,
|
|
model,
|
|
hostname,
|
|
miner_sn,
|
|
discovery_status
|
|
FROM minerops_agent_discoveries
|
|
WHERE ip_address = $1::inet
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
""", ip_address)
|
|
|
|
if not discovery:
|
|
raise HTTPException(status_code=404, detail="Discovery not found.")
|
|
|
|
if action == "ignore":
|
|
await conn.execute("""
|
|
UPDATE minerops_agent_discoveries
|
|
SET
|
|
discovery_status = 'ignored',
|
|
updated_at = now()
|
|
WHERE ip_address = $1::inet
|
|
""", ip_address)
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": action,
|
|
"ip_address": ip_address,
|
|
"message": "Discovery ignored."
|
|
}
|
|
|
|
if action == "non_miner":
|
|
await conn.execute("""
|
|
UPDATE minerops_agent_discoveries
|
|
SET
|
|
discovery_status = 'non_miner',
|
|
updated_at = now()
|
|
WHERE ip_address = $1::inet
|
|
""", ip_address)
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": action,
|
|
"ip_address": ip_address,
|
|
"message": "Discovery marked as non-miner."
|
|
}
|
|
|
|
mac = discovery["mac_address"]
|
|
if mac is not None:
|
|
mac = str(mac).strip() or None
|
|
|
|
existing = None
|
|
|
|
if mac:
|
|
existing = await conn.fetchrow("""
|
|
SELECT id::text AS id
|
|
FROM miners
|
|
WHERE upper(current_mac) = upper($1)
|
|
LIMIT 1
|
|
""", mac)
|
|
|
|
if existing is None:
|
|
existing = await conn.fetchrow("""
|
|
SELECT id::text AS id
|
|
FROM miners
|
|
WHERE current_ip = $1::inet
|
|
LIMIT 1
|
|
""", ip_address)
|
|
|
|
if existing:
|
|
association = zone_service.associate_miners_from_agent(
|
|
discovery["agent_id"], [UUID(existing["id"])]
|
|
)
|
|
ownership_status = association.outcomes.get(
|
|
UUID(existing["id"]), association.routing_reason or "preserved"
|
|
)
|
|
await conn.execute("""
|
|
UPDATE minerops_agent_discoveries
|
|
SET
|
|
discovery_status = 'known',
|
|
matched_miner_id = $2::uuid,
|
|
updated_at = now()
|
|
WHERE ip_address = $1::inet
|
|
""", ip_address, existing["id"])
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": action,
|
|
"ip_address": ip_address,
|
|
"miner_id": existing["id"],
|
|
"ownership_status": ownership_status,
|
|
"message": "Discovery already matched an existing inventory miner."
|
|
}
|
|
|
|
room_id = None
|
|
routing = zone_service.routing_context(
|
|
discovery["agent_id"], discovery["agent_zone_id"]
|
|
)
|
|
site_id = routing.site_id if routing.routing_reason is None else None
|
|
agent_zone_id = (
|
|
routing.agent_zone_id if routing.routing_reason is None else None
|
|
)
|
|
room_name = discovery["room_name"]
|
|
|
|
if room_name and site_id:
|
|
room = await conn.fetchrow(
|
|
"""
|
|
SELECT id::text AS id
|
|
FROM rooms
|
|
WHERE site_id = $1::uuid AND lower(name) = lower($2)
|
|
ORDER BY id
|
|
LIMIT 1
|
|
""",
|
|
str(site_id),
|
|
str(room_name),
|
|
)
|
|
if room:
|
|
room_id = room["id"]
|
|
|
|
model = discovery["model"] or "WhatsMiner"
|
|
hostname = discovery["hostname"]
|
|
miner_sn = discovery["miner_sn"]
|
|
|
|
new_miner = await conn.fetchrow("""
|
|
INSERT INTO miners (
|
|
site_id,
|
|
agent_zone_id,
|
|
room_id,
|
|
current_mac,
|
|
current_ip,
|
|
hostname,
|
|
miner_sn,
|
|
model,
|
|
miner_type,
|
|
status,
|
|
notes,
|
|
first_seen_at,
|
|
last_seen_at,
|
|
created_at,
|
|
updated_at
|
|
)
|
|
VALUES (
|
|
$1::uuid,
|
|
$2::uuid,
|
|
$3::uuid,
|
|
$4,
|
|
$5::inet,
|
|
$6,
|
|
$7,
|
|
$8,
|
|
$8,
|
|
'discovered',
|
|
$9,
|
|
now(),
|
|
now(),
|
|
now(),
|
|
now()
|
|
)
|
|
RETURNING id::text AS id
|
|
""",
|
|
site_id,
|
|
agent_zone_id,
|
|
room_id,
|
|
mac,
|
|
ip_address,
|
|
hostname,
|
|
miner_sn,
|
|
model,
|
|
"Added from Agent Discovery. Review MAC, serial, location, and notes."
|
|
)
|
|
|
|
await conn.execute("""
|
|
UPDATE minerops_agent_discoveries
|
|
SET
|
|
discovery_status = 'added',
|
|
matched_miner_id = $2::uuid,
|
|
updated_at = now()
|
|
WHERE ip_address = $1::inet
|
|
""", ip_address, new_miner["id"])
|
|
|
|
return {
|
|
"ok": True,
|
|
"action": action,
|
|
"ip_address": ip_address,
|
|
"miner_id": new_miner["id"],
|
|
"message": "Discovery added to inventory."
|
|
}
|
|
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
@app.post("/api/agent/miner-enrichment")
|
|
async def minerops_agent_miner_enrichment(
|
|
request: Request,
|
|
authenticated_agent_id: str = Depends(require_agent_identity),
|
|
zone_service: AgentZoneService = Depends(get_agent_zone_service),
|
|
):
|
|
import json
|
|
|
|
routing = zone_service.routing_context(authenticated_agent_id)
|
|
if routing.routing_reason or routing.agent_zone_id is None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=(
|
|
"Agent routing unavailable: "
|
|
+ (routing.routing_reason or "agent_zone_unassigned")
|
|
),
|
|
)
|
|
|
|
payload = await request.json()
|
|
|
|
ip_address = str(payload.get("ip_address") or "").strip()
|
|
miner_id = str(payload.get("miner_id") or "").strip()
|
|
|
|
def clean(value):
|
|
if value is None:
|
|
return None
|
|
value = str(value).strip()
|
|
return value or None
|
|
|
|
def normalize_mac(value):
|
|
value = clean(value)
|
|
if not value:
|
|
return None
|
|
value = value.replace("-", ":").upper()
|
|
return value
|
|
|
|
def jsonb_value(value):
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, (dict, list)):
|
|
return json.dumps(value)
|
|
text_value = str(value).strip()
|
|
if not text_value:
|
|
return None
|
|
try:
|
|
return json.dumps(json.loads(text_value))
|
|
except Exception:
|
|
return json.dumps({"raw": text_value})
|
|
|
|
if not ip_address and not miner_id:
|
|
raise HTTPException(status_code=400, detail="ip_address or miner_id is required.")
|
|
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
miner = None
|
|
|
|
if miner_id:
|
|
miner = await conn.fetchrow("""
|
|
SELECT id::text AS id
|
|
FROM miners
|
|
WHERE id = $1::uuid
|
|
AND agent_zone_id = $2::uuid
|
|
LIMIT 1
|
|
""", miner_id, str(routing.agent_zone_id))
|
|
|
|
if miner is None and ip_address:
|
|
miner = await conn.fetchrow("""
|
|
SELECT id::text AS id
|
|
FROM miners
|
|
WHERE current_ip = $1::inet
|
|
AND agent_zone_id = $2::uuid
|
|
LIMIT 1
|
|
""", ip_address, str(routing.agent_zone_id))
|
|
|
|
if miner is None:
|
|
raise HTTPException(status_code=404, detail="Miner not found.")
|
|
|
|
miner_id = miner["id"]
|
|
|
|
mac = normalize_mac(payload.get("mac_address"))
|
|
hostname = clean(payload.get("hostname"))
|
|
miner_sn = clean(payload.get("miner_sn"))
|
|
model = clean(payload.get("model"))
|
|
miner_type = clean(payload.get("miner_type")) or model
|
|
api_version = clean(payload.get("api_version"))
|
|
firmware_version = clean(payload.get("firmware_version"))
|
|
platform = clean(payload.get("platform"))
|
|
|
|
raw_version_json = jsonb_value(payload.get("raw_version"))
|
|
raw_miner_info_json = jsonb_value(payload.get("raw_miner_info"))
|
|
|
|
await conn.execute("""
|
|
UPDATE miners
|
|
SET
|
|
current_mac = COALESCE($2, current_mac),
|
|
hostname = COALESCE($3, hostname),
|
|
miner_sn = COALESCE($4, miner_sn),
|
|
model = COALESCE($5, model),
|
|
miner_type = COALESCE($6, miner_type),
|
|
api_version = COALESCE($7, api_version),
|
|
firmware_version = COALESCE($8, firmware_version),
|
|
platform = COALESCE($9, platform),
|
|
updated_at = now()
|
|
WHERE id = $1::uuid
|
|
""",
|
|
miner_id,
|
|
mac,
|
|
hostname,
|
|
miner_sn,
|
|
model,
|
|
miner_type,
|
|
api_version,
|
|
firmware_version,
|
|
platform
|
|
)
|
|
|
|
if raw_version_json is not None or raw_miner_info_json is not None:
|
|
await conn.execute("""
|
|
UPDATE miner_status_snapshots
|
|
SET
|
|
raw_version_json = COALESCE($2::jsonb, raw_version_json),
|
|
raw_miner_info_json = COALESCE($3::jsonb, raw_miner_info_json)
|
|
WHERE id = (
|
|
SELECT id
|
|
FROM miner_status_snapshots
|
|
WHERE miner_id = $1::uuid
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
)
|
|
""", miner_id, raw_version_json, raw_miner_info_json)
|
|
|
|
updated = await conn.fetchrow("""
|
|
SELECT
|
|
id::text AS id,
|
|
current_mac,
|
|
host(current_ip) AS current_ip,
|
|
hostname,
|
|
miner_sn,
|
|
model,
|
|
miner_type,
|
|
api_version,
|
|
firmware_version,
|
|
platform,
|
|
status,
|
|
updated_at
|
|
FROM miners
|
|
WHERE id = $1::uuid
|
|
""", miner_id)
|
|
|
|
return {
|
|
"ok": True,
|
|
"miner": dict(updated),
|
|
"message": "Miner enrichment saved."
|
|
}
|
|
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
@app.post("/api/agent/miner-enrichment-v2")
|
|
async def minerops_agent_miner_enrichment_v2(
|
|
request: Request,
|
|
authenticated_agent_id: str = Depends(require_agent_identity),
|
|
zone_service: AgentZoneService = Depends(get_agent_zone_service),
|
|
):
|
|
import json
|
|
|
|
routing = zone_service.routing_context(authenticated_agent_id)
|
|
if routing.routing_reason or routing.agent_zone_id is None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=(
|
|
"Agent routing unavailable: "
|
|
+ (routing.routing_reason or "agent_zone_unassigned")
|
|
),
|
|
)
|
|
|
|
payload = await request.json()
|
|
|
|
ip_address = str(payload.get("ip_address") or "").strip()
|
|
miner_id = str(payload.get("miner_id") or "").strip()
|
|
|
|
def clean(value):
|
|
if value is None:
|
|
return None
|
|
value = str(value).strip()
|
|
return value or None
|
|
|
|
def normalize_mac(value):
|
|
value = clean(value)
|
|
if not value:
|
|
return None
|
|
return value.replace("-", ":").upper()
|
|
|
|
def jsonb_text(value):
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, (dict, list)):
|
|
return json.dumps(value)
|
|
value = str(value).strip()
|
|
if not value:
|
|
return None
|
|
try:
|
|
return json.dumps(json.loads(value))
|
|
except Exception:
|
|
return json.dumps({"raw": value})
|
|
|
|
def safe_dt(value):
|
|
try:
|
|
return value.isoformat()
|
|
except Exception:
|
|
return str(value) if value is not None else None
|
|
|
|
if not ip_address and not miner_id:
|
|
raise HTTPException(status_code=400, detail="ip_address or miner_id is required.")
|
|
|
|
mac = normalize_mac(payload.get("mac_address"))
|
|
hostname = clean(payload.get("hostname"))
|
|
miner_sn = clean(payload.get("miner_sn"))
|
|
model = clean(payload.get("model")) or "WhatsMiner"
|
|
miner_type = clean(payload.get("miner_type")) or model
|
|
api_version = clean(payload.get("api_version"))
|
|
firmware_version = clean(payload.get("firmware_version"))
|
|
platform = clean(payload.get("platform"))
|
|
raw_version_json = jsonb_text(payload.get("raw_version"))
|
|
raw_miner_info_json = jsonb_text(payload.get("raw_miner_info"))
|
|
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
source = None
|
|
|
|
if miner_id:
|
|
source = await conn.fetchrow("""
|
|
SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes
|
|
FROM miners
|
|
WHERE id = $1::uuid
|
|
AND agent_zone_id = $2::uuid
|
|
LIMIT 1
|
|
""", miner_id, str(routing.agent_zone_id))
|
|
|
|
if source is None and ip_address:
|
|
source = await conn.fetchrow("""
|
|
SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes
|
|
FROM miners
|
|
WHERE current_ip = $1::inet
|
|
AND agent_zone_id = $2::uuid
|
|
LIMIT 1
|
|
""", ip_address, str(routing.agent_zone_id))
|
|
|
|
if source is None:
|
|
raise HTTPException(status_code=404, detail="Source miner not found.")
|
|
|
|
source_id = source["id"]
|
|
target_id = source_id
|
|
merged_from_miner_id = None
|
|
source_removed = False
|
|
|
|
if mac:
|
|
existing_mac_miner = await conn.fetchrow("""
|
|
SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes
|
|
FROM miners
|
|
WHERE upper(current_mac) = upper($1)
|
|
AND agent_zone_id = $2::uuid
|
|
LIMIT 1
|
|
""", mac, str(routing.agent_zone_id))
|
|
|
|
if existing_mac_miner and existing_mac_miner["id"] != source_id:
|
|
target_id = existing_mac_miner["id"]
|
|
merged_from_miner_id = source_id
|
|
|
|
await conn.execute("""
|
|
UPDATE miners
|
|
SET
|
|
current_ip = NULL,
|
|
status = 'merged',
|
|
notes = COALESCE(notes, '') || E'\nMerged into miner ' || $2 || ' after enrichment found matching MAC ' || $3 || '.',
|
|
updated_at = now()
|
|
WHERE id = $1::uuid
|
|
""", source_id, target_id, mac)
|
|
|
|
await conn.execute("""
|
|
UPDATE miners
|
|
SET
|
|
current_mac = COALESCE($2, current_mac),
|
|
current_ip = COALESCE($3::inet, current_ip),
|
|
hostname = COALESCE($4, hostname),
|
|
miner_sn = COALESCE($5, miner_sn),
|
|
model = COALESCE($6, model),
|
|
miner_type = COALESCE($7, miner_type),
|
|
api_version = COALESCE($8, api_version),
|
|
firmware_version = COALESCE($9, firmware_version),
|
|
platform = COALESCE($10, platform),
|
|
status = 'online',
|
|
last_seen_at = now(),
|
|
updated_at = now()
|
|
WHERE id = $1::uuid
|
|
""",
|
|
target_id,
|
|
mac,
|
|
ip_address or None,
|
|
hostname,
|
|
miner_sn,
|
|
model,
|
|
miner_type,
|
|
api_version,
|
|
firmware_version,
|
|
platform
|
|
)
|
|
|
|
if merged_from_miner_id:
|
|
await conn.execute("""
|
|
UPDATE miner_status_snapshots
|
|
SET miner_id = $2::uuid
|
|
WHERE miner_id = $1::uuid
|
|
""", merged_from_miner_id, target_id)
|
|
|
|
try:
|
|
await conn.execute("""
|
|
DELETE FROM miners
|
|
WHERE id = $1::uuid
|
|
""", merged_from_miner_id)
|
|
source_removed = True
|
|
except Exception:
|
|
source_removed = False
|
|
|
|
if raw_version_json is not None or raw_miner_info_json is not None:
|
|
await conn.execute("""
|
|
UPDATE miner_status_snapshots
|
|
SET
|
|
raw_version_json = CASE WHEN $2 IS NULL THEN raw_version_json ELSE $2::jsonb END,
|
|
raw_miner_info_json = CASE WHEN $3 IS NULL THEN raw_miner_info_json ELSE $3::jsonb END
|
|
WHERE id = (
|
|
SELECT id
|
|
FROM miner_status_snapshots
|
|
WHERE miner_id = $1::uuid
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
)
|
|
""", target_id, raw_version_json, raw_miner_info_json)
|
|
|
|
if ip_address:
|
|
try:
|
|
await conn.execute("""
|
|
UPDATE minerops_agent_discoveries
|
|
SET
|
|
discovery_status = 'known',
|
|
matched_miner_id = $2::uuid,
|
|
updated_at = now()
|
|
WHERE ip_address = $1::inet
|
|
AND agent_zone_id = $3::uuid
|
|
""", ip_address, target_id, str(routing.agent_zone_id))
|
|
except Exception:
|
|
pass
|
|
|
|
updated = await conn.fetchrow("""
|
|
SELECT
|
|
id::text AS id,
|
|
current_mac,
|
|
host(current_ip) AS current_ip,
|
|
hostname,
|
|
miner_sn,
|
|
model,
|
|
miner_type,
|
|
api_version,
|
|
firmware_version,
|
|
platform,
|
|
status,
|
|
updated_at
|
|
FROM miners
|
|
WHERE id = $1::uuid
|
|
""", target_id)
|
|
|
|
result = dict(updated)
|
|
result["updated_at"] = safe_dt(result.get("updated_at"))
|
|
|
|
return {
|
|
"ok": True,
|
|
"miner": result,
|
|
"target_miner_id": target_id,
|
|
"merged_from_miner_id": merged_from_miner_id,
|
|
"source_removed": source_removed,
|
|
"message": "Miner enrichment saved."
|
|
}
|
|
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
@app.post("/api/agent/miner-enrichment-v3")
|
|
async def minerops_agent_miner_enrichment_v3(
|
|
request: Request,
|
|
authenticated_agent_id: str = Depends(require_agent_identity),
|
|
zone_service: AgentZoneService = Depends(get_agent_zone_service),
|
|
):
|
|
import json
|
|
|
|
routing = zone_service.routing_context(authenticated_agent_id)
|
|
if routing.routing_reason or routing.agent_zone_id is None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=(
|
|
"Agent routing unavailable: "
|
|
+ (routing.routing_reason or "agent_zone_unassigned")
|
|
),
|
|
)
|
|
|
|
payload = await request.json()
|
|
|
|
ip_address = str(payload.get("ip_address") or "").strip()
|
|
miner_id = str(payload.get("miner_id") or "").strip()
|
|
|
|
def clean(value):
|
|
if value is None:
|
|
return None
|
|
value = str(value).strip()
|
|
return value or None
|
|
|
|
def normalize_mac(value):
|
|
value = clean(value)
|
|
if not value:
|
|
return None
|
|
return value.replace("-", ":").upper()
|
|
|
|
def jsonb_text(value):
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, (dict, list)):
|
|
return json.dumps(value)
|
|
value = str(value).strip()
|
|
if not value:
|
|
return None
|
|
try:
|
|
return json.dumps(json.loads(value))
|
|
except Exception:
|
|
return json.dumps({"raw": value})
|
|
|
|
mac = normalize_mac(payload.get("mac_address"))
|
|
hostname = clean(payload.get("hostname"))
|
|
miner_sn = clean(payload.get("miner_sn"))
|
|
model = clean(payload.get("model")) or "WhatsMiner"
|
|
miner_type = clean(payload.get("miner_type")) or model
|
|
api_version = clean(payload.get("api_version"))
|
|
firmware_version = clean(payload.get("firmware_version"))
|
|
platform = clean(payload.get("platform"))
|
|
raw_version_json = jsonb_text(payload.get("raw_version"))
|
|
raw_miner_info_json = jsonb_text(payload.get("raw_miner_info"))
|
|
|
|
if not ip_address and not miner_id:
|
|
raise HTTPException(status_code=400, detail="ip_address or miner_id is required.")
|
|
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
target_id = None
|
|
matched_by = None
|
|
|
|
if mac:
|
|
row = await conn.fetchrow("""
|
|
SELECT id::text AS id
|
|
FROM miners
|
|
WHERE upper(current_mac) = upper($1)
|
|
AND agent_zone_id = $2::uuid
|
|
LIMIT 1
|
|
""", mac, str(routing.agent_zone_id))
|
|
|
|
if row:
|
|
target_id = row["id"]
|
|
matched_by = "mac"
|
|
|
|
if target_id is None and miner_id:
|
|
row = await conn.fetchrow("""
|
|
SELECT id::text AS id
|
|
FROM miners
|
|
WHERE id = $1::uuid
|
|
AND agent_zone_id = $2::uuid
|
|
LIMIT 1
|
|
""", miner_id, str(routing.agent_zone_id))
|
|
|
|
if row:
|
|
target_id = row["id"]
|
|
matched_by = "miner_id"
|
|
|
|
if target_id is None and ip_address:
|
|
row = await conn.fetchrow("""
|
|
SELECT id::text AS id
|
|
FROM miners
|
|
WHERE current_ip = $1::inet
|
|
AND agent_zone_id = $2::uuid
|
|
LIMIT 1
|
|
""", ip_address, str(routing.agent_zone_id))
|
|
|
|
if row:
|
|
target_id = row["id"]
|
|
matched_by = "ip"
|
|
|
|
if target_id:
|
|
await conn.execute("""
|
|
UPDATE miners
|
|
SET
|
|
current_mac = COALESCE($2, current_mac),
|
|
current_ip = COALESCE($3::inet, current_ip),
|
|
hostname = COALESCE($4, hostname),
|
|
miner_sn = COALESCE($5, miner_sn),
|
|
model = COALESCE($6, model),
|
|
miner_type = COALESCE($7, miner_type),
|
|
api_version = COALESCE($8, api_version),
|
|
firmware_version = COALESCE($9, firmware_version),
|
|
platform = COALESCE($10, platform),
|
|
status = 'online',
|
|
last_seen_at = now(),
|
|
updated_at = now()
|
|
WHERE id = $1::uuid
|
|
""",
|
|
target_id,
|
|
mac,
|
|
ip_address or None,
|
|
hostname,
|
|
miner_sn,
|
|
model,
|
|
miner_type,
|
|
api_version,
|
|
firmware_version,
|
|
platform
|
|
)
|
|
|
|
if raw_version_json is not None or raw_miner_info_json is not None:
|
|
await conn.execute("""
|
|
UPDATE miner_status_snapshots
|
|
SET
|
|
raw_version_json = CASE WHEN $2 IS NULL THEN raw_version_json ELSE $2::jsonb END,
|
|
raw_miner_info_json = CASE WHEN $3 IS NULL THEN raw_miner_info_json ELSE $3::jsonb END
|
|
WHERE id = (
|
|
SELECT id
|
|
FROM miner_status_snapshots
|
|
WHERE miner_id = $1::uuid
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
)
|
|
""", target_id, raw_version_json, raw_miner_info_json)
|
|
|
|
if ip_address:
|
|
await conn.execute("""
|
|
UPDATE minerops_agent_discoveries
|
|
SET
|
|
mac_address = COALESCE($2, mac_address),
|
|
model = COALESCE($3, model),
|
|
hostname = COALESCE($4, hostname),
|
|
miner_sn = COALESCE($5, miner_sn),
|
|
discovery_status = 'matched',
|
|
matched_miner_id = $6::uuid,
|
|
raw_payload = COALESCE(raw_payload, '{}'::jsonb) || $7::jsonb,
|
|
updated_at = now()
|
|
WHERE ip_address = $1::inet
|
|
AND agent_zone_id = $8::uuid
|
|
""",
|
|
ip_address,
|
|
mac,
|
|
model,
|
|
hostname,
|
|
miner_sn,
|
|
target_id,
|
|
json.dumps({"enrichment": payload, "matched_by": matched_by}),
|
|
str(routing.agent_zone_id)
|
|
)
|
|
|
|
updated = await conn.fetchrow("""
|
|
SELECT
|
|
id::text AS id,
|
|
current_mac,
|
|
host(current_ip) AS current_ip,
|
|
hostname,
|
|
miner_sn,
|
|
model,
|
|
miner_type,
|
|
api_version,
|
|
firmware_version,
|
|
platform,
|
|
status,
|
|
updated_at
|
|
FROM miners
|
|
WHERE id = $1::uuid
|
|
""", target_id)
|
|
|
|
return {
|
|
"ok": True,
|
|
"matched": True,
|
|
"matched_by": matched_by,
|
|
"miner": dict(updated),
|
|
"message": "Discovery enriched and matched to existing inventory."
|
|
}
|
|
|
|
if ip_address:
|
|
await conn.execute("""
|
|
UPDATE minerops_agent_discoveries
|
|
SET
|
|
mac_address = COALESCE($2, mac_address),
|
|
model = COALESCE($3, model),
|
|
hostname = COALESCE($4, hostname),
|
|
miner_sn = COALESCE($5, miner_sn),
|
|
discovery_status = 'needs_review',
|
|
raw_payload = COALESCE(raw_payload, '{}'::jsonb) || $6::jsonb,
|
|
updated_at = now()
|
|
WHERE ip_address = $1::inet
|
|
AND agent_zone_id = $7::uuid
|
|
""",
|
|
ip_address,
|
|
mac,
|
|
model,
|
|
hostname,
|
|
miner_sn,
|
|
json.dumps({"enrichment": payload, "reason": "no_inventory_match"}),
|
|
str(routing.agent_zone_id)
|
|
)
|
|
|
|
return {
|
|
"ok": True,
|
|
"matched": False,
|
|
"ip_address": ip_address,
|
|
"mac_address": mac,
|
|
"message": "Discovery enriched but did not match existing inventory. Review before adding."
|
|
}
|
|
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
@app.post("/api/agent/discoveries/enrich-match")
|
|
async def minerops_queue_discovery_enrich_match(request: Request, current_user: dict = Depends(get_current_user)):
|
|
payload = await request.json()
|
|
ip_address = str(payload.get("ip_address") or "").strip()
|
|
|
|
if not ip_address:
|
|
raise HTTPException(status_code=400, detail="ip_address is required.")
|
|
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
discovery = await conn.fetchrow("""
|
|
SELECT room_name
|
|
FROM minerops_agent_discoveries
|
|
WHERE ip_address = $1::inet
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
""", ip_address)
|
|
|
|
room_name = discovery["room_name"] if discovery else None
|
|
|
|
command = await conn.fetchrow("""
|
|
INSERT INTO minerops_agent_command_queue (
|
|
id,
|
|
agent_id,
|
|
command_type,
|
|
command_status,
|
|
requested_by_username,
|
|
request_payload,
|
|
created_at
|
|
)
|
|
VALUES (
|
|
gen_random_uuid()::text,
|
|
'plexus-windows-agent',
|
|
'enrich_match_discovery',
|
|
'pending',
|
|
'admin',
|
|
jsonb_build_object(
|
|
'ip_address', $1::text,
|
|
'room_name', $2::text,
|
|
'source', 'agent-control-discovery'
|
|
),
|
|
now()
|
|
)
|
|
RETURNING id
|
|
""", ip_address, room_name)
|
|
|
|
return {
|
|
"ok": True,
|
|
"command_id": command["id"],
|
|
"ip_address": ip_address,
|
|
"message": "Enrich / Match command queued."
|
|
}
|
|
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
@app.get("/api/agent/discoveries/summary")
|
|
async def minerops_agent_discoveries_summary(request: Request, current_user: dict = Depends(get_current_user)):
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
rows = await conn.fetch("""
|
|
SELECT
|
|
discovery_status,
|
|
COUNT(*)::int AS count
|
|
FROM minerops_agent_discoveries
|
|
GROUP BY discovery_status
|
|
ORDER BY discovery_status
|
|
""")
|
|
|
|
latest = await conn.fetchrow("""
|
|
SELECT
|
|
MAX(COALESCE(updated_at, created_at)) AS latest_at,
|
|
COUNT(*)::int AS total
|
|
FROM minerops_agent_discoveries
|
|
""")
|
|
|
|
counts = {}
|
|
|
|
for row in rows:
|
|
counts[row["discovery_status"] or "unknown"] = row["count"]
|
|
|
|
return {
|
|
"ok": True,
|
|
"total": latest["total"] if latest else 0,
|
|
"counts": counts,
|
|
"latest_at": latest["latest_at"].isoformat() if latest and latest["latest_at"] else None
|
|
}
|
|
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
@app.post("/api/miners/{miner_id}/agent-control")
|
|
async def minerops_queue_agent_miner_control(miner_id: str, request: Request, current_user: dict = Depends(get_current_user)):
|
|
payload = await request.json()
|
|
|
|
action = str(payload.get("action") or "").strip().lower()
|
|
dry_run = bool(payload.get("dry_run", True))
|
|
performance_mode = str(payload.get("performance_mode") or payload.get("mode") or "").strip().lower()
|
|
|
|
allowed_actions = {"reboot", "suspend", "resume", "performance_mode"}
|
|
|
|
if action == "performance_mode" and performance_mode not in {"low", "normal", "high"}:
|
|
raise HTTPException(status_code=400, detail="performance_mode must be one of: low, normal, high.")
|
|
|
|
if action not in allowed_actions:
|
|
raise HTTPException(status_code=400, detail="Invalid action. Allowed: reboot, suspend, resume, performance_mode.")
|
|
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
miner = await conn.fetchrow("""
|
|
SELECT
|
|
id::text AS id,
|
|
host(current_ip) AS current_ip,
|
|
current_mac,
|
|
model,
|
|
miner_type,
|
|
status
|
|
FROM miners
|
|
WHERE id = $1::uuid
|
|
LIMIT 1
|
|
""", miner_id)
|
|
|
|
if not miner:
|
|
raise HTTPException(status_code=404, detail="Miner not found.")
|
|
|
|
if not miner["current_ip"]:
|
|
raise HTTPException(status_code=400, detail="Miner has no current IP.")
|
|
|
|
command = await conn.fetchrow("""
|
|
INSERT INTO minerops_agent_command_queue (
|
|
id,
|
|
agent_id,
|
|
command_type,
|
|
command_status,
|
|
requested_by_username,
|
|
request_payload,
|
|
created_at
|
|
)
|
|
VALUES (
|
|
gen_random_uuid()::text,
|
|
'plexus-windows-agent',
|
|
'miner_control',
|
|
'pending',
|
|
'admin',
|
|
jsonb_build_object(
|
|
'miner_id', $1::text,
|
|
'ip_address', $2::text,
|
|
'mac_address', $3::text,
|
|
'model', $4::text,
|
|
'miner_type', $5::text,
|
|
'action', $6::text,
|
|
'dry_run', $7::boolean,
|
|
'performance_mode', $8::text,
|
|
'source', 'miner-detail-agent-control'
|
|
),
|
|
now()
|
|
)
|
|
RETURNING id
|
|
""",
|
|
miner["id"],
|
|
miner["current_ip"],
|
|
miner["current_mac"],
|
|
miner["model"],
|
|
miner["miner_type"],
|
|
action,
|
|
dry_run,
|
|
performance_mode
|
|
)
|
|
|
|
return {
|
|
"ok": True,
|
|
"command_id": command["id"],
|
|
"miner_id": miner["id"],
|
|
"ip_address": miner["current_ip"],
|
|
"action": action,
|
|
"dry_run": dry_run,
|
|
"message": "Miner control command queued for Windows Agent."
|
|
}
|
|
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
|
|
def minerops_ensure_manual_detail_columns():
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
ALTER TABLE miners
|
|
ADD COLUMN IF NOT EXISTS power_sn TEXT,
|
|
ADD COLUMN IF NOT EXISTS manual_details JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
ADD COLUMN IF NOT EXISTS manual_details_updated_at TIMESTAMPTZ,
|
|
ADD COLUMN IF NOT EXISTS manual_details_updated_by TEXT;
|
|
"""
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
|
|
@app.get("/api/miners/{miner_id}/detail-extra")
|
|
def miner_detail_extra(miner_id: str, current_user: dict = Depends(get_current_user)):
|
|
import json as _json
|
|
import datetime as _datetime
|
|
import decimal as _decimal
|
|
import uuid as _uuid
|
|
|
|
def clean(value):
|
|
if value is None:
|
|
return None
|
|
|
|
if isinstance(value, (str, int, float, bool)):
|
|
return value
|
|
|
|
if isinstance(value, (_datetime.datetime, _datetime.date)):
|
|
return value.isoformat()
|
|
|
|
if isinstance(value, _decimal.Decimal):
|
|
return float(value)
|
|
|
|
if isinstance(value, _uuid.UUID):
|
|
return str(value)
|
|
|
|
if isinstance(value, dict):
|
|
return {str(k): clean(v) for k, v in value.items()}
|
|
|
|
if isinstance(value, (list, tuple)):
|
|
return [clean(v) for v in value]
|
|
|
|
return str(value)
|
|
|
|
def sort_hashboard_key(row):
|
|
value = row.get("slot_number", row.get("slot", row.get("Slot", "")))
|
|
|
|
try:
|
|
return int(value)
|
|
except Exception:
|
|
return 9999
|
|
|
|
minerops_ensure_manual_detail_columns()
|
|
|
|
try:
|
|
minerops_ensure_pool_info_table()
|
|
except Exception:
|
|
pass
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
m.*,
|
|
rooms.name AS room_name
|
|
FROM miners m
|
|
LEFT JOIN rooms ON rooms.id = m.room_id
|
|
WHERE m.id = %s::uuid
|
|
LIMIT 1;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
|
|
miner_row = cur.fetchone()
|
|
|
|
if not miner_row:
|
|
raise HTTPException(status_code=404, detail="Miner not found.")
|
|
|
|
miner = dict(miner_row)
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT *
|
|
FROM miner_status_snapshots
|
|
WHERE miner_id = %s::uuid
|
|
ORDER BY created_at DESC
|
|
LIMIT 1;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
|
|
snapshot_row = cur.fetchone()
|
|
latest_snapshot = dict(snapshot_row) if snapshot_row else {}
|
|
|
|
pool_info = []
|
|
|
|
try:
|
|
cur.execute(
|
|
"""
|
|
WITH latest_pool AS (
|
|
SELECT DISTINCT ON (pool_index)
|
|
*
|
|
FROM miner_pool_snapshots
|
|
WHERE miner_id = %s::uuid
|
|
ORDER BY pool_index, created_at DESC
|
|
)
|
|
SELECT *
|
|
FROM latest_pool
|
|
ORDER BY pool_index;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
|
|
pool_info = [dict(row) for row in cur.fetchall()]
|
|
except Exception:
|
|
pool_info = []
|
|
|
|
hashboards = []
|
|
|
|
try:
|
|
cur.execute(
|
|
"""
|
|
SELECT *
|
|
FROM miner_hashboards
|
|
WHERE miner_id = %s::uuid;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
|
|
hashboards = [dict(row) for row in cur.fetchall()]
|
|
hashboards = sorted(hashboards, key=sort_hashboard_key)
|
|
except Exception as exc:
|
|
hashboards = [
|
|
{
|
|
"error": "Could not load hashboards",
|
|
"detail": str(exc),
|
|
}
|
|
]
|
|
|
|
raw_summary = latest_snapshot.get("raw_summary_json") or {}
|
|
summary_obj = {}
|
|
|
|
try:
|
|
if isinstance(raw_summary, str):
|
|
raw_summary = _json.loads(raw_summary)
|
|
|
|
if isinstance(raw_summary, dict):
|
|
if isinstance(raw_summary.get("SUMMARY"), list) and raw_summary.get("SUMMARY"):
|
|
summary_obj = raw_summary["SUMMARY"][0] or {}
|
|
elif isinstance(raw_summary.get("Msg"), dict):
|
|
summary_obj = raw_summary["Msg"] or {}
|
|
except Exception:
|
|
summary_obj = {}
|
|
|
|
summary_keys = []
|
|
|
|
if isinstance(summary_obj, dict):
|
|
summary_keys = sorted([str(k) for k in summary_obj.keys()])
|
|
|
|
data = {
|
|
"miner": miner,
|
|
"latest_snapshot": latest_snapshot,
|
|
"summary_fields": summary_obj,
|
|
"summary_keys": summary_keys,
|
|
"pool_info": pool_info,
|
|
"hashboards": hashboards,
|
|
}
|
|
|
|
return api_response(data=clean(data))
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
@app.patch("/api/miners/{miner_id}/manual-details")
|
|
async def miner_manual_details_update(miner_id: str, request: Request, current_user: dict = Depends(get_current_user)):
|
|
import json as _json
|
|
|
|
minerops_ensure_manual_detail_columns()
|
|
|
|
body = await request.json()
|
|
|
|
editable_columns = [
|
|
"miner_sn",
|
|
"power_sn",
|
|
"hostname",
|
|
"model",
|
|
"miner_type",
|
|
"firmware_version",
|
|
"api_version",
|
|
"platform",
|
|
"notes",
|
|
]
|
|
|
|
updates = {}
|
|
for column in editable_columns:
|
|
if column in body:
|
|
value = body.get(column)
|
|
if value is None:
|
|
updates[column] = None
|
|
else:
|
|
updates[column] = str(value).strip()
|
|
|
|
manual_details = body.get("manual_details", None)
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"SELECT id FROM miners WHERE id = %s::uuid LIMIT 1;",
|
|
(miner_id,),
|
|
)
|
|
|
|
if not cur.fetchone():
|
|
raise HTTPException(status_code=404, detail="Miner not found.")
|
|
|
|
set_parts = []
|
|
params = []
|
|
|
|
for column, value in updates.items():
|
|
set_parts.append(f"{column} = %s")
|
|
params.append(value)
|
|
|
|
if manual_details is not None:
|
|
if isinstance(manual_details, str):
|
|
manual_details = manual_details.strip()
|
|
if manual_details:
|
|
try:
|
|
manual_details = _json.loads(manual_details)
|
|
except Exception:
|
|
raise HTTPException(status_code=400, detail="Manual details must be valid JSON.")
|
|
else:
|
|
manual_details = {}
|
|
|
|
if not isinstance(manual_details, dict):
|
|
raise HTTPException(status_code=400, detail="Manual details must be a JSON object.")
|
|
|
|
set_parts.append("manual_details = %s::jsonb")
|
|
params.append(_json.dumps(manual_details))
|
|
|
|
username = (
|
|
current_user.get("username")
|
|
or current_user.get("email")
|
|
or current_user.get("id")
|
|
or "admin"
|
|
)
|
|
|
|
set_parts.append("manual_details_updated_at = now()")
|
|
set_parts.append("manual_details_updated_by = %s")
|
|
params.append(str(username))
|
|
|
|
set_parts.append("updated_at = now()")
|
|
|
|
params.append(miner_id)
|
|
|
|
sql = "UPDATE miners SET " + ", ".join(set_parts) + " WHERE id = %s::uuid RETURNING *;"
|
|
|
|
cur.execute(sql, params)
|
|
updated = dict(cur.fetchone())
|
|
|
|
conn.commit()
|
|
|
|
return api_response(
|
|
data={"miner": updated},
|
|
meta={"message": "Manual miner details saved."},
|
|
)
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/assets/minerops-miner-detail-extra.js")
|
|
def minerops_miner_detail_extra_js():
|
|
from fastapi.responses import Response as _Response
|
|
|
|
js = r"""
|
|
(function () {
|
|
function getToken() {
|
|
const keys = [
|
|
"minerops_token",
|
|
"mineropsToken",
|
|
"access_token",
|
|
"authToken",
|
|
"token",
|
|
"jwt"
|
|
];
|
|
|
|
for (const key of keys) {
|
|
const value = localStorage.getItem(key) || sessionStorage.getItem(key);
|
|
|
|
if (value) {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function authHeaders(extra) {
|
|
const token = getToken();
|
|
const headers = Object.assign({}, extra || {});
|
|
|
|
if (token) {
|
|
headers["Authorization"] = "Bearer " + token;
|
|
}
|
|
|
|
return headers;
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
if (value === null || value === undefined) {
|
|
return "";
|
|
}
|
|
|
|
return String(value)
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function fmtNumber(value, decimals) {
|
|
const n = Number(value);
|
|
|
|
if (!Number.isFinite(n)) {
|
|
return "";
|
|
}
|
|
|
|
return n.toFixed(decimals || 0);
|
|
}
|
|
|
|
function fmtSeconds(value) {
|
|
const seconds = Number(value || 0);
|
|
|
|
if (!seconds || seconds <= 0) {
|
|
return "";
|
|
}
|
|
|
|
const days = Math.floor(seconds / 86400);
|
|
const hours = Math.floor((seconds % 86400) / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
|
|
if (days > 0) {
|
|
return days + "d " + hours + "h";
|
|
}
|
|
|
|
if (hours > 0) {
|
|
return hours + "h " + minutes + "m";
|
|
}
|
|
|
|
return minutes + "m";
|
|
}
|
|
|
|
function findMinerId() {
|
|
const match = window.location.pathname.match(/\/miners\/([^\/]+)\/detail/);
|
|
|
|
if (!match) {
|
|
return "";
|
|
}
|
|
|
|
return match[1];
|
|
}
|
|
|
|
async function apiGet(url) {
|
|
const response = await fetch(url, {
|
|
headers: authHeaders(),
|
|
credentials: "same-origin"
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await response.text());
|
|
}
|
|
|
|
const payload = await response.json();
|
|
return payload.data || payload;
|
|
}
|
|
|
|
async function apiPatch(url, body) {
|
|
const response = await fetch(url, {
|
|
method: "PATCH",
|
|
headers: authHeaders({
|
|
"Content-Type": "application/json"
|
|
}),
|
|
credentials: "same-origin",
|
|
body: JSON.stringify(body)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await response.text());
|
|
}
|
|
|
|
const payload = await response.json();
|
|
return payload.data || payload;
|
|
}
|
|
|
|
function row(label, value) {
|
|
return `
|
|
<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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function firstValue() {
|
|
for (const value of arguments) {
|
|
if (value !== null && value !== undefined && value !== "") {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function numberValue() {
|
|
const value = firstValue.apply(null, arguments);
|
|
const number = Number(value);
|
|
|
|
if (!Number.isFinite(number)) {
|
|
return "";
|
|
}
|
|
|
|
return number;
|
|
}
|
|
|
|
function fmtNumber(value, decimals) {
|
|
const number = Number(value);
|
|
|
|
if (!Number.isFinite(number)) {
|
|
return "";
|
|
}
|
|
|
|
return number.toFixed(decimals || 0);
|
|
}
|
|
|
|
function fmtSeconds(value) {
|
|
const seconds = Number(value || 0);
|
|
|
|
if (!seconds || seconds <= 0) {
|
|
return "";
|
|
}
|
|
|
|
const days = Math.floor(seconds / 86400);
|
|
const hours = Math.floor((seconds % 86400) / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
|
|
if (days > 0) {
|
|
return days + "d " + hours + "h";
|
|
}
|
|
|
|
if (hours > 0) {
|
|
return hours + "h " + minutes + "m";
|
|
}
|
|
|
|
return minutes + "m";
|
|
}
|
|
|
|
function fmtEpoch(value) {
|
|
const number = Number(value);
|
|
|
|
if (!Number.isFinite(number) || number <= 0) {
|
|
return "";
|
|
}
|
|
|
|
try {
|
|
return new Date(number * 1000).toLocaleString();
|
|
} catch (error) {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
function findMinerId() {
|
|
const match = window.location.pathname.match(/\/miners\/([^\/]+)\/detail/);
|
|
|
|
if (!match) {
|
|
return "";
|
|
}
|
|
|
|
return decodeURIComponent(match[1]);
|
|
}
|
|
|
|
async function apiGet(url) {
|
|
const response = await fetch(url, {
|
|
headers: authHeaders(),
|
|
credentials: "same-origin"
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await response.text());
|
|
}
|
|
|
|
const payload = await response.json();
|
|
return payload.data || payload;
|
|
}
|
|
|
|
async function apiPatch(url, body) {
|
|
const response = await fetch(url, {
|
|
method: "PATCH",
|
|
headers: authHeaders({
|
|
"Content-Type": "application/json"
|
|
}),
|
|
credentials: "same-origin",
|
|
body: JSON.stringify(body)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await response.text());
|
|
}
|
|
|
|
const payload = await response.json();
|
|
return payload.data || payload;
|
|
}
|
|
|
|
function tableRow(label, value) {
|
|
return `
|
|
<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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function findMinerId() {
|
|
const match = window.location.pathname.match(/\/miners\/([^\/]+)\/detail/);
|
|
return match ? decodeURIComponent(match[1]) : "";
|
|
}
|
|
|
|
function fmtNumber(value, decimals) {
|
|
const number = Number(value);
|
|
|
|
if (!Number.isFinite(number)) {
|
|
return "";
|
|
}
|
|
|
|
return number.toFixed(decimals || 0);
|
|
}
|
|
|
|
function fmtSeconds(value) {
|
|
const seconds = Number(value || 0);
|
|
|
|
if (!seconds || seconds <= 0) {
|
|
return "";
|
|
}
|
|
|
|
const days = Math.floor(seconds / 86400);
|
|
const hours = Math.floor((seconds % 86400) / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
|
|
if (days > 0) {
|
|
return days + "d " + hours + "h";
|
|
}
|
|
|
|
if (hours > 0) {
|
|
return hours + "h " + minutes + "m";
|
|
}
|
|
|
|
return minutes + "m";
|
|
}
|
|
|
|
function fmtEpoch(value) {
|
|
const number = Number(value);
|
|
|
|
if (!Number.isFinite(number) || number <= 0) {
|
|
return "";
|
|
}
|
|
|
|
return new Date(number * 1000).toLocaleString();
|
|
}
|
|
|
|
async function apiGet(url) {
|
|
const response = await fetch(url, {
|
|
headers: authHeaders(),
|
|
credentials: "same-origin"
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await response.text());
|
|
}
|
|
|
|
const payload = await response.json();
|
|
return payload.data || payload;
|
|
}
|
|
|
|
async function apiPatch(url, body) {
|
|
const response = await fetch(url, {
|
|
method: "PATCH",
|
|
headers: authHeaders({"Content-Type": "application/json"}),
|
|
credentials: "same-origin",
|
|
body: JSON.stringify(body)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await response.text());
|
|
}
|
|
|
|
const payload = await response.json();
|
|
return payload.data || payload;
|
|
}
|
|
|
|
function findCardByHeading(text) {
|
|
const headings = Array.from(document.querySelectorAll("h1,h2,h3"));
|
|
|
|
for (const heading of headings) {
|
|
if ((heading.textContent || "").trim().toLowerCase() === text.toLowerCase()) {
|
|
return heading.closest(".card") || heading.parentElement;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function removeMessyExtraCards() {
|
|
const ids = [
|
|
"minerExtraDetailsCard",
|
|
"minerExtraDetailsCardV2",
|
|
"minerOpsCleanDetailV3Card"
|
|
];
|
|
|
|
for (const id of ids) {
|
|
const element = document.getElementById(id);
|
|
|
|
if (element) {
|
|
element.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
function metricCard(label, value) {
|
|
return `
|
|
<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,
|
|
authenticated_agent_id: str = Depends(require_agent_identity),
|
|
zone_service: AgentZoneService = Depends(get_agent_zone_service),
|
|
):
|
|
import json
|
|
import uuid
|
|
import psycopg
|
|
from psycopg.types.json import Json
|
|
|
|
def as_text(value):
|
|
if value is None:
|
|
return None
|
|
value = str(value).strip()
|
|
return value if value else None
|
|
|
|
def fnum(value):
|
|
try:
|
|
if value is None or value == "":
|
|
return None
|
|
return float(value)
|
|
except Exception:
|
|
return None
|
|
|
|
def fint(value):
|
|
try:
|
|
if value is None or value == "":
|
|
return None
|
|
return int(float(value))
|
|
except Exception:
|
|
return None
|
|
|
|
def jdict(value):
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
def jlist(value):
|
|
if value is None:
|
|
return []
|
|
if isinstance(value, list):
|
|
return value
|
|
return [value]
|
|
|
|
routing = zone_service.routing_context(authenticated_agent_id)
|
|
if (
|
|
routing.routing_reason
|
|
or routing.agent_zone_id is None
|
|
or routing.site_id is None
|
|
):
|
|
raise MinerOpsApi3HTTPException(
|
|
status_code=409,
|
|
detail=(
|
|
"Agent routing unavailable: "
|
|
+ (routing.routing_reason or "agent_zone_unassigned")
|
|
),
|
|
)
|
|
|
|
body = await request.json()
|
|
agent_id = authenticated_agent_id
|
|
site_name = None
|
|
items = body.get("items") or []
|
|
|
|
if not isinstance(items, list):
|
|
raise MinerOpsApi3HTTPException(status_code=400, detail="items must be a list.")
|
|
|
|
conn = None
|
|
|
|
processed = 0
|
|
matched = 0
|
|
api3_ok = 0
|
|
api3_failed = 0
|
|
miner_updates = 0
|
|
status_rows = 0
|
|
pool_rows = 0
|
|
hashboard_rows = 0
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"SELECT name FROM sites WHERE id = %s",
|
|
(routing.site_id,),
|
|
)
|
|
site_row = cur.fetchone()
|
|
|
|
if not site_row:
|
|
raise MinerOpsApi3HTTPException(
|
|
status_code=409,
|
|
detail="Agent Site assignment no longer exists.",
|
|
)
|
|
|
|
site_name = str(site_row["name"])
|
|
|
|
cur.execute(
|
|
"""
|
|
ALTER TABLE miners
|
|
ADD COLUMN IF NOT EXISTS power_sn TEXT,
|
|
ADD COLUMN IF NOT EXISTS psu_model TEXT,
|
|
ADD COLUMN IF NOT EXISTS customer_sn TEXT,
|
|
ADD COLUMN IF NOT EXISTS api3_supported BOOLEAN,
|
|
ADD COLUMN IF NOT EXISTS api3_api_version TEXT,
|
|
ADD COLUMN IF NOT EXISTS api3_api_switch TEXT,
|
|
ADD COLUMN IF NOT EXISTS api3_last_seen_at TIMESTAMPTZ,
|
|
ADD COLUMN IF NOT EXISTS api3_last_error TEXT,
|
|
ADD COLUMN IF NOT EXISTS configured_performance_mode TEXT,
|
|
ADD COLUMN IF NOT EXISTS configured_performance_mode_source TEXT,
|
|
ADD COLUMN IF NOT EXISTS configured_performance_mode_updated_at TIMESTAMPTZ;
|
|
"""
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
ALTER TABLE miner_status_snapshots
|
|
ADD COLUMN IF NOT EXISTS ip_address INET,
|
|
ADD COLUMN IF NOT EXISTS mac_address TEXT,
|
|
ADD COLUMN IF NOT EXISTS power_rate_w_per_th DOUBLE PRECISION,
|
|
ADD COLUMN IF NOT EXISTS fan_speed_in DOUBLE PRECISION,
|
|
ADD COLUMN IF NOT EXISTS fan_speed_out DOUBLE PRECISION,
|
|
ADD COLUMN IF NOT EXISTS env_temp_c DOUBLE PRECISION,
|
|
ADD COLUMN IF NOT EXISTS elapsed_seconds DOUBLE PRECISION,
|
|
ADD COLUMN IF NOT EXISTS uptime_seconds DOUBLE PRECISION,
|
|
ADD COLUMN IF NOT EXISTS pool_rejected_percent DOUBLE PRECISION,
|
|
ADD COLUMN IF NOT EXISTS raw_summary_json JSONB;
|
|
"""
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS miner_pool_snapshots (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
miner_id UUID,
|
|
ip_address INET,
|
|
mac_address TEXT,
|
|
model TEXT,
|
|
pool_index INTEGER,
|
|
pool_status TEXT,
|
|
stratum_active BOOLEAN,
|
|
worker_user TEXT,
|
|
url TEXT,
|
|
accepted BIGINT,
|
|
rejected BIGINT,
|
|
pool_rejected_percent DOUBLE PRECISION,
|
|
last_share_time_epoch BIGINT,
|
|
raw_pool_json JSONB,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
"""
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
ALTER TABLE miner_pool_snapshots
|
|
ADD COLUMN IF NOT EXISTS model TEXT,
|
|
ADD COLUMN IF NOT EXISTS pool_index INTEGER,
|
|
ADD COLUMN IF NOT EXISTS pool_status TEXT,
|
|
ADD COLUMN IF NOT EXISTS stratum_active BOOLEAN,
|
|
ADD COLUMN IF NOT EXISTS worker_user TEXT,
|
|
ADD COLUMN IF NOT EXISTS url TEXT,
|
|
ADD COLUMN IF NOT EXISTS accepted BIGINT,
|
|
ADD COLUMN IF NOT EXISTS rejected BIGINT,
|
|
ADD COLUMN IF NOT EXISTS pool_rejected_percent DOUBLE PRECISION,
|
|
ADD COLUMN IF NOT EXISTS last_share_time_epoch BIGINT,
|
|
ADD COLUMN IF NOT EXISTS raw_pool_json JSONB;
|
|
"""
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS miner_api3_enrichment_snapshots (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
miner_id UUID,
|
|
agent_id TEXT,
|
|
site_name TEXT,
|
|
ip_address INET,
|
|
mac_address TEXT,
|
|
api_supported BOOLEAN,
|
|
api_error TEXT,
|
|
api_version TEXT,
|
|
api_switch TEXT,
|
|
model TEXT,
|
|
miner_sn TEXT,
|
|
power_sn TEXT,
|
|
psu_model TEXT,
|
|
device_info JSONB,
|
|
miner_setting JSONB,
|
|
miner_status JSONB,
|
|
fan_setting JSONB,
|
|
custom_data JSONB,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
"""
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS miner_api3_hashboard_snapshots (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
miner_id UUID,
|
|
ip_address INET,
|
|
mac_address TEXT,
|
|
slot INTEGER,
|
|
pcb_sn TEXT,
|
|
hash_average_ths DOUBLE PRECISION,
|
|
factory_hash_ths DOUBLE PRECISION,
|
|
freq DOUBLE PRECISION,
|
|
effective_chips INTEGER,
|
|
chip_temp_min_c DOUBLE PRECISION,
|
|
chip_temp_avg_c DOUBLE PRECISION,
|
|
chip_temp_max_c DOUBLE PRECISION,
|
|
raw_hashboard_json JSONB,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
"""
|
|
)
|
|
|
|
for item in items:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
|
|
processed += 1
|
|
|
|
ip = as_text(item.get("ip"))
|
|
api_supported = bool(item.get("api3_supported"))
|
|
api_error = as_text(item.get("api3_error"))
|
|
|
|
if api_supported:
|
|
api3_ok += 1
|
|
else:
|
|
api3_failed += 1
|
|
|
|
device_info = jdict(item.get("device_info"))
|
|
miner_setting = jdict(item.get("miner_setting"))
|
|
miner_status = jdict(item.get("miner_status"))
|
|
fan_setting = jdict(item.get("fan_setting"))
|
|
custom_data = jdict(item.get("custom_data"))
|
|
|
|
device_msg = jdict(device_info.get("msg"))
|
|
network = jdict(device_msg.get("network"))
|
|
miner = jdict(device_msg.get("miner"))
|
|
system = jdict(device_msg.get("system"))
|
|
power = jdict(device_msg.get("power"))
|
|
|
|
setting_msg = jdict(miner_setting.get("msg"))
|
|
status_msg = jdict(miner_status.get("msg"))
|
|
summary = jdict(status_msg.get("summary"))
|
|
pools = jlist(status_msg.get("pools"))
|
|
edevs = jlist(status_msg.get("edevs"))
|
|
custom_msg = jdict(custom_data.get("msg"))
|
|
|
|
mac = as_text(network.get("mac")) or as_text(item.get("mac"))
|
|
ip_from_api = as_text(network.get("ip"))
|
|
if ip_from_api:
|
|
ip = ip_from_api
|
|
|
|
model = as_text(miner.get("type"))
|
|
miner_sn = as_text(miner.get("miner-sn"))
|
|
customer_sn = as_text(custom_msg.get("CustomerSn"))
|
|
api_version = as_text(system.get("api"))
|
|
api_switch = as_text(system.get("apiswitch"))
|
|
firmware = as_text(system.get("fwversion"))
|
|
platform = as_text(system.get("platform"))
|
|
power_sn = as_text(power.get("sn"))
|
|
psu_model = as_text(power.get("model"))
|
|
power_mode = as_text(setting_msg.get("power-mode"))
|
|
|
|
miner_id = None
|
|
|
|
if mac:
|
|
cur.execute(
|
|
"""
|
|
SELECT id
|
|
FROM miners
|
|
WHERE upper(current_mac) = upper(%s)
|
|
AND agent_zone_id = %s
|
|
ORDER BY updated_at DESC NULLS LAST
|
|
LIMIT 1;
|
|
""",
|
|
(mac, routing.agent_zone_id),
|
|
)
|
|
row = cur.fetchone()
|
|
if row:
|
|
miner_id = row["id"]
|
|
|
|
if miner_id is None and ip:
|
|
cur.execute(
|
|
"""
|
|
SELECT id
|
|
FROM miners
|
|
WHERE current_ip = %s::inet
|
|
AND agent_zone_id = %s
|
|
ORDER BY updated_at DESC NULLS LAST
|
|
LIMIT 1;
|
|
""",
|
|
(ip, routing.agent_zone_id),
|
|
)
|
|
row = cur.fetchone()
|
|
if row:
|
|
miner_id = row["id"]
|
|
|
|
if miner_id is None:
|
|
continue
|
|
|
|
if miner_id is not None:
|
|
matched += 1
|
|
|
|
set_parts = [
|
|
"api3_supported = %s",
|
|
"api3_last_seen_at = now()",
|
|
"api3_last_error = %s",
|
|
"updated_at = now()",
|
|
]
|
|
params = [api_supported, api_error]
|
|
|
|
def add_update(column, value, overwrite_blank_only=False):
|
|
if value is None:
|
|
return
|
|
|
|
if overwrite_blank_only:
|
|
set_parts.append(f"{column} = COALESCE(NULLIF({column}, ''), %s)")
|
|
else:
|
|
set_parts.append(f"{column} = %s")
|
|
|
|
params.append(value)
|
|
|
|
add_update("current_mac", mac, True)
|
|
add_update("current_ip", ip, False)
|
|
add_update("model", model, True)
|
|
add_update("miner_type", model, True)
|
|
add_update("miner_sn", miner_sn, True)
|
|
add_update("customer_sn", customer_sn, False)
|
|
add_update("power_sn", power_sn, True)
|
|
add_update("psu_model", psu_model, True)
|
|
add_update("api_version", api_version, False)
|
|
add_update("api3_api_version", api_version, False)
|
|
add_update("api3_api_switch", api_switch, False)
|
|
add_update("firmware_version", firmware, False)
|
|
add_update("platform", platform, False)
|
|
|
|
if power_mode:
|
|
set_parts.append("configured_performance_mode = %s")
|
|
params.append(power_mode.lower())
|
|
set_parts.append("configured_performance_mode_source = %s")
|
|
params.append("api3_get_miner_setting")
|
|
set_parts.append("configured_performance_mode_updated_at = now()")
|
|
|
|
params.append(str(miner_id))
|
|
|
|
cur.execute(
|
|
"UPDATE miners SET " + ", ".join(set_parts) + " WHERE id = %s::uuid;",
|
|
params,
|
|
)
|
|
|
|
miner_updates += 1
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO miner_api3_enrichment_snapshots (
|
|
miner_id,
|
|
agent_id,
|
|
site_name,
|
|
ip_address,
|
|
mac_address,
|
|
api_supported,
|
|
api_error,
|
|
api_version,
|
|
api_switch,
|
|
model,
|
|
miner_sn,
|
|
power_sn,
|
|
psu_model,
|
|
device_info,
|
|
miner_setting,
|
|
miner_status,
|
|
fan_setting,
|
|
custom_data
|
|
)
|
|
VALUES (
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s::inet,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s::jsonb,
|
|
%s::jsonb,
|
|
%s::jsonb,
|
|
%s::jsonb,
|
|
%s::jsonb
|
|
);
|
|
""",
|
|
(
|
|
str(miner_id) if miner_id is not None else None,
|
|
agent_id,
|
|
site_name,
|
|
ip,
|
|
mac,
|
|
api_supported,
|
|
api_error,
|
|
api_version,
|
|
api_switch,
|
|
model,
|
|
miner_sn,
|
|
power_sn,
|
|
psu_model,
|
|
json.dumps(device_info),
|
|
json.dumps(miner_setting),
|
|
json.dumps(miner_status),
|
|
json.dumps(fan_setting),
|
|
json.dumps(custom_data),
|
|
),
|
|
)
|
|
|
|
if miner_id is not None and summary:
|
|
ths = fnum(summary.get("hash-average"))
|
|
mhs_av = ths * 1000000.0 if ths is not None else None
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO miner_status_snapshots (
|
|
miner_id,
|
|
ip_address,
|
|
mac_address,
|
|
mhs_av,
|
|
power_w,
|
|
power_rate_w_per_th,
|
|
chip_temp_avg_c,
|
|
chip_temp_max_c,
|
|
fan_speed_in,
|
|
fan_speed_out,
|
|
env_temp_c,
|
|
elapsed_seconds,
|
|
uptime_seconds,
|
|
pool_rejected_percent,
|
|
raw_summary_json,
|
|
created_at
|
|
)
|
|
VALUES (
|
|
%s::uuid,
|
|
%s::inet,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s::jsonb,
|
|
now()
|
|
);
|
|
""",
|
|
(
|
|
str(miner_id),
|
|
ip,
|
|
mac,
|
|
mhs_av,
|
|
fnum(summary.get("power-realtime")),
|
|
fnum(summary.get("power-rate")),
|
|
fnum(summary.get("chip-temp-avg")),
|
|
fnum(summary.get("chip-temp-max")),
|
|
fnum(summary.get("fan-speed-in")),
|
|
fnum(summary.get("fan-speed-out")),
|
|
fnum(summary.get("environment-temperature")),
|
|
fnum(summary.get("elapsed")),
|
|
fnum(summary.get("bootup-time")),
|
|
None,
|
|
json.dumps(miner_status),
|
|
),
|
|
)
|
|
|
|
status_rows += 1
|
|
|
|
if miner_id is not None and pools:
|
|
for pool in pools:
|
|
if not isinstance(pool, dict):
|
|
continue
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO miner_pool_snapshots (
|
|
miner_id,
|
|
ip_address,
|
|
mac_address,
|
|
model,
|
|
pool_index,
|
|
pool_status,
|
|
stratum_active,
|
|
worker_user,
|
|
url,
|
|
accepted,
|
|
rejected,
|
|
pool_rejected_percent,
|
|
last_share_time_epoch,
|
|
raw_pool_json,
|
|
created_at
|
|
)
|
|
VALUES (
|
|
%s::uuid,
|
|
%s::inet,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s::jsonb,
|
|
now()
|
|
);
|
|
""",
|
|
(
|
|
str(miner_id),
|
|
ip,
|
|
mac,
|
|
model,
|
|
fint(pool.get("id")),
|
|
as_text(pool.get("status")),
|
|
pool.get("stratum-active") if isinstance(pool.get("stratum-active"), bool) else None,
|
|
as_text(pool.get("account")),
|
|
as_text(pool.get("url")),
|
|
None,
|
|
None,
|
|
fnum(pool.get("reject-rate")),
|
|
fint(pool.get("last-share-time")),
|
|
json.dumps(pool),
|
|
),
|
|
)
|
|
|
|
pool_rows += 1
|
|
|
|
if miner_id is not None and edevs:
|
|
for board in edevs:
|
|
if not isinstance(board, dict):
|
|
continue
|
|
|
|
slot = fint(board.get("slot"))
|
|
pcb_sn = None
|
|
|
|
if slot is not None:
|
|
pcb_sn = as_text(miner.get(f"pcbsn{slot}"))
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO miner_api3_hashboard_snapshots (
|
|
miner_id,
|
|
ip_address,
|
|
mac_address,
|
|
slot,
|
|
pcb_sn,
|
|
hash_average_ths,
|
|
factory_hash_ths,
|
|
freq,
|
|
effective_chips,
|
|
chip_temp_min_c,
|
|
chip_temp_avg_c,
|
|
chip_temp_max_c,
|
|
raw_hashboard_json,
|
|
created_at
|
|
)
|
|
VALUES (
|
|
%s::uuid,
|
|
%s::inet,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s::jsonb,
|
|
now()
|
|
);
|
|
""",
|
|
(
|
|
str(miner_id),
|
|
ip,
|
|
mac,
|
|
slot,
|
|
pcb_sn,
|
|
fnum(board.get("hash-average")),
|
|
fnum(board.get("factory-hash")),
|
|
fnum(board.get("freq")),
|
|
fint(board.get("effective-chips")),
|
|
fnum(board.get("chip-temp-min")),
|
|
fnum(board.get("chip-temp-avg")),
|
|
fnum(board.get("chip-temp-max")),
|
|
json.dumps(board),
|
|
),
|
|
)
|
|
|
|
hashboard_rows += 1
|
|
|
|
conn.commit()
|
|
|
|
return api_response(
|
|
data={
|
|
"ok": True,
|
|
"processed": processed,
|
|
"matched": matched,
|
|
"api3_ok": api3_ok,
|
|
"api3_failed": api3_failed,
|
|
"miner_updates": miner_updates,
|
|
"status_rows": status_rows,
|
|
"pool_rows": pool_rows,
|
|
"hashboard_rows": hashboard_rows,
|
|
}
|
|
)
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
# MINEROPS_LOCATION_MANAGEMENT_V1
|
|
from fastapi.responses import Response as MinerOpsLocationResponse
|
|
|
|
@app.get("/api/miners/{miner_id}/location-management")
|
|
def minerops_get_miner_location_management(
|
|
miner_id: str,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
ensure_location_tables()
|
|
ensure_maintenance_tables()
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text AS miner_id,
|
|
current_mac,
|
|
host(current_ip) AS current_ip,
|
|
COALESCE(miner_type, model) AS model,
|
|
miner_sn,
|
|
power_sn,
|
|
status
|
|
FROM miners
|
|
WHERE id = %s::uuid
|
|
LIMIT 1;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
|
|
miner = cur.fetchone()
|
|
|
|
if not miner:
|
|
raise HTTPException(status_code=404, detail="Miner not found")
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
mls.asset_state,
|
|
mls.state_notes,
|
|
mls.updated_at,
|
|
lp.id::text AS location_position_id,
|
|
lp.room_name,
|
|
lp.rack_name,
|
|
lp.shelf_name,
|
|
lp.shelf_index,
|
|
lp.position_index,
|
|
lp.position_label,
|
|
lp.position_status,
|
|
lp.position_kind,
|
|
lp.notes AS position_notes
|
|
FROM miner_location_states mls
|
|
LEFT JOIN location_positions lp ON lp.id = mls.location_position_id
|
|
WHERE mls.miner_id = %s::uuid
|
|
LIMIT 1;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
|
|
state_row = cur.fetchone()
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text AS location_position_id,
|
|
room_name,
|
|
rack_name,
|
|
shelf_name,
|
|
shelf_index,
|
|
position_index,
|
|
position_label,
|
|
position_status,
|
|
position_kind,
|
|
notes AS position_notes
|
|
FROM location_positions
|
|
WHERE miner_id = %s::uuid
|
|
ORDER BY room_name, rack_name, shelf_index, position_index
|
|
LIMIT 1;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
|
|
physical_row = cur.fetchone()
|
|
|
|
current_position = dict(physical_row) if physical_row else None
|
|
|
|
asset_state = "waiting_placement"
|
|
|
|
if state_row:
|
|
asset_state = state_row.get("asset_state") or asset_state
|
|
|
|
if current_position:
|
|
asset_state = "shelved"
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
lp.id::text AS location_position_id,
|
|
lp.room_name,
|
|
lp.rack_name,
|
|
lp.shelf_name,
|
|
lp.shelf_index,
|
|
lp.position_index,
|
|
lp.position_label,
|
|
lp.position_status,
|
|
lp.position_kind,
|
|
lp.miner_id::text AS assigned_miner_id,
|
|
lp.expected_mac,
|
|
miners.current_mac AS assigned_mac,
|
|
host(miners.current_ip) AS assigned_ip
|
|
FROM location_positions lp
|
|
LEFT JOIN miners ON miners.id = lp.miner_id
|
|
LEFT JOIN rooms room_order ON lower(room_order.name) = lower(lp.room_name)
|
|
WHERE lp.position_kind = 'miner_slot'
|
|
AND lp.position_status NOT IN ('switch', 'blocked')
|
|
AND (
|
|
lp.miner_id IS NULL
|
|
OR lp.miner_id = %s::uuid
|
|
)
|
|
ORDER BY
|
|
COALESCE(room_order.sort_order, 999999),
|
|
lp.room_name,
|
|
lp.rack_name,
|
|
lp.shelf_index,
|
|
lp.position_index;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
|
|
available_positions = [dict(row) for row in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text AS event_id,
|
|
event_type,
|
|
event_status,
|
|
event_title,
|
|
description,
|
|
event_at,
|
|
component_slot
|
|
FROM miner_maintenance_events
|
|
WHERE miner_id = %s::uuid
|
|
AND (
|
|
event_type = 'moved'
|
|
OR component_type = 'location'
|
|
OR event_title ILIKE '%%location%%'
|
|
)
|
|
ORDER BY event_at DESC NULLS LAST, created_at DESC
|
|
LIMIT 10;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
|
|
recent_location_events = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(
|
|
data={
|
|
"miner": dict(miner),
|
|
"asset_state": asset_state,
|
|
"state": dict(state_row) if state_row else None,
|
|
"current_position": current_position,
|
|
"available_positions": available_positions,
|
|
"virtual_states": [
|
|
{
|
|
"value": "waiting_placement",
|
|
"label": "Unracked / Waiting Placement",
|
|
},
|
|
{
|
|
"value": "repair",
|
|
"label": "Repair",
|
|
},
|
|
{
|
|
"value": "storage",
|
|
"label": "Inventory / Storage",
|
|
},
|
|
{
|
|
"value": "decommissioned",
|
|
"label": "Decommissioned",
|
|
},
|
|
],
|
|
"recent_location_events": recent_location_events,
|
|
}
|
|
)
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
MINEROPS_LOCATION_MANAGEMENT_JS = r"""
|
|
(function () {
|
|
"use strict";
|
|
|
|
const pathMatch = window.location.pathname.match(/\/miners\/([^\/]+)\/detail/);
|
|
|
|
if (!pathMatch) {
|
|
return;
|
|
}
|
|
|
|
const minerId = decodeURIComponent(pathMatch[1]);
|
|
|
|
function getStoredToken() {
|
|
const keys = [
|
|
"minerops_token",
|
|
"mineropsToken",
|
|
"authToken",
|
|
"access_token",
|
|
"token"
|
|
];
|
|
|
|
for (const storage of [window.localStorage, window.sessionStorage]) {
|
|
if (!storage) {
|
|
continue;
|
|
}
|
|
|
|
for (const key of keys) {
|
|
const value = storage.getItem(key);
|
|
|
|
if (value) {
|
|
return value;
|
|
}
|
|
}
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function authHeaders(extra) {
|
|
const headers = Object.assign({}, extra || {});
|
|
const token = getStoredToken();
|
|
|
|
if (token && !headers.Authorization) {
|
|
headers.Authorization = "Bearer " + token;
|
|
}
|
|
|
|
return headers;
|
|
}
|
|
|
|
async function apiGet(url) {
|
|
const response = await fetch(url, {
|
|
method: "GET",
|
|
credentials: "same-origin",
|
|
headers: authHeaders({
|
|
"Accept": "application/json"
|
|
})
|
|
});
|
|
|
|
const payload = await response.json().catch(function () {
|
|
return null;
|
|
});
|
|
|
|
if (!response.ok || (payload && payload.error)) {
|
|
const message = payload && payload.error ? payload.error : "Request failed";
|
|
throw new Error(message);
|
|
}
|
|
|
|
return payload ? payload.data : null;
|
|
}
|
|
|
|
async function apiPost(url, body) {
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
credentials: "same-origin",
|
|
headers: authHeaders({
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json"
|
|
}),
|
|
body: JSON.stringify(body)
|
|
});
|
|
|
|
const text = await response.text();
|
|
let payload = null;
|
|
|
|
try {
|
|
payload = text ? JSON.parse(text) : null;
|
|
} catch (error) {
|
|
payload = null;
|
|
}
|
|
|
|
if (!response.ok || (payload && payload.error)) {
|
|
const detail = payload && payload.error ? payload.error : text || "Request failed";
|
|
throw new Error(detail);
|
|
}
|
|
|
|
return payload ? payload.data : null;
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value == null ? "" : value)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/\"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function stateLabel(value) {
|
|
const labels = {
|
|
shelved: "Shelved",
|
|
waiting_placement: "Unracked / Waiting Placement",
|
|
repair: "Repair",
|
|
storage: "Inventory / Storage",
|
|
decommissioned: "Decommissioned",
|
|
unassigned: "Unassigned"
|
|
};
|
|
|
|
return labels[value] || value || "Unknown";
|
|
}
|
|
|
|
function positionLabel(position) {
|
|
if (!position) {
|
|
return "No shelf position assigned";
|
|
}
|
|
|
|
const label = [
|
|
position.room_name || "-",
|
|
position.rack_name || "-",
|
|
position.shelf_name || "-",
|
|
"Pos " + (position.position_index || "-")
|
|
].join(" / ");
|
|
|
|
return label;
|
|
}
|
|
|
|
function findMountPoint() {
|
|
const candidates = [
|
|
"main",
|
|
".minerops-main",
|
|
".container",
|
|
".content",
|
|
"body"
|
|
];
|
|
|
|
for (const selector of candidates) {
|
|
const el = document.querySelector(selector);
|
|
|
|
if (el) {
|
|
return el;
|
|
}
|
|
}
|
|
|
|
return document.body;
|
|
}
|
|
|
|
function ensureCard() {
|
|
let card = document.getElementById("mineropsLocationManagementCard");
|
|
|
|
if (card) {
|
|
return card;
|
|
}
|
|
|
|
card = document.createElement("section");
|
|
card.id = "mineropsLocationManagementCard";
|
|
card.style.margin = "16px 0";
|
|
card.style.padding = "16px";
|
|
card.style.border = "1px solid #d1d5db";
|
|
card.style.borderRadius = "12px";
|
|
card.style.background = "#ffffff";
|
|
card.style.boxShadow = "0 1px 2px rgba(15, 23, 42, 0.08)";
|
|
card.innerHTML = '<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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/\"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function positionLabel(position) {
|
|
if (!position) {
|
|
return "Unknown position";
|
|
}
|
|
|
|
return [
|
|
position.room_name || "-",
|
|
position.rack_name || "-",
|
|
position.shelf_name || "-",
|
|
"Pos " + (position.position_index || "-")
|
|
].join(" / ");
|
|
}
|
|
|
|
function minerLabel(miner) {
|
|
if (!miner) {
|
|
return "Unknown miner";
|
|
}
|
|
|
|
const parts = [];
|
|
|
|
if (miner.current_mac) {
|
|
parts.push(miner.current_mac);
|
|
}
|
|
|
|
if (miner.current_ip) {
|
|
parts.push(miner.current_ip);
|
|
}
|
|
|
|
if (miner.model || miner.miner_type) {
|
|
parts.push(miner.miner_type || miner.model);
|
|
}
|
|
|
|
if (miner.miner_sn) {
|
|
parts.push("SN " + miner.miner_sn);
|
|
}
|
|
|
|
return parts.join(" | ") || miner.miner_id || "Unknown miner";
|
|
}
|
|
|
|
function getMinerCurrentLocation(minerId) {
|
|
if (!mapData || !minerId) {
|
|
return "Unknown";
|
|
}
|
|
|
|
const positions = mapData.positions || [];
|
|
|
|
for (const position of positions) {
|
|
if (position.miner_id === minerId) {
|
|
return positionLabel(position);
|
|
}
|
|
}
|
|
|
|
const virtuals = mapData.virtual_miners || [];
|
|
|
|
for (const miner of virtuals) {
|
|
if (miner.miner_id === minerId) {
|
|
const state = miner.asset_state || "waiting_placement";
|
|
const labels = {
|
|
waiting_placement: "Unracked / Waiting Placement",
|
|
repair: "Repair",
|
|
storage: "Inventory / Storage",
|
|
decommissioned: "Decommissioned"
|
|
};
|
|
|
|
return labels[state] || state;
|
|
}
|
|
}
|
|
|
|
return "Unknown";
|
|
}
|
|
|
|
function isMoveTarget(position) {
|
|
if (!position) {
|
|
return false;
|
|
}
|
|
|
|
if (position.miner_id) {
|
|
return false;
|
|
}
|
|
|
|
if (position.position_kind !== "miner_slot") {
|
|
return false;
|
|
}
|
|
|
|
const status = String(position.position_status || "").toLowerCase();
|
|
|
|
if (status === "blocked" || status === "switch") {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function buildMinerIndex(data) {
|
|
minersById = {};
|
|
|
|
const positions = data.positions || [];
|
|
|
|
for (const position of positions) {
|
|
if (!position.miner_id) {
|
|
continue;
|
|
}
|
|
|
|
minersById[position.miner_id] = {
|
|
miner_id: position.miner_id,
|
|
current_mac: position.current_mac,
|
|
current_ip: position.current_ip,
|
|
status: position.miner_status,
|
|
hostname: position.hostname,
|
|
miner_sn: position.miner_sn,
|
|
power_sn: position.power_sn,
|
|
model: position.model,
|
|
miner_type: position.miner_type,
|
|
asset_state: position.asset_state || "shelved"
|
|
};
|
|
}
|
|
|
|
const virtuals = data.virtual_miners || [];
|
|
|
|
for (const miner of virtuals) {
|
|
if (!miner.miner_id) {
|
|
continue;
|
|
}
|
|
|
|
minersById[miner.miner_id] = miner;
|
|
}
|
|
}
|
|
|
|
async function refreshMapData() {
|
|
const data = await apiGet("/api/location/map");
|
|
mapData = data || {
|
|
positions: [],
|
|
virtual_miners: []
|
|
};
|
|
|
|
positionsById = {};
|
|
|
|
for (const position of mapData.positions || []) {
|
|
if (position.location_position_id) {
|
|
positionsById[position.location_position_id] = position;
|
|
}
|
|
}
|
|
|
|
buildMinerIndex(mapData);
|
|
assignTileTargets();
|
|
}
|
|
|
|
function positionFromTileDataset(tile) {
|
|
if (!tile) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
location_position_id: tile.getAttribute("data-location-position-id") || null,
|
|
room_name: tile.dataset.roomName || "",
|
|
rack_name: tile.dataset.rackName || "",
|
|
shelf_name: tile.dataset.shelfName || "",
|
|
position_index: Number(tile.dataset.positionIndex || 0),
|
|
position_kind: tile.dataset.positionKind || "",
|
|
position_status: tile.dataset.positionStatus || "",
|
|
conflict_reason: tile.dataset.conflictReason || "",
|
|
miner_id: tile.dataset.minerId || null
|
|
};
|
|
}
|
|
|
|
function assignTileTargets() {
|
|
const tiles = Array.from(document.querySelectorAll(".tile"));
|
|
|
|
if (!tiles.length) {
|
|
return;
|
|
}
|
|
|
|
for (const tile of tiles) {
|
|
const position = positionFromTileDataset(tile);
|
|
|
|
if (!position || !position.room_name || !position.rack_name || !position.shelf_name || !position.position_index) {
|
|
tile.classList.remove("minerops-map-empty-move-target");
|
|
continue;
|
|
}
|
|
|
|
tile.setAttribute("data-position-label", positionLabel(position));
|
|
|
|
if (isMoveTarget(position)) {
|
|
tile.classList.add("minerops-map-empty-move-target");
|
|
tile.setAttribute("title", "Click to move a miner here: " + positionLabel(position));
|
|
|
|
if (!tile.getAttribute("tabindex")) {
|
|
tile.setAttribute("tabindex", "0");
|
|
}
|
|
} else {
|
|
tile.classList.remove("minerops-map-empty-move-target");
|
|
}
|
|
}
|
|
}
|
|
|
|
function createModal() {
|
|
if (document.getElementById("mineropsMapMoveModal")) {
|
|
return;
|
|
}
|
|
|
|
const style = document.createElement("style");
|
|
style.textContent = `
|
|
.minerops-map-empty-move-target {
|
|
cursor: copy !important;
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.minerops-map-empty-move-target:hover {
|
|
outline: 4px solid #0f766e !important;
|
|
box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.18);
|
|
}
|
|
|
|
#mineropsMapMoveModal {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 999999;
|
|
background: rgba(15, 23, 42, 0.45);
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 18px;
|
|
}
|
|
|
|
#mineropsMapMoveModal .minerops-map-move-panel {
|
|
width: min(760px, 100%);
|
|
max-height: 88vh;
|
|
overflow: auto;
|
|
background: white;
|
|
color: #102a43;
|
|
border-radius: 12px;
|
|
border: 1px solid #d9e2ec;
|
|
box-shadow: 0 12px 36px rgba(15, 23, 42, 0.28);
|
|
padding: 16px;
|
|
}
|
|
|
|
#mineropsMapMoveModal .minerops-map-move-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
gap: 10px;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
#mineropsMapMoveModal label {
|
|
display: block;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
color: #334155;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
#mineropsMapMoveModal input,
|
|
#mineropsMapMoveModal select {
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
border: 1px solid #bcccdc;
|
|
border-radius: 8px;
|
|
padding: 9px;
|
|
}
|
|
|
|
#mineropsMapMoveModal .minerops-map-move-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin-top: 14px;
|
|
}
|
|
|
|
#mineropsMapMoveModal .primary {
|
|
background: #0f766e;
|
|
color: white;
|
|
border-color: #0f766e;
|
|
}
|
|
|
|
#mineropsMapMoveStatus {
|
|
display: none;
|
|
margin-top: 12px;
|
|
border-radius: 8px;
|
|
padding: 10px;
|
|
font-size: 13px;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
|
|
const modal = document.createElement("div");
|
|
modal.id = "mineropsMapMoveModal";
|
|
modal.innerHTML = `
|
|
<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>
|
|
|
|
|
<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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/\"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/\"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function insertBanner() {
|
|
if (document.getElementById("mineropsRepairReturnBanner")) {
|
|
return;
|
|
}
|
|
|
|
const style = document.createElement("style");
|
|
style.textContent = `
|
|
#mineropsRepairReturnBanner {
|
|
margin: 0 0 14px 0;
|
|
padding: 12px;
|
|
background: #ecfdf5;
|
|
border: 1px solid #99f6e4;
|
|
border-radius: 10px;
|
|
color: #134e4a;
|
|
font-family: Segoe UI, Arial, sans-serif;
|
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
|
|
}
|
|
|
|
#mineropsRepairReturnBanner strong {
|
|
display: block;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
#mineropsRepairReturnBanner code {
|
|
background: rgba(15, 118, 110, 0.12);
|
|
border-radius: 5px;
|
|
padding: 1px 4px;
|
|
}
|
|
|
|
#mineropsRepairReturnBanner a {
|
|
color: #0b63ce;
|
|
font-weight: 700;
|
|
text-decoration: none;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
|
|
const banner = document.createElement("div");
|
|
banner.id = "mineropsRepairReturnBanner";
|
|
banner.innerHTML = `
|
|
<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> <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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/\"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function createShell() {
|
|
const style = document.createElement("style");
|
|
style.textContent = `
|
|
#mineropsDashboardRepairSummary {
|
|
margin: 0 0 16px 0;
|
|
padding: 14px;
|
|
background: #ffffff;
|
|
border: 1px solid #d9e2ec;
|
|
border-radius: 12px;
|
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
|
|
font-family: Segoe UI, Arial, sans-serif;
|
|
color: #102a43;
|
|
}
|
|
|
|
#mineropsDashboardRepairSummary .repair-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
#mineropsDashboardRepairSummary h2 {
|
|
margin: 0;
|
|
font-size: 20px;
|
|
}
|
|
|
|
#mineropsDashboardRepairSummary .repair-muted {
|
|
color: #52606d;
|
|
font-size: 13px;
|
|
}
|
|
|
|
#mineropsDashboardRepairSummary .repair-metrics {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
|
gap: 8px;
|
|
}
|
|
|
|
#mineropsDashboardRepairSummary .repair-metric {
|
|
border: 1px solid #d9e2ec;
|
|
border-radius: 10px;
|
|
padding: 10px;
|
|
background: #f8fafc;
|
|
}
|
|
|
|
#mineropsDashboardRepairSummary .repair-metric .value {
|
|
font-size: 24px;
|
|
font-weight: 800;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
#mineropsDashboardRepairSummary .repair-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
#mineropsDashboardRepairSummary a,
|
|
#mineropsDashboardRepairSummary button {
|
|
border: 1px solid #829ab1;
|
|
border-radius: 8px;
|
|
background: #f8fafc;
|
|
padding: 7px 10px;
|
|
font-weight: 700;
|
|
color: #0b63ce;
|
|
text-decoration: none;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
}
|
|
|
|
#mineropsDashboardRepairSummary a.primary {
|
|
background: #0f766e;
|
|
border-color: #0f766e;
|
|
color: #ffffff;
|
|
}
|
|
|
|
#mineropsDashboardRepairSummary .repair-list {
|
|
margin-top: 10px;
|
|
font-size: 13px;
|
|
color: #334155;
|
|
}
|
|
|
|
#mineropsDashboardRepairSummary .repair-list strong {
|
|
color: #102a43;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
|
|
const card = document.createElement("section");
|
|
card.id = "mineropsDashboardRepairSummary";
|
|
card.innerHTML = `
|
|
<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(" ");
|
|
|
|
const errors = topEntries(errorCounts, 4).map(function (entry) {
|
|
return escapeHtml(entry[0]) + ": <strong>" + escapeHtml(entry[1]) + "</strong>";
|
|
}).join(" ");
|
|
|
|
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/\"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) {
|
|
return "-";
|
|
}
|
|
|
|
const date = new Date(value);
|
|
|
|
if (Number.isNaN(date.getTime())) {
|
|
return value;
|
|
}
|
|
|
|
return date.toLocaleString();
|
|
}
|
|
|
|
function statusPill(status) {
|
|
const value = status || "unknown";
|
|
const safe = escapeHtml(value);
|
|
|
|
return '<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_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)
|