Bind Agent observations, review, enrichment and telemetry to current Zone
and inventory authority. Preserve immutable MAC ownership and historical
snapshots with stale-result rejection and transactional security audits.
Retire unbound direct/range/scheduled discovery and one/bulk legacy summary
writes in both modes. Require reconciled exact inventory identity or
explicit Zone-scoped IP selection for CSV metadata imports.
Independent Blocker #1 application-security re-audit: PASS on tree
cf863aadc9. Evidence: 141 focused PostgreSQL/HTTP
tests passed; Chromium and PowerShell checks passed. Two documented baseline
source assertions remain unchanged. Production deployment is not approved.
29467 lines
928 KiB
Python
29467 lines
928 KiB
Python
from app.discovery.http import dispatch as discovery_dispatch
|
|
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:
|
|
# Only the existing fixed legacy discovery queries remain. This is not a
|
|
# generic API3 gateway, and reserved keys can never override the selection.
|
|
allowed_parameters = {
|
|
"get_miner_info": {"info": "ip,proto,netmask,gateway,dns,hostname,mac,ledstat,minersn,powersn"},
|
|
"get_version": {}, "summary": {}, "edevs": {}, "pools": {},
|
|
}
|
|
if port != 4028 or command not in allowed_parameters or (params or {}) != allowed_parameters[command]:
|
|
raise HTTPException(status_code=410, detail="Unsupported diagnostic operation; no command was transmitted.")
|
|
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(current_user: dict = Depends(require_permission("miners.control"))):
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Unrestricted miner diagnostics are retired; no command was transmitted.",
|
|
)
|
|
|
|
@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:
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Legacy inventory discovery and summary writes are retired. "
|
|
"Use authenticated Agent observations, discovery review, and inventory-bound Agent polling.",
|
|
)
|
|
|
|
|
|
@app.post("/api/miners/discover-one")
|
|
def discover_one_miner(request: DiscoverOneMinerRequest, current_user: dict = Depends(get_current_user)):
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Legacy inventory discovery and summary writes are retired. "
|
|
"Use authenticated Agent observations, discovery review, and inventory-bound Agent polling.",
|
|
)
|
|
|
|
@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)):
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Legacy inventory discovery and summary writes are retired. "
|
|
"Use authenticated Agent observations, discovery review, and inventory-bound Agent polling.",
|
|
)
|
|
|
|
@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),
|
|
agent_zone_id: str | None = Query(default=None),
|
|
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))
|
|
if reader.fieldnames and len(set(reader.fieldnames)) != len(reader.fieldnames):
|
|
raise HTTPException(status_code=422, detail="CSV contains duplicate column names")
|
|
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 = []
|
|
audited_updates = []
|
|
unmatched_samples = []
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
from app.discovery.csv_import import resolve_inventory_row
|
|
from app.discovery.repository import DiscoveryRepository
|
|
|
|
# Same lock order as discovery; includes noncooperating inventory writers.
|
|
# Selection, metadata changes and audit stay in this one transaction.
|
|
DiscoveryRepository.lock(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)
|
|
|
|
model = model_from_miner_type(miner_type)
|
|
miner = resolve_inventory_row(cur, row, row_number, agent_zone_id)
|
|
|
|
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),
|
|
)
|
|
|
|
audited_updates.append({
|
|
"miner_id": miner["id"], "site_id": miner["site_id"],
|
|
"agent_zone_id": miner["agent_zone_id"],
|
|
"changes": proposed_changes,
|
|
})
|
|
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,
|
|
"agent_zone_id": agent_zone_id,
|
|
"updates": audited_updates,
|
|
}
|
|
),
|
|
),
|
|
)
|
|
|
|
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>
|
|
|
|
<p>Provide a Miner ID, current MAC, or serial number in each row. For IP-only rows, select a Zone below or include an Agent Zone ID column. All supplied identifiers must agree with current inventory.</p>
|
|
<label>Agent Zone ID (required for IP-only rows)</label>
|
|
<input id="importZoneId" type="text" placeholder="Zone UUID">
|
|
<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 zoneId = document.getElementById("importZoneId").value.trim();
|
|
const url = "/api/imports/whatsminer-tool-scan?dry_run=" + dryRun + "&update_existing=" + updateExisting
|
|
+ (zoneId ? "&agent_zone_id=" + encodeURIComponent(zoneId) : "");
|
|
|
|
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():
|
|
# Historical schedules remain readable; no legacy work is materialized.
|
|
return []
|
|
|
|
|
|
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),
|
|
):
|
|
return await discovery_dispatch('snapshots', request, agent_id=authenticated_agent_id, kind='pool')
|
|
|
|
|
|
@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(
|
|
current_user: dict = Depends(require_permission("miners.control")),
|
|
):
|
|
del current_user
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Legacy M30 command testing is retired; physical control is disabled.",
|
|
)
|
|
|
|
|
|
@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(current_user: dict = Depends(require_permission("miners.control"))):
|
|
raise HTTPException(status_code=410, detail="Legacy physical-control scheduling is retired; historical schedules are preserved.")
|
|
|
|
|
|
def minerops_retire_scheduled_action(action_id: str, current_user: dict, operation: str):
|
|
from app.agent_commands.exceptions import AgentCommandNotFound
|
|
from app.agent_commands.repository import AgentCommandRepository
|
|
|
|
# No lazy DDL or independent commits: queue, schedule and audits share a transaction.
|
|
try:
|
|
count = AgentCommandRepository(get_db_connection).retire_schedule(
|
|
action_id, actor_user_id=str(current_user["id"]), operation=operation,
|
|
)
|
|
except AgentCommandNotFound as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
return api_response(meta={
|
|
"message": "Schedule retired; history retained. Previously claimed execution is unknown.",
|
|
"cancelled_command_count": count,
|
|
})
|
|
|
|
|
|
@app.post("/api/dashboard/scheduled-bulk-actions/{action_id}/cancel")
|
|
def cancel_dashboard_scheduled_bulk_action(
|
|
action_id: str, current_user: dict = Depends(require_permission("miners.control")),
|
|
):
|
|
return minerops_retire_scheduled_action(action_id, current_user, "cancel")
|
|
|
|
|
|
@app.post("/api/dashboard/scheduled-bulk-actions/{action_id}/delete")
|
|
def delete_dashboard_scheduled_bulk_action(
|
|
action_id: str, current_user: dict = Depends(require_permission("miners.control")),
|
|
):
|
|
return minerops_retire_scheduled_action(action_id, current_user, "delete")
|
|
|
|
|
|
@app.post("/api/dashboard/scheduled-bulk-actions/process-due")
|
|
def process_due_dashboard_scheduled_bulk_actions(
|
|
current_user: dict = Depends(require_permission("miners.control")),
|
|
):
|
|
del current_user
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail=(
|
|
"Legacy scheduled miner-control execution is retired; "
|
|
"existing schedule records remain available for review."
|
|
),
|
|
)
|
|
|
|
|
|
@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):
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Legacy inventory discovery and summary writes are retired. "
|
|
"Use authenticated Agent observations, discovery review, and inventory-bound Agent polling.",
|
|
)
|
|
|
|
|
|
def refresh_one_known_miner_for_schedule(miner_row: dict):
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Legacy inventory discovery and summary writes are retired. "
|
|
"Use authenticated Agent observations, discovery review, and inventory-bound Agent polling.",
|
|
)
|
|
|
|
|
|
def run_scheduled_known_miner_scan(reason: str = "scheduled"):
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Legacy inventory discovery and summary writes are retired. "
|
|
"Use authenticated Agent observations, discovery review, and inventory-bound Agent polling.",
|
|
)
|
|
|
|
|
|
def scheduled_scanner_loop():
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Legacy inventory discovery and summary writes are retired. "
|
|
"Use authenticated Agent observations, discovery review, and inventory-bound Agent polling.",
|
|
)
|
|
|
|
|
|
@app.on_event("startup")
|
|
def start_scheduled_scanner():
|
|
# Retired in all modes: no background task may reach the legacy writer.
|
|
SCHEDULED_SCANNER_STATE.update(
|
|
enabled=False, running=False, thread_started=False,
|
|
last_reason="legacy_workflow_retired", next_run_at=None,
|
|
last_errors=[{"message": "Legacy scanner retired. Use authenticated Agent polling."}],
|
|
)
|
|
|
|
|
|
@app.get("/api/scanner/status")
|
|
def scanner_status(current_user: dict = Depends(get_current_user)):
|
|
status = dict(SCHEDULED_SCANNER_STATE)
|
|
status.update(
|
|
enabled=False, running=False, scanner_disabled=True,
|
|
hub_mode=minerops_hub_mode_enabled(),
|
|
last_reason="legacy_workflow_retired", next_run_at=None,
|
|
last_errors=[{"message": "Legacy scanner retired. Use authenticated Agent polling."}],
|
|
)
|
|
return api_response(data=status)
|
|
|
|
|
|
@app.post("/api/scanner/run-known-now")
|
|
def scanner_run_known_now(current_user: dict = Depends(get_current_user)):
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Legacy inventory discovery and summary writes are retired. "
|
|
"Use authenticated Agent observations, discovery review, and inventory-bound Agent polling.",
|
|
)
|
|
|
|
# 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):
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Legacy inventory discovery and summary writes are retired. "
|
|
"Use authenticated Agent observations, discovery review, and inventory-bound Agent polling.",
|
|
)
|
|
|
|
# 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):
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Legacy inventory discovery and summary writes are retired. "
|
|
"Use authenticated Agent observations, discovery review, and inventory-bound Agent polling.",
|
|
)
|
|
|
|
# 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):
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Legacy inventory discovery and summary writes are retired. "
|
|
"Use authenticated Agent observations, discovery review, and inventory-bound Agent polling.",
|
|
)
|
|
|
|
# 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):
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Legacy inventory discovery and summary writes are retired. "
|
|
"Use authenticated Agent observations, discovery review, and inventory-bound Agent polling.",
|
|
)
|
|
|
|
# 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):
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Legacy inventory discovery and summary writes are retired. "
|
|
"Use authenticated Agent observations, discovery review, and inventory-bound Agent polling.",
|
|
)
|
|
|
|
# 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):
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Legacy inventory discovery and summary writes are retired. "
|
|
"Use authenticated Agent observations, discovery review, and inventory-bound Agent polling.",
|
|
)
|
|
|
|
@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):
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Legacy inventory discovery and summary writes are retired. "
|
|
"Use authenticated Agent observations, discovery review, and inventory-bound Agent polling.",
|
|
)
|
|
|
|
|
|
@app.post("/api/tools/legacy-summary-refresh-one")
|
|
def legacy_summary_refresh_one(
|
|
request: LegacySummaryRefreshRequest,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Legacy inventory discovery and summary writes are retired. "
|
|
"Use authenticated Agent observations, discovery review, and inventory-bound Agent polling.",
|
|
)
|
|
|
|
|
|
@app.post("/api/tools/legacy-summary-refresh-missing")
|
|
def legacy_summary_refresh_missing(
|
|
request: LegacySummaryRefreshRequest,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Legacy inventory discovery and summary writes are retired. "
|
|
"Use authenticated Agent observations, discovery review, and inventory-bound Agent polling.",
|
|
)
|
|
|
|
|
|
def legacy_find_miner_id_by_ip(ip_address: str):
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Legacy inventory discovery and summary writes are retired. "
|
|
"Use authenticated Agent observations, discovery review, and inventory-bound Agent polling.",
|
|
)
|
|
|
|
|
|
try:
|
|
_minerops_original_discover_one_miner_legacy_summary = discover_one_miner
|
|
|
|
def discover_one_miner(request, current_user: dict = Depends(get_current_user)):
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Legacy inventory discovery and summary writes are retired. "
|
|
"Use authenticated Agent observations, discovery review, and inventory-bound Agent polling.",
|
|
)
|
|
|
|
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:
|
|
raise HTTPException(status_code=410, detail="Legacy physical miner control is retired.")
|
|
|
|
|
|
async def minerops_send_whatsminer_reboot(target_ip: str) -> dict:
|
|
raise HTTPException(status_code=410, detail="Legacy physical miner control is retired.")
|
|
|
|
@app.post("/api/miners/{miner_id}/commands/reboot")
|
|
async def retired_direct_reboot_miner_command(
|
|
miner_id: str,
|
|
current_user: dict = Depends(require_permission("miners.control")),
|
|
):
|
|
del miner_id, current_user
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Direct Hub miner reboot is retired; physical control is disabled.",
|
|
)
|
|
|
|
|
|
async def dry_run_reboot_miner_command(miner_id: str, request: Request):
|
|
raise HTTPException(status_code=410, detail="Legacy physical miner control is retired.")
|
|
|
|
@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:
|
|
raise HTTPException(status_code=410, detail="Legacy physical miner control is retired.")
|
|
|
|
|
|
# -----------------------------
|
|
# 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:
|
|
raise HTTPException(status_code=410, detail="Legacy physical miner control is retired.")
|
|
|
|
|
|
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:
|
|
raise HTTPException(status_code=410, detail="Legacy physical miner control is retired.")
|
|
|
|
|
|
async def minerops_api3_service(target_ip: str, service_action: str) -> dict:
|
|
raise HTTPException(status_code=410, detail="Legacy physical miner control is retired.")
|
|
|
|
|
|
async def minerops_send_control_command_item(target_ip: str, command_name: str) -> dict:
|
|
raise HTTPException(status_code=410, detail="Legacy physical miner control is retired.")
|
|
|
|
|
|
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:
|
|
raise HTTPException(status_code=410, detail="Legacy physical miner control is retired.")
|
|
|
|
async def minerops_send_whatsminer_command(target_ip: str, command_name: str) -> dict:
|
|
raise HTTPException(status_code=410, detail="Legacy physical miner control is retired.")
|
|
|
|
|
|
async def minerops_power_command(miner_id: str, request: Request, action: str):
|
|
raise HTTPException(status_code=410, detail="Legacy physical miner control is retired.")
|
|
|
|
|
|
@app.post("/api/miners/{miner_id}/commands/power-off")
|
|
async def real_power_off_miner_command(
|
|
miner_id: str,
|
|
current_user: dict = Depends(require_permission("miners.control")),
|
|
):
|
|
del miner_id, current_user
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Direct Hub miner power control is retired; physical control is disabled.",
|
|
)
|
|
|
|
|
|
@app.post("/api/miners/{miner_id}/commands/power-on")
|
|
async def real_power_on_miner_command(
|
|
miner_id: str,
|
|
current_user: dict = Depends(require_permission("miners.control")),
|
|
):
|
|
del miner_id, current_user
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Direct Hub miner power control is retired; physical control is disabled.",
|
|
)
|
|
|
|
|
|
|
|
@app.post("/api/miners/{miner_id}/commands/suspend")
|
|
async def real_suspend_miner_command(
|
|
miner_id: str,
|
|
current_user: dict = Depends(require_permission("miners.control")),
|
|
):
|
|
del miner_id, current_user
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Direct Hub miner service control is retired; physical control is disabled.",
|
|
)
|
|
|
|
|
|
@app.post("/api/miners/{miner_id}/commands/resume")
|
|
async def real_resume_miner_command(
|
|
miner_id: str,
|
|
current_user: dict = Depends(require_permission("miners.control")),
|
|
):
|
|
del miner_id, current_user
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Direct Hub miner service control is retired; physical control is disabled.",
|
|
)
|
|
|
|
|
|
# -----------------------------
|
|
# 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:
|
|
raise HTTPException(status_code=410, detail="Legacy physical miner control is retired.")
|
|
|
|
|
|
async def minerops_api3_service_with_credentials(
|
|
target_ip: str,
|
|
service_action: str,
|
|
account: str | None = None,
|
|
password: str | None = None,
|
|
) -> dict:
|
|
raise HTTPException(status_code=410, detail="Legacy physical miner control is retired.")
|
|
|
|
|
|
async def minerops_send_control_command_item(
|
|
target_ip: str,
|
|
command_name: str,
|
|
account_override: str | None = None,
|
|
password_override: str | None = None,
|
|
) -> dict:
|
|
raise HTTPException(status_code=410, detail="Legacy physical miner control is retired.")
|
|
|
|
|
|
async def minerops_send_whatsminer_command_sequence(
|
|
target_ip: str,
|
|
command_name: str,
|
|
account_override: str | None = None,
|
|
password_override: str | None = None,
|
|
) -> dict:
|
|
raise HTTPException(status_code=410, detail="Legacy physical miner control is retired.")
|
|
|
|
|
|
def minerops_api3_change_password_sync(
|
|
target_ip: str,
|
|
account_to_change: str,
|
|
old_password: str,
|
|
new_password: str,
|
|
) -> dict:
|
|
raise HTTPException(status_code=410, detail="Legacy physical miner control is retired.")
|
|
|
|
|
|
async def minerops_api3_change_password(
|
|
target_ip: str,
|
|
account_to_change: str,
|
|
old_password: str,
|
|
new_password: str,
|
|
) -> dict:
|
|
raise HTTPException(status_code=410, detail="Legacy physical miner control is retired.")
|
|
|
|
|
|
@app.post("/api/miners/{miner_id}/api3/change-password")
|
|
async def retired_direct_api3_password_change(
|
|
miner_id: str,
|
|
current_user: dict = Depends(require_permission("credentials.manage")),
|
|
):
|
|
del miner_id, current_user
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail=(
|
|
"Direct Hub API3 password changes are retired; "
|
|
"manage miner credentials outside the Hub control path."
|
|
),
|
|
)
|
|
|
|
|
|
async def change_miner_api3_password(miner_id: str, request: Request):
|
|
raise HTTPException(status_code=410, detail="Legacy physical miner control is retired.")
|
|
|
|
|
|
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
|
|
# -----------------------------
|
|
|
|
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,
|
|
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")
|
|
),
|
|
)
|
|
|
|
configuration = zone_service.configuration(authenticated_agent_id)
|
|
if not configuration:
|
|
raise HTTPException(status_code=409, detail="Agent configuration unavailable")
|
|
|
|
return {
|
|
"ok": True,
|
|
**configuration,
|
|
"poll_interval_seconds": 120,
|
|
"hub_url": str(request.base_url).rstrip("/"),
|
|
"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),
|
|
):
|
|
return await discovery_dispatch('snapshots', request, agent_id=authenticated_agent_id)
|
|
|
|
|
|
@app.get("/api/agent/commands/pending-disabled-old")
|
|
async def minerops_agent_commands_pending_retired():
|
|
raise HTTPException(status_code=410, detail="Legacy command endpoint retired")
|
|
|
|
|
|
@app.post("/api/agent/commands/result-disabled-old")
|
|
async def minerops_agent_commands_result_retired():
|
|
raise HTTPException(status_code=410, detail="Legacy command endpoint retired")
|
|
|
|
|
|
|
|
# -----------------------------
|
|
# 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 = settings.minerops_agent_shared_secret.strip()
|
|
|
|
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),
|
|
):
|
|
return await discovery_dispatch('known', agent_id=authenticated_agent_id)
|
|
|
|
|
|
|
|
# -----------------------------
|
|
# 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:
|
|
raise HTTPException(409, "discovery_versioned_snapshot_required")
|
|
|
|
|
|
# -----------------------------
|
|
# 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(request: Request, current_user: dict = Depends(get_current_user)):
|
|
return await discovery_dispatch('queue', request, user=current_user, kind='poll_known_miners_now')
|
|
|
|
|
|
@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 discoveryEscape(value) {
|
|
return String(value ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
|
}
|
|
|
|
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>${discoveryEscape(fmt(cmd.created_at))}</td>
|
|
<td>${discoveryEscape(cmd.command_type || "-")}</td>
|
|
<td class="${discoveryEscape(statusClass(cmd.command_status))}">${discoveryEscape(cmd.command_status || "-")}</td>
|
|
<td>${discoveryEscape(cmd.requested_by_username || "-")}</td>
|
|
<td>${discoveryEscape(fmt(cmd.claimed_at))}</td>
|
|
<td>${discoveryEscape(fmt(cmd.completed_at))}</td>
|
|
<td>${discoveryEscape(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 agentId = prompt("Registered Agent ID for this poll:");
|
|
if (!agentId) return;
|
|
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",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({agent_id: agentId})
|
|
});
|
|
|
|
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 agentId = prompt("Registered Agent ID for this discovery scan:");
|
|
if (!agentId) return;
|
|
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",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({agent_id: agentId})
|
|
});
|
|
|
|
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(discovery) {
|
|
const statusBox = document.getElementById("statusBox");
|
|
|
|
try {
|
|
statusBox.className = "card";
|
|
statusBox.textContent = "Queueing Enrich / Match for " + discovery.ip_address + "...";
|
|
|
|
const result = await apiFetch("/api/agent/discoveries/enrich-match", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({
|
|
discovery_id: discovery.id,
|
|
version: discovery.version
|
|
})
|
|
});
|
|
|
|
statusBox.textContent = result.message + " Command: " + result.command_id;
|
|
await loadStatus();
|
|
} catch (err) {
|
|
statusBox.className = "bad";
|
|
statusBox.textContent = err.message;
|
|
}
|
|
}
|
|
|
|
async function reviewDiscovery(discovery, 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 " + discovery.ip_address + "?")) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
statusBox.className = "card";
|
|
statusBox.textContent = "Updating discovery " + discovery.ip_address + "...";
|
|
|
|
const result = await apiFetch("/api/agent/discoveries/review", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({
|
|
discovery_id: discovery.id,
|
|
version: discovery.version,
|
|
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>${discoveryEscape(total)}</div>
|
|
<div><strong>Known responders:</strong><br>${discoveryEscape(counts.known || 0)}</div>
|
|
<div><strong>Unmatched:</strong><br>${discoveryEscape(counts.new || 0)}</div>
|
|
<div><strong>Needs review:</strong><br>${discoveryEscape(counts.needs_review || 0)}</div>
|
|
<div><strong>Matched:</strong><br>${discoveryEscape(counts.matched || 0)}</div>
|
|
<div><strong>Added as new:</strong><br>${discoveryEscape(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>${discoveryEscape(total)}</div>
|
|
<div><strong>Known responders:</strong><br>${discoveryEscape(counts.known || 0)}</div>
|
|
<div><strong>Unmatched:</strong><br>${discoveryEscape(counts.new || 0)}</div>
|
|
<div><strong>Needs review:</strong><br>${discoveryEscape(counts.needs_review || 0)}</div>
|
|
<div><strong>Matched:</strong><br>${discoveryEscape(counts.matched || 0)}</div>
|
|
<div><strong>Added as new:</strong><br>${discoveryEscape(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>${discoveryEscape(fmt(d.created_at))}</td>
|
|
<td>${discoveryEscape(d.agent_id)} / ${discoveryEscape(d.agent_zone_id)} / ${discoveryEscape(d.room_name || "-")}</td>
|
|
<td>${discoveryEscape(d.ip_address || "-")}</td>
|
|
<td>${discoveryEscape(d.mac_address || "-")}</td>
|
|
<td>${discoveryEscape(d.model || "-")}</td>
|
|
<td class="${discoveryEscape(d.discovery_status === "new" ? "pending" : "ok")}">${discoveryEscape(d.discovery_status || "-")}</td>
|
|
<td>${discoveryEscape(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);
|
|
actionCell.appendChild(enrichButton);
|
|
|
|
actionCell.appendChild(document.createTextNode(" "));
|
|
|
|
const addButton = document.createElement("button");
|
|
addButton.textContent = "Add as New";
|
|
addButton.disabled = !d.mac_address;
|
|
if (!d.mac_address) addButton.title = "Enrich this observation before adding it.";
|
|
addButton.onclick = () => reviewDiscovery(d, "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, "ignore");
|
|
actionCell.appendChild(ignoreButton);
|
|
|
|
actionCell.appendChild(document.createTextNode(" "));
|
|
|
|
const nonMinerButton = document.createElement("button");
|
|
nonMinerButton.textContent = "Non-Miner";
|
|
nonMinerButton.className = "secondary";
|
|
nonMinerButton.onclick = () => reviewDiscovery(d, "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: " + discoveryEscape(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(request: Request, current_user: dict = Depends(get_current_user)):
|
|
return await discovery_dispatch('queue', request, user=current_user, kind='discover_new_miners')
|
|
|
|
|
|
@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),
|
|
):
|
|
return await discovery_dispatch('ingest', request, agent_id=authenticated_agent_id)
|
|
|
|
|
|
@app.get("/api/agent/discoveries")
|
|
async def minerops_agent_discoveries(current_user: dict = Depends(require_permission("agents.view"))):
|
|
return await discovery_dispatch('listing')
|
|
|
|
|
|
|
|
@app.post("/api/agent/discoveries/review")
|
|
async def minerops_agent_discovery_review(
|
|
request: Request,
|
|
current_user: dict = Depends(require_permission("agents.manage")),
|
|
zone_service: AgentZoneService = Depends(get_agent_zone_service),
|
|
):
|
|
return await discovery_dispatch('review', request, user=current_user)
|
|
|
|
|
|
@app.post("/api/agent/discovery-target")
|
|
async def minerops_discovery_target(request: Request, authenticated_agent_id: str = Depends(require_agent_identity)):
|
|
return await discovery_dispatch('target', request, agent_id=authenticated_agent_id)
|
|
|
|
|
|
@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),
|
|
):
|
|
return await discovery_dispatch('enrich', request, agent_id=authenticated_agent_id)
|
|
|
|
|
|
@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),
|
|
):
|
|
return await discovery_dispatch('enrich', request, agent_id=authenticated_agent_id)
|
|
|
|
|
|
@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),
|
|
):
|
|
return await discovery_dispatch('enrich', request, agent_id=authenticated_agent_id)
|
|
|
|
|
|
@app.post("/api/agent/discoveries/enrich-match")
|
|
async def minerops_queue_discovery_enrich_match(request: Request, current_user: dict = Depends(require_permission("agents.manage"))):
|
|
return await discovery_dispatch('queue', request, user=current_user)
|
|
|
|
|
|
@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 retired_legacy_agent_miner_control(
|
|
miner_id: str,
|
|
current_user: dict = Depends(require_permission("miners.control")),
|
|
):
|
|
del miner_id, current_user
|
|
raise HTTPException(
|
|
status_code=410,
|
|
detail="Legacy Agent control is retired; physical control is disabled.",
|
|
)
|
|
|
|
|
|
async def minerops_queue_agent_miner_control(miner_id: str, request: Request, current_user: dict = Depends(get_current_user)):
|
|
raise HTTPException(status_code=410, detail="Legacy physical miner control is retired.")
|
|
|
|
|
|
|
|
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),
|
|
):
|
|
return await discovery_dispatch('api3', request, agent_id=authenticated_agent_id)
|
|
|
|
|
|
|
|
|
|
# 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)
|