21445 lines
650 KiB
Plaintext
21445 lines
650 KiB
Plaintext
from datetime import datetime, timedelta, timezone
|
|
from typing import Any
|
|
|
|
import jwt
|
|
import psycopg
|
|
import redis
|
|
from argon2 import PasswordHasher
|
|
from argon2.exceptions import VerifyMismatchError
|
|
from fastapi import Depends, FastAPI, Header, HTTPException, status
|
|
from pydantic import BaseModel, EmailStr, Field
|
|
|
|
from app.config import settings
|
|
|
|
|
|
app = FastAPI(title=settings.app_name)
|
|
password_hasher = PasswordHasher()
|
|
|
|
|
|
def api_response(data: Any = None, meta: dict | None = None, error: Any = None) -> dict:
|
|
return {
|
|
"data": data,
|
|
"meta": meta or {},
|
|
"error": error,
|
|
}
|
|
|
|
def json_safe(value: Any) -> Any:
|
|
if isinstance(value, dict):
|
|
return {key: json_safe(item) for key, item in value.items()}
|
|
|
|
if isinstance(value, list):
|
|
return [json_safe(item) for item in value]
|
|
|
|
if isinstance(value, datetime):
|
|
return value.isoformat()
|
|
|
|
return value
|
|
|
|
|
|
def get_db_connection():
|
|
return psycopg.connect(
|
|
host=settings.postgres_host,
|
|
port=settings.postgres_port,
|
|
dbname=settings.postgres_db,
|
|
user=settings.postgres_user,
|
|
password=settings.postgres_password,
|
|
connect_timeout=5,
|
|
)
|
|
|
|
|
|
def check_database() -> str:
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor() as cur:
|
|
cur.execute("SELECT 1;")
|
|
result = cur.fetchone()
|
|
if result and result[0] == 1:
|
|
return "ok"
|
|
return "error"
|
|
except Exception as exc:
|
|
return f"error: {exc}"
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
def check_redis() -> str:
|
|
try:
|
|
client = redis.Redis.from_url(settings.redis_url, socket_connect_timeout=5)
|
|
if client.ping():
|
|
return "ok"
|
|
return "error"
|
|
except Exception as exc:
|
|
return f"error: {exc}"
|
|
|
|
|
|
class SetupAdminRequest(BaseModel):
|
|
username: str = Field(min_length=3, max_length=64)
|
|
display_name: str = Field(min_length=1, max_length=120)
|
|
email: EmailStr | None = None
|
|
password: str = Field(min_length=10, max_length=256)
|
|
|
|
|
|
class LoginRequest(BaseModel):
|
|
username: str
|
|
password: str
|
|
|
|
|
|
def create_access_token(user_id: str, username: str, role: str) -> str:
|
|
now = datetime.now(timezone.utc)
|
|
payload = {
|
|
"sub": user_id,
|
|
"username": username,
|
|
"role": role,
|
|
"iat": int(now.timestamp()),
|
|
"exp": int((now + timedelta(minutes=settings.jwt_access_token_minutes)).timestamp()),
|
|
"type": "access",
|
|
}
|
|
return jwt.encode(payload, settings.jwt_secret_key, algorithm="HS256")
|
|
|
|
|
|
def decode_access_token(token: str) -> dict:
|
|
try:
|
|
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=["HS256"])
|
|
if payload.get("type") != "access":
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
|
|
return payload
|
|
except jwt.ExpiredSignatureError:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
|
|
except jwt.InvalidTokenError:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
|
|
|
|
|
def get_bearer_token(authorization: str | None = Header(default=None)) -> str:
|
|
if not authorization or not authorization.lower().startswith("bearer "):
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
|
|
return authorization.split(" ", 1)[1].strip()
|
|
|
|
|
|
def get_current_user(token: str = Depends(get_bearer_token)) -> dict:
|
|
payload = decode_access_token(token)
|
|
user_id = payload["sub"]
|
|
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
users.id::text AS id,
|
|
users.username,
|
|
users.display_name,
|
|
users.email,
|
|
users.is_active,
|
|
roles.name AS role
|
|
FROM users
|
|
JOIN roles ON roles.id = users.role_id
|
|
WHERE users.id = %s;
|
|
""",
|
|
(user_id,),
|
|
)
|
|
user = cur.fetchone()
|
|
|
|
if not user or not user["is_active"]:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
|
|
|
|
return dict(user)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/health")
|
|
def health_check():
|
|
database_status = check_database()
|
|
redis_status = check_redis()
|
|
|
|
overall_status = "ok"
|
|
if database_status != "ok" or redis_status != "ok":
|
|
overall_status = "degraded"
|
|
|
|
return api_response(
|
|
data={
|
|
"status": overall_status,
|
|
"app": settings.app_name,
|
|
"environment": settings.app_env,
|
|
"database": database_status,
|
|
"redis": redis_status,
|
|
}
|
|
)
|
|
|
|
|
|
@app.get("/api/auth/setup-status")
|
|
def setup_status():
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor() as cur:
|
|
cur.execute("SELECT COUNT(*) FROM users;")
|
|
user_count = cur.fetchone()[0]
|
|
|
|
return api_response(
|
|
data={
|
|
"setup_required": user_count == 0,
|
|
"user_count": user_count,
|
|
}
|
|
)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/auth/setup-admin")
|
|
def setup_admin(request: SetupAdminRequest):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute("SELECT COUNT(*) AS user_count FROM users;")
|
|
user_count = cur.fetchone()["user_count"]
|
|
|
|
if user_count > 0:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="Initial admin setup has already been completed",
|
|
)
|
|
|
|
cur.execute("SELECT id FROM roles WHERE name = 'Admin';")
|
|
admin_role = cur.fetchone()
|
|
if not admin_role:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Admin role is missing",
|
|
)
|
|
|
|
password_hash = password_hasher.hash(request.password)
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO users (username, display_name, email, password_hash, role_id)
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
RETURNING id::text, username, display_name, email;
|
|
""",
|
|
(
|
|
request.username.strip(),
|
|
request.display_name.strip(),
|
|
request.email,
|
|
password_hash,
|
|
admin_role["id"],
|
|
),
|
|
)
|
|
user = cur.fetchone()
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
|
VALUES (%s, 'user.created', 'user', %s, %s);
|
|
""",
|
|
(
|
|
user["id"],
|
|
user["id"],
|
|
psycopg.types.json.Jsonb(
|
|
{
|
|
"username": user["username"],
|
|
"display_name": user["display_name"],
|
|
"role": "Admin",
|
|
"source": "setup-admin",
|
|
}
|
|
),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
return api_response(
|
|
data={
|
|
"user_id": user["id"],
|
|
"username": user["username"],
|
|
"display_name": user["display_name"],
|
|
}
|
|
)
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/auth/login")
|
|
def login(request: LoginRequest):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
users.id::text AS id,
|
|
users.username,
|
|
users.display_name,
|
|
users.email,
|
|
users.password_hash,
|
|
users.is_active,
|
|
roles.name AS role
|
|
FROM users
|
|
JOIN roles ON roles.id = users.role_id
|
|
WHERE lower(users.username) = lower(%s);
|
|
""",
|
|
(request.username.strip(),),
|
|
)
|
|
user = cur.fetchone()
|
|
|
|
if not user or not user["is_active"]:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
|
|
|
|
try:
|
|
password_hasher.verify(user["password_hash"], request.password)
|
|
except VerifyMismatchError:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
|
|
|
|
access_token = create_access_token(user["id"], user["username"], user["role"])
|
|
|
|
cur.execute(
|
|
"UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = %s;",
|
|
(user["id"],),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
|
VALUES (%s, 'user.login', 'user', %s, %s);
|
|
""",
|
|
(
|
|
user["id"],
|
|
user["id"],
|
|
psycopg.types.json.Jsonb({"username": user["username"]}),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
return api_response(
|
|
data={
|
|
"access_token": access_token,
|
|
"token_type": "bearer",
|
|
"expires_in": settings.jwt_access_token_minutes * 60,
|
|
"user": {
|
|
"id": user["id"],
|
|
"username": user["username"],
|
|
"display_name": user["display_name"],
|
|
"email": user["email"],
|
|
"role": user["role"],
|
|
},
|
|
}
|
|
)
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/auth/me")
|
|
def me(current_user: dict = Depends(get_current_user)):
|
|
return api_response(data=current_user)
|
|
|
|
class CreateRoomRequest(BaseModel):
|
|
site_id: str | None = None
|
|
name: str = Field(min_length=1, max_length=120)
|
|
description: str | None = None
|
|
sort_order: int = 0
|
|
|
|
|
|
class UpdateRoomRequest(BaseModel):
|
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
|
description: str | None = None
|
|
sort_order: int | None = None
|
|
|
|
|
|
@app.get("/api/sites")
|
|
def list_sites(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text, name, description, created_at, updated_at
|
|
FROM sites
|
|
ORDER BY name;
|
|
"""
|
|
)
|
|
sites = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=sites)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/rooms")
|
|
def list_rooms(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
rooms.id::text,
|
|
rooms.site_id::text,
|
|
sites.name AS site_name,
|
|
rooms.name,
|
|
rooms.description,
|
|
rooms.sort_order,
|
|
rooms.created_at,
|
|
rooms.updated_at
|
|
FROM rooms
|
|
JOIN sites ON sites.id = rooms.site_id
|
|
ORDER BY rooms.sort_order, rooms.name;
|
|
"""
|
|
)
|
|
rooms = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=rooms)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/rooms")
|
|
def create_room(request: CreateRoomRequest, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
site_id = request.site_id
|
|
|
|
if site_id is None:
|
|
cur.execute("SELECT id::text FROM sites ORDER BY name LIMIT 1;")
|
|
site = cur.fetchone()
|
|
if not site:
|
|
raise HTTPException(status_code=500, detail="No site exists")
|
|
site_id = site["id"]
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO rooms (site_id, name, description, sort_order)
|
|
VALUES (%s, %s, %s, %s)
|
|
RETURNING id::text, site_id::text, name, description, sort_order, created_at, updated_at;
|
|
""",
|
|
(
|
|
site_id,
|
|
request.name.strip(),
|
|
request.description,
|
|
request.sort_order,
|
|
),
|
|
)
|
|
room = cur.fetchone()
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
|
VALUES (%s, 'room.created', 'room', %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
room["id"],
|
|
psycopg.types.json.Jsonb(
|
|
{
|
|
"name": room["name"],
|
|
"site_id": room["site_id"],
|
|
"sort_order": room["sort_order"],
|
|
}
|
|
),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
return api_response(data=dict(room))
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.patch("/api/rooms/{room_id}")
|
|
def update_room(room_id: str, request: UpdateRoomRequest, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text, name, description, sort_order
|
|
FROM rooms
|
|
WHERE id = %s;
|
|
""",
|
|
(room_id,),
|
|
)
|
|
old_room = cur.fetchone()
|
|
|
|
if not old_room:
|
|
raise HTTPException(status_code=404, detail="Room not found")
|
|
|
|
new_name = request.name.strip() if request.name is not None else old_room["name"]
|
|
new_description = request.description if request.description is not None else old_room["description"]
|
|
new_sort_order = request.sort_order if request.sort_order is not None else old_room["sort_order"]
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE rooms
|
|
SET name = %s,
|
|
description = %s,
|
|
sort_order = %s,
|
|
updated_at = now()
|
|
WHERE id = %s
|
|
RETURNING id::text, site_id::text, name, description, sort_order, created_at, updated_at;
|
|
""",
|
|
(new_name, new_description, new_sort_order, room_id),
|
|
)
|
|
room = cur.fetchone()
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json, new_value_json)
|
|
VALUES (%s, 'room.updated', 'room', %s, %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
room["id"],
|
|
psycopg.types.json.Jsonb(json_safe(dict(old_room))),
|
|
psycopg.types.json.Jsonb(json_safe(dict(room))),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
return api_response(data=dict(room))
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.delete("/api/rooms/{room_id}")
|
|
def delete_room(room_id: str, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text, name, description, sort_order
|
|
FROM rooms
|
|
WHERE id = %s;
|
|
""",
|
|
(room_id,),
|
|
)
|
|
room = cur.fetchone()
|
|
|
|
if not room:
|
|
raise HTTPException(status_code=404, detail="Room not found")
|
|
|
|
cur.execute("DELETE FROM rooms WHERE id = %s;", (room_id,))
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json)
|
|
VALUES (%s, 'room.deleted', 'room', %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
room["id"],
|
|
psycopg.types.json.Jsonb(json_safe(dict(room))),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
return api_response(data={"deleted": True, "room_id": room_id})
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
class CreateRoomRequest(BaseModel):
|
|
site_id: str | None = None
|
|
name: str = Field(min_length=1, max_length=120)
|
|
description: str | None = None
|
|
sort_order: int = 0
|
|
|
|
|
|
class UpdateRoomRequest(BaseModel):
|
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
|
description: str | None = None
|
|
sort_order: int | None = None
|
|
|
|
|
|
@app.get("/api/sites")
|
|
def list_sites(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text, name, description, created_at, updated_at
|
|
FROM sites
|
|
ORDER BY name;
|
|
"""
|
|
)
|
|
sites = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=sites)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/rooms")
|
|
def list_rooms(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
rooms.id::text,
|
|
rooms.site_id::text,
|
|
sites.name AS site_name,
|
|
rooms.name,
|
|
rooms.description,
|
|
rooms.sort_order,
|
|
rooms.created_at,
|
|
rooms.updated_at
|
|
FROM rooms
|
|
JOIN sites ON sites.id = rooms.site_id
|
|
ORDER BY rooms.sort_order, rooms.name;
|
|
"""
|
|
)
|
|
rooms = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=rooms)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/rooms")
|
|
def create_room(request: CreateRoomRequest, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
site_id = request.site_id
|
|
|
|
if site_id is None:
|
|
cur.execute("SELECT id::text FROM sites ORDER BY name LIMIT 1;")
|
|
site = cur.fetchone()
|
|
if not site:
|
|
raise HTTPException(status_code=500, detail="No site exists")
|
|
site_id = site["id"]
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO rooms (site_id, name, description, sort_order)
|
|
VALUES (%s, %s, %s, %s)
|
|
RETURNING id::text, site_id::text, name, description, sort_order, created_at, updated_at;
|
|
""",
|
|
(
|
|
site_id,
|
|
request.name.strip(),
|
|
request.description,
|
|
request.sort_order,
|
|
),
|
|
)
|
|
room = cur.fetchone()
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
|
VALUES (%s, 'room.created', 'room', %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
room["id"],
|
|
psycopg.types.json.Jsonb(
|
|
{
|
|
"name": room["name"],
|
|
"site_id": room["site_id"],
|
|
"sort_order": room["sort_order"],
|
|
}
|
|
),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
return api_response(data=dict(room))
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.patch("/api/rooms/{room_id}")
|
|
def update_room(room_id: str, request: UpdateRoomRequest, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text, name, description, sort_order
|
|
FROM rooms
|
|
WHERE id = %s;
|
|
""",
|
|
(room_id,),
|
|
)
|
|
old_room = cur.fetchone()
|
|
|
|
if not old_room:
|
|
raise HTTPException(status_code=404, detail="Room not found")
|
|
|
|
new_name = request.name.strip() if request.name is not None else old_room["name"]
|
|
new_description = request.description if request.description is not None else old_room["description"]
|
|
new_sort_order = request.sort_order if request.sort_order is not None else old_room["sort_order"]
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE rooms
|
|
SET name = %s,
|
|
description = %s,
|
|
sort_order = %s,
|
|
updated_at = now()
|
|
WHERE id = %s
|
|
RETURNING id::text, site_id::text, name, description, sort_order, created_at, updated_at;
|
|
""",
|
|
(new_name, new_description, new_sort_order, room_id),
|
|
)
|
|
room = cur.fetchone()
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json, new_value_json)
|
|
VALUES (%s, 'room.updated', 'room', %s, %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
room["id"],
|
|
psycopg.types.json.Jsonb(json_safe(dict(old_room))),
|
|
psycopg.types.json.Jsonb(json_safe(dict(room))),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
return api_response(data=dict(room))
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.delete("/api/rooms/{room_id}")
|
|
def delete_room(room_id: str, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text, name, description, sort_order
|
|
FROM rooms
|
|
WHERE id = %s;
|
|
""",
|
|
(room_id,),
|
|
)
|
|
room = cur.fetchone()
|
|
|
|
if not room:
|
|
raise HTTPException(status_code=404, detail="Room not found")
|
|
|
|
cur.execute("DELETE FROM rooms WHERE id = %s;", (room_id,))
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json)
|
|
VALUES (%s, 'room.deleted', 'room', %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
room["id"],
|
|
psycopg.types.json.Jsonb(json_safe(dict(room))),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
return api_response(data={"deleted": True, "room_id": room_id})
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
import ipaddress
|
|
|
|
|
|
class CreateScanRangeRequest(BaseModel):
|
|
room_id: str | None = None
|
|
name: str = Field(min_length=1, max_length=120)
|
|
range_type: str = "cidr"
|
|
cidr: str | None = None
|
|
start_ip: str | None = None
|
|
end_ip: str | None = None
|
|
enabled: bool = True
|
|
scan_interval_seconds: int = Field(default=900, ge=60, le=86400)
|
|
timeout_ms: int = Field(default=3000, ge=500, le=30000)
|
|
retry_limit: int = Field(default=1, ge=0, le=5)
|
|
parallelism_limit: int = Field(default=10, ge=1, le=100)
|
|
stagger_ms: int = Field(default=250, ge=0, le=10000)
|
|
api_port: int = Field(default=4028, ge=1, le=65535)
|
|
|
|
|
|
class UpdateScanRangeRequest(BaseModel):
|
|
room_id: str | None = None
|
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
|
range_type: str | None = None
|
|
cidr: str | None = None
|
|
start_ip: str | None = None
|
|
end_ip: str | None = None
|
|
enabled: bool | None = None
|
|
scan_interval_seconds: int | None = Field(default=None, ge=60, le=86400)
|
|
timeout_ms: int | None = Field(default=None, ge=500, le=30000)
|
|
retry_limit: int | None = Field(default=None, ge=0, le=5)
|
|
parallelism_limit: int | None = Field(default=None, ge=1, le=100)
|
|
stagger_ms: int | None = Field(default=None, ge=0, le=10000)
|
|
api_port: int | None = Field(default=None, ge=1, le=65535)
|
|
|
|
|
|
def validate_scan_range_input(range_type: str, cidr: str | None, start_ip: str | None, end_ip: str | None) -> None:
|
|
if range_type not in ("cidr", "start_end"):
|
|
raise HTTPException(status_code=422, detail="range_type must be 'cidr' or 'start_end'")
|
|
|
|
if range_type == "cidr":
|
|
if not cidr:
|
|
raise HTTPException(status_code=422, detail="cidr is required when range_type is cidr")
|
|
try:
|
|
ipaddress.ip_network(cidr, strict=False)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail=f"Invalid CIDR range: {exc}")
|
|
|
|
if range_type == "start_end":
|
|
if not start_ip or not end_ip:
|
|
raise HTTPException(status_code=422, detail="start_ip and end_ip are required when range_type is start_end")
|
|
try:
|
|
start = ipaddress.ip_address(start_ip)
|
|
end = ipaddress.ip_address(end_ip)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail=f"Invalid IP address: {exc}")
|
|
|
|
if start.version != end.version:
|
|
raise HTTPException(status_code=422, detail="start_ip and end_ip must use the same IP version")
|
|
|
|
if int(start) > int(end):
|
|
raise HTTPException(status_code=422, detail="start_ip must be lower than or equal to end_ip")
|
|
|
|
|
|
@app.get("/api/scan-ranges")
|
|
def list_scan_ranges(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
scan_ranges.id::text,
|
|
scan_ranges.room_id::text,
|
|
rooms.name AS room_name,
|
|
scan_ranges.name,
|
|
scan_ranges.range_type,
|
|
scan_ranges.cidr,
|
|
host(scan_ranges.start_ip) AS start_ip,
|
|
host(scan_ranges.end_ip) AS end_ip,
|
|
scan_ranges.enabled,
|
|
scan_ranges.scan_interval_seconds,
|
|
scan_ranges.timeout_ms,
|
|
scan_ranges.retry_limit,
|
|
scan_ranges.parallelism_limit,
|
|
scan_ranges.stagger_ms,
|
|
scan_ranges.api_port,
|
|
scan_ranges.last_scan_at,
|
|
scan_ranges.next_scan_at,
|
|
scan_ranges.created_at,
|
|
scan_ranges.updated_at
|
|
FROM scan_ranges
|
|
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
|
|
ORDER BY rooms.sort_order, scan_ranges.name;
|
|
"""
|
|
)
|
|
ranges = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=ranges)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/scan-ranges")
|
|
def create_scan_range(request: CreateScanRangeRequest, current_user: dict = Depends(get_current_user)):
|
|
validate_scan_range_input(request.range_type, request.cidr, request.start_ip, request.end_ip)
|
|
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO scan_ranges (
|
|
room_id,
|
|
name,
|
|
range_type,
|
|
cidr,
|
|
start_ip,
|
|
end_ip,
|
|
enabled,
|
|
scan_interval_seconds,
|
|
timeout_ms,
|
|
retry_limit,
|
|
parallelism_limit,
|
|
stagger_ms,
|
|
api_port,
|
|
created_by_user_id,
|
|
next_scan_at
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, now())
|
|
RETURNING
|
|
id::text,
|
|
room_id::text,
|
|
name,
|
|
range_type,
|
|
cidr,
|
|
host(start_ip) AS start_ip,
|
|
host(end_ip) AS end_ip,
|
|
enabled,
|
|
scan_interval_seconds,
|
|
timeout_ms,
|
|
retry_limit,
|
|
parallelism_limit,
|
|
stagger_ms,
|
|
api_port,
|
|
last_scan_at,
|
|
next_scan_at,
|
|
created_at,
|
|
updated_at;
|
|
""",
|
|
(
|
|
request.room_id,
|
|
request.name.strip(),
|
|
request.range_type,
|
|
request.cidr,
|
|
request.start_ip,
|
|
request.end_ip,
|
|
request.enabled,
|
|
request.scan_interval_seconds,
|
|
request.timeout_ms,
|
|
request.retry_limit,
|
|
request.parallelism_limit,
|
|
request.stagger_ms,
|
|
request.api_port,
|
|
current_user["id"],
|
|
),
|
|
)
|
|
scan_range = cur.fetchone()
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
|
VALUES (%s, 'scan_range.created', 'scan_range', %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
scan_range["id"],
|
|
psycopg.types.json.Jsonb(json_safe(dict(scan_range))),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
return api_response(data=dict(scan_range))
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.patch("/api/scan-ranges/{scan_range_id}")
|
|
def update_scan_range(scan_range_id: str, request: UpdateScanRangeRequest, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
room_id::text,
|
|
name,
|
|
range_type,
|
|
cidr,
|
|
host(start_ip) AS start_ip,
|
|
host(end_ip) AS end_ip,
|
|
enabled,
|
|
scan_interval_seconds,
|
|
timeout_ms,
|
|
retry_limit,
|
|
parallelism_limit,
|
|
stagger_ms,
|
|
api_port
|
|
FROM scan_ranges
|
|
WHERE id = %s;
|
|
""",
|
|
(scan_range_id,),
|
|
)
|
|
old_range = cur.fetchone()
|
|
|
|
if not old_range:
|
|
raise HTTPException(status_code=404, detail="Scan range not found")
|
|
|
|
new_room_id = request.room_id if request.room_id is not None else old_range["room_id"]
|
|
new_name = request.name.strip() if request.name is not None else old_range["name"]
|
|
new_range_type = request.range_type if request.range_type is not None else old_range["range_type"]
|
|
new_cidr = request.cidr if request.cidr is not None else old_range["cidr"]
|
|
new_start_ip = request.start_ip if request.start_ip is not None else old_range["start_ip"]
|
|
new_end_ip = request.end_ip if request.end_ip is not None else old_range["end_ip"]
|
|
new_enabled = request.enabled if request.enabled is not None else old_range["enabled"]
|
|
new_scan_interval_seconds = request.scan_interval_seconds if request.scan_interval_seconds is not None else old_range["scan_interval_seconds"]
|
|
new_timeout_ms = request.timeout_ms if request.timeout_ms is not None else old_range["timeout_ms"]
|
|
new_retry_limit = request.retry_limit if request.retry_limit is not None else old_range["retry_limit"]
|
|
new_parallelism_limit = request.parallelism_limit if request.parallelism_limit is not None else old_range["parallelism_limit"]
|
|
new_stagger_ms = request.stagger_ms if request.stagger_ms is not None else old_range["stagger_ms"]
|
|
new_api_port = request.api_port if request.api_port is not None else old_range["api_port"]
|
|
|
|
validate_scan_range_input(new_range_type, new_cidr, new_start_ip, new_end_ip)
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE scan_ranges
|
|
SET room_id = %s,
|
|
name = %s,
|
|
range_type = %s,
|
|
cidr = %s,
|
|
start_ip = %s,
|
|
end_ip = %s,
|
|
enabled = %s,
|
|
scan_interval_seconds = %s,
|
|
timeout_ms = %s,
|
|
retry_limit = %s,
|
|
parallelism_limit = %s,
|
|
stagger_ms = %s,
|
|
api_port = %s,
|
|
updated_at = now()
|
|
WHERE id = %s
|
|
RETURNING
|
|
id::text,
|
|
room_id::text,
|
|
name,
|
|
range_type,
|
|
cidr,
|
|
host(start_ip) AS start_ip,
|
|
host(end_ip) AS end_ip,
|
|
enabled,
|
|
scan_interval_seconds,
|
|
timeout_ms,
|
|
retry_limit,
|
|
parallelism_limit,
|
|
stagger_ms,
|
|
api_port,
|
|
last_scan_at,
|
|
next_scan_at,
|
|
created_at,
|
|
updated_at;
|
|
""",
|
|
(
|
|
new_room_id,
|
|
new_name,
|
|
new_range_type,
|
|
new_cidr,
|
|
new_start_ip,
|
|
new_end_ip,
|
|
new_enabled,
|
|
new_scan_interval_seconds,
|
|
new_timeout_ms,
|
|
new_retry_limit,
|
|
new_parallelism_limit,
|
|
new_stagger_ms,
|
|
new_api_port,
|
|
scan_range_id,
|
|
),
|
|
)
|
|
scan_range = cur.fetchone()
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json, new_value_json)
|
|
VALUES (%s, 'scan_range.updated', 'scan_range', %s, %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
scan_range["id"],
|
|
psycopg.types.json.Jsonb(json_safe(dict(old_range))),
|
|
psycopg.types.json.Jsonb(json_safe(dict(scan_range))),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
return api_response(data=dict(scan_range))
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.delete("/api/scan-ranges/{scan_range_id}")
|
|
def delete_scan_range(scan_range_id: str, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text, name, range_type, cidr, host(start_ip) AS start_ip, end_ip::text
|
|
FROM scan_ranges
|
|
WHERE id = %s;
|
|
""",
|
|
(scan_range_id,),
|
|
)
|
|
old_range = cur.fetchone()
|
|
|
|
if not old_range:
|
|
raise HTTPException(status_code=404, detail="Scan range not found")
|
|
|
|
cur.execute("DELETE FROM scan_ranges WHERE id = %s;", (scan_range_id,))
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, old_value_json)
|
|
VALUES (%s, 'scan_range.deleted', 'scan_range', %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
old_range["id"],
|
|
psycopg.types.json.Jsonb(json_safe(dict(old_range))),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
return api_response(data={"deleted": True, "scan_range_id": scan_range_id})
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
@app.get("/api/scan-runs")
|
|
def list_scan_runs(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
scan_runs.id::text,
|
|
scan_runs.scan_range_id::text,
|
|
scan_ranges.name AS scan_range_name,
|
|
rooms.name AS room_name,
|
|
scan_runs.triggered_by,
|
|
scan_runs.triggered_by_user_id::text,
|
|
scan_runs.started_at,
|
|
scan_runs.finished_at,
|
|
scan_runs.status,
|
|
scan_runs.total_ips,
|
|
scan_runs.scanned_ips,
|
|
scan_runs.miners_found,
|
|
scan_runs.errors_count,
|
|
scan_runs.notes
|
|
FROM scan_runs
|
|
LEFT JOIN scan_ranges ON scan_ranges.id = scan_runs.scan_range_id
|
|
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
|
|
ORDER BY scan_runs.started_at DESC
|
|
LIMIT 100;
|
|
"""
|
|
)
|
|
scan_runs = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=scan_runs)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/scan-runs/{scan_run_id}")
|
|
def get_scan_run(scan_run_id: str, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
scan_runs.id::text,
|
|
scan_runs.scan_range_id::text,
|
|
scan_ranges.name AS scan_range_name,
|
|
rooms.name AS room_name,
|
|
scan_runs.triggered_by,
|
|
scan_runs.triggered_by_user_id::text,
|
|
scan_runs.started_at,
|
|
scan_runs.finished_at,
|
|
scan_runs.status,
|
|
scan_runs.total_ips,
|
|
scan_runs.scanned_ips,
|
|
scan_runs.miners_found,
|
|
scan_runs.errors_count,
|
|
scan_runs.notes
|
|
FROM scan_runs
|
|
LEFT JOIN scan_ranges ON scan_ranges.id = scan_runs.scan_range_id
|
|
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
|
|
WHERE scan_runs.id = %s;
|
|
""",
|
|
(scan_run_id,),
|
|
)
|
|
scan_run = cur.fetchone()
|
|
|
|
if not scan_run:
|
|
raise HTTPException(status_code=404, detail="Scan run not found")
|
|
|
|
return api_response(data=dict(scan_run))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/scan-runs/{scan_run_id}/results")
|
|
def list_scan_run_results(scan_run_id: str, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
scan_run_id::text,
|
|
host(ip_address) AS ip_address,
|
|
port,
|
|
detected,
|
|
mac_address,
|
|
response_ms,
|
|
status,
|
|
error_message,
|
|
raw_summary_json,
|
|
raw_version_json,
|
|
raw_pools_json,
|
|
raw_error_json,
|
|
created_at
|
|
FROM scan_results
|
|
WHERE scan_run_id = %s
|
|
ORDER BY ip_address
|
|
LIMIT 2000;
|
|
""",
|
|
(scan_run_id,),
|
|
)
|
|
results = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=results)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
@app.get("/api/scan-runs")
|
|
def list_scan_runs(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
scan_runs.id::text,
|
|
scan_runs.scan_range_id::text,
|
|
scan_ranges.name AS scan_range_name,
|
|
rooms.name AS room_name,
|
|
scan_runs.triggered_by,
|
|
scan_runs.triggered_by_user_id::text,
|
|
scan_runs.started_at,
|
|
scan_runs.finished_at,
|
|
scan_runs.status,
|
|
scan_runs.total_ips,
|
|
scan_runs.scanned_ips,
|
|
scan_runs.miners_found,
|
|
scan_runs.errors_count,
|
|
scan_runs.notes
|
|
FROM scan_runs
|
|
LEFT JOIN scan_ranges ON scan_ranges.id = scan_runs.scan_range_id
|
|
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
|
|
ORDER BY scan_runs.started_at DESC
|
|
LIMIT 100;
|
|
"""
|
|
)
|
|
scan_runs = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=scan_runs)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/scan-runs/{scan_run_id}")
|
|
def get_scan_run(scan_run_id: str, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
scan_runs.id::text,
|
|
scan_runs.scan_range_id::text,
|
|
scan_ranges.name AS scan_range_name,
|
|
rooms.name AS room_name,
|
|
scan_runs.triggered_by,
|
|
scan_runs.triggered_by_user_id::text,
|
|
scan_runs.started_at,
|
|
scan_runs.finished_at,
|
|
scan_runs.status,
|
|
scan_runs.total_ips,
|
|
scan_runs.scanned_ips,
|
|
scan_runs.miners_found,
|
|
scan_runs.errors_count,
|
|
scan_runs.notes
|
|
FROM scan_runs
|
|
LEFT JOIN scan_ranges ON scan_ranges.id = scan_runs.scan_range_id
|
|
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
|
|
WHERE scan_runs.id = %s;
|
|
""",
|
|
(scan_run_id,),
|
|
)
|
|
scan_run = cur.fetchone()
|
|
|
|
if not scan_run:
|
|
raise HTTPException(status_code=404, detail="Scan run not found")
|
|
|
|
return api_response(data=dict(scan_run))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/scan-runs/{scan_run_id}/results")
|
|
def list_scan_run_results(scan_run_id: str, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
scan_run_id::text,
|
|
host(ip_address) AS ip_address,
|
|
port,
|
|
detected,
|
|
mac_address,
|
|
response_ms,
|
|
status,
|
|
error_message,
|
|
raw_summary_json,
|
|
raw_version_json,
|
|
raw_pools_json,
|
|
raw_error_json,
|
|
created_at
|
|
FROM scan_results
|
|
WHERE scan_run_id = %s
|
|
ORDER BY ip_address
|
|
LIMIT 2000;
|
|
""",
|
|
(scan_run_id,),
|
|
)
|
|
results = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=results)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
import socket
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
|
|
|
class ScanNowRequest(BaseModel):
|
|
max_ips: int = Field(default=25, ge=1, le=2000)
|
|
specific_ips: list[str] | None = None
|
|
|
|
|
|
def expand_scan_range_for_scan(scan_range: dict, max_ips: int, specific_ips: list[str] | None = None) -> list[str]:
|
|
if specific_ips:
|
|
validated_ips = []
|
|
for ip in specific_ips:
|
|
try:
|
|
validated_ips.append(str(ipaddress.ip_address(ip)))
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail=f"Invalid IP address {ip}: {exc}")
|
|
return validated_ips[:max_ips]
|
|
|
|
range_type = scan_range["range_type"]
|
|
|
|
if range_type == "cidr":
|
|
network = ipaddress.ip_network(scan_range["cidr"], strict=False)
|
|
return [str(ip) for ip in list(network.hosts())[:max_ips]]
|
|
|
|
if range_type == "start_end":
|
|
start = ipaddress.ip_address(scan_range["start_ip"])
|
|
end = ipaddress.ip_address(scan_range["end_ip"])
|
|
|
|
ips = []
|
|
current = int(start)
|
|
end_int = int(end)
|
|
|
|
while current <= end_int and len(ips) < max_ips:
|
|
ips.append(str(ipaddress.ip_address(current)))
|
|
current += 1
|
|
|
|
return ips
|
|
|
|
raise HTTPException(status_code=422, detail="Unsupported scan range type")
|
|
|
|
|
|
def tcp_check_ip(ip_address: str, port: int, timeout_ms: int) -> dict:
|
|
started = time.perf_counter()
|
|
|
|
try:
|
|
with socket.create_connection((ip_address, port), timeout=timeout_ms / 1000):
|
|
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
|
return {
|
|
"ip_address": ip_address,
|
|
"port": port,
|
|
"detected": True,
|
|
"response_ms": elapsed_ms,
|
|
"status": "port_open",
|
|
"error_message": None,
|
|
}
|
|
except socket.timeout:
|
|
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
|
return {
|
|
"ip_address": ip_address,
|
|
"port": port,
|
|
"detected": False,
|
|
"response_ms": elapsed_ms,
|
|
"status": "timeout",
|
|
"error_message": "Connection timed out",
|
|
}
|
|
except OSError as exc:
|
|
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
|
return {
|
|
"ip_address": ip_address,
|
|
"port": port,
|
|
"detected": False,
|
|
"response_ms": elapsed_ms,
|
|
"status": "no_response",
|
|
"error_message": str(exc),
|
|
}
|
|
|
|
|
|
@app.post("/api/scan-ranges/{scan_range_id}/scan-now")
|
|
def scan_range_now(scan_range_id: str, request: ScanNowRequest, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
room_id::text,
|
|
name,
|
|
range_type,
|
|
cidr,
|
|
host(start_ip) AS start_ip,
|
|
host(end_ip) AS end_ip,
|
|
enabled,
|
|
timeout_ms,
|
|
retry_limit,
|
|
parallelism_limit,
|
|
stagger_ms,
|
|
api_port,
|
|
scan_interval_seconds
|
|
FROM scan_ranges
|
|
WHERE id = %s;
|
|
""",
|
|
(scan_range_id,),
|
|
)
|
|
scan_range = cur.fetchone()
|
|
|
|
if not scan_range:
|
|
raise HTTPException(status_code=404, detail="Scan range not found")
|
|
|
|
if not scan_range["enabled"]:
|
|
raise HTTPException(status_code=409, detail="Scan range is disabled")
|
|
|
|
ip_list = expand_scan_range_for_scan(dict(scan_range), request.max_ips, request.specific_ips)
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO scan_runs (
|
|
scan_range_id,
|
|
triggered_by,
|
|
triggered_by_user_id,
|
|
status,
|
|
total_ips,
|
|
scanned_ips,
|
|
miners_found,
|
|
errors_count,
|
|
notes
|
|
)
|
|
VALUES (%s, 'manual', %s, 'running', %s, 0, 0, 0, %s)
|
|
RETURNING id::text;
|
|
""",
|
|
(
|
|
scan_range_id,
|
|
current_user["id"],
|
|
len(ip_list),
|
|
"TCP port 4028 reachability scan only",
|
|
),
|
|
)
|
|
scan_run_id = cur.fetchone()["id"]
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
|
VALUES (%s, 'scan.started', 'scan_run', %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
scan_run_id,
|
|
psycopg.types.json.Jsonb(
|
|
{
|
|
"scan_range_id": scan_range_id,
|
|
"scan_range_name": scan_range["name"],
|
|
"total_ips": len(ip_list),
|
|
"mode": "tcp_4028_only",
|
|
}
|
|
),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
results = []
|
|
detected_count = 0
|
|
errors_count = 0
|
|
|
|
max_workers = min(scan_range["parallelism_limit"], len(ip_list))
|
|
|
|
if max_workers < 1:
|
|
max_workers = 1
|
|
|
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
future_map = {
|
|
executor.submit(
|
|
tcp_check_ip,
|
|
ip,
|
|
scan_range["api_port"],
|
|
scan_range["timeout_ms"],
|
|
): ip
|
|
for ip in ip_list
|
|
}
|
|
|
|
for future in as_completed(future_map):
|
|
result = future.result()
|
|
results.append(result)
|
|
|
|
if result["detected"]:
|
|
detected_count += 1
|
|
|
|
if result["status"] not in ("port_open", "timeout", "no_response"):
|
|
errors_count += 1
|
|
|
|
results = sorted(results, key=lambda item: ipaddress.ip_address(item["ip_address"]))
|
|
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
for result in results:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO scan_results (
|
|
scan_run_id,
|
|
ip_address,
|
|
port,
|
|
detected,
|
|
response_ms,
|
|
status,
|
|
error_message
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s);
|
|
""",
|
|
(
|
|
scan_run_id,
|
|
result["ip_address"],
|
|
result["port"],
|
|
result["detected"],
|
|
result["response_ms"],
|
|
result["status"],
|
|
result["error_message"],
|
|
),
|
|
)
|
|
|
|
final_status = "completed"
|
|
if errors_count > 0:
|
|
final_status = "completed_with_errors"
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE scan_runs
|
|
SET finished_at = now(),
|
|
status = %s,
|
|
scanned_ips = %s,
|
|
miners_found = %s,
|
|
errors_count = %s
|
|
WHERE id = %s
|
|
RETURNING
|
|
id::text,
|
|
scan_range_id::text,
|
|
triggered_by,
|
|
started_at,
|
|
finished_at,
|
|
status,
|
|
total_ips,
|
|
scanned_ips,
|
|
miners_found,
|
|
errors_count,
|
|
notes;
|
|
""",
|
|
(
|
|
final_status,
|
|
len(results),
|
|
detected_count,
|
|
errors_count,
|
|
scan_run_id,
|
|
),
|
|
)
|
|
scan_run = cur.fetchone()
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE scan_ranges
|
|
SET last_scan_at = now(),
|
|
next_scan_at = now() + make_interval(secs => scan_interval_seconds),
|
|
updated_at = now()
|
|
WHERE id = %s;
|
|
""",
|
|
(scan_range_id,),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
|
VALUES (%s, 'scan.completed', 'scan_run', %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
scan_run_id,
|
|
psycopg.types.json.Jsonb(
|
|
{
|
|
"scan_range_id": scan_range_id,
|
|
"total_ips": len(ip_list),
|
|
"scanned_ips": len(results),
|
|
"port_4028_open": detected_count,
|
|
"errors_count": errors_count,
|
|
}
|
|
),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
return api_response(
|
|
data={
|
|
"scan_run": dict(scan_run),
|
|
"summary": {
|
|
"scan_range_id": scan_range_id,
|
|
"scan_range_name": scan_range["name"],
|
|
"total_ips": len(ip_list),
|
|
"scanned_ips": len(results),
|
|
"port_4028_open": detected_count,
|
|
"errors_count": errors_count,
|
|
},
|
|
}
|
|
)
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
class WhatsminerReadTestRequest(BaseModel):
|
|
ip_address: str
|
|
port: int = Field(default=4028, ge=1, le=65535)
|
|
command: str = Field(default="summary", min_length=1, max_length=80)
|
|
params: dict[str, Any] | None = None
|
|
timeout_ms: int = Field(default=5000, ge=500, le=30000)
|
|
|
|
|
|
def read_from_socket_until_closed(sock: socket.socket, max_bytes: int = 1048576) -> bytes:
|
|
chunks = []
|
|
total = 0
|
|
|
|
while True:
|
|
chunk = sock.recv(65536)
|
|
if not chunk:
|
|
break
|
|
|
|
chunks.append(chunk)
|
|
total += len(chunk)
|
|
|
|
if total >= max_bytes:
|
|
break
|
|
|
|
return b"".join(chunks)
|
|
|
|
|
|
def send_whatsminer_read_command(ip_address: str, port: int, command: str, timeout_ms: int, params: dict[str, Any] | None = None) -> dict:
|
|
try:
|
|
ipaddress.ip_address(ip_address)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail=f"Invalid IP address: {exc}")
|
|
|
|
import json
|
|
|
|
started = time.perf_counter()
|
|
|
|
params = params or {}
|
|
|
|
command_payload = {"command": command}
|
|
command_payload.update(params)
|
|
|
|
cmd_payload = {"cmd": command}
|
|
cmd_payload.update(params)
|
|
|
|
command_payloads = [
|
|
{
|
|
"name": "json_command_plain",
|
|
"payload": json.dumps(command_payload, separators=(",", ":")).encode("utf-8"),
|
|
},
|
|
{
|
|
"name": "json_command_newline",
|
|
"payload": (json.dumps(command_payload, separators=(",", ":")) + "\n").encode("utf-8"),
|
|
},
|
|
{
|
|
"name": "json_cmd_plain",
|
|
"payload": json.dumps(cmd_payload, separators=(",", ":")).encode("utf-8"),
|
|
},
|
|
{
|
|
"name": "json_cmd_newline",
|
|
"payload": (json.dumps(cmd_payload, separators=(",", ":")) + "\n").encode("utf-8"),
|
|
},
|
|
{
|
|
"name": "plain_command",
|
|
"payload": command.encode("utf-8"),
|
|
},
|
|
{
|
|
"name": "plain_command_newline",
|
|
"payload": (command + "\n").encode("utf-8"),
|
|
},
|
|
]
|
|
|
|
attempts = []
|
|
|
|
for command_payload in command_payloads:
|
|
payload_name = command_payload["name"]
|
|
payload = command_payload["payload"]
|
|
|
|
try:
|
|
with socket.create_connection((ip_address, port), timeout=timeout_ms / 1000) as sock:
|
|
sock.settimeout(timeout_ms / 1000)
|
|
sock.sendall(payload)
|
|
raw_response = read_from_socket_until_closed(sock)
|
|
|
|
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
|
response_text = raw_response.decode("utf-8", errors="replace").strip()
|
|
|
|
attempts.append(
|
|
{
|
|
"payload_type": payload_name,
|
|
"bytes_received": len(raw_response),
|
|
"response_preview": response_text[:1000],
|
|
}
|
|
)
|
|
|
|
if response_text:
|
|
parsed_json = None
|
|
parse_error = None
|
|
|
|
try:
|
|
parsed_json = json.loads(response_text)
|
|
except Exception as exc:
|
|
parse_error = str(exc)
|
|
|
|
return {
|
|
"success": True,
|
|
"ip_address": ip_address,
|
|
"port": port,
|
|
"command": command,
|
|
"payload_type": payload_name,
|
|
"response_ms": elapsed_ms,
|
|
"bytes_received": len(raw_response),
|
|
"response_text": response_text,
|
|
"parsed_json": parsed_json,
|
|
"parse_error": parse_error,
|
|
"attempts": attempts,
|
|
}
|
|
|
|
except Exception as exc:
|
|
attempts.append(
|
|
{
|
|
"payload_type": payload_name,
|
|
"bytes_received": 0,
|
|
"error": str(exc),
|
|
}
|
|
)
|
|
|
|
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
|
|
|
return {
|
|
"success": False,
|
|
"ip_address": ip_address,
|
|
"port": port,
|
|
"command": command,
|
|
"response_ms": elapsed_ms,
|
|
"attempts": attempts,
|
|
}
|
|
|
|
|
|
@app.post("/api/tools/whatsminer-read-test")
|
|
def whatsminer_read_test(request: WhatsminerReadTestRequest, current_user: dict = Depends(get_current_user)):
|
|
result = send_whatsminer_read_command(
|
|
ip_address=request.ip_address,
|
|
port=request.port,
|
|
command=request.command,
|
|
timeout_ms=request.timeout_ms,
|
|
params=request.params,
|
|
)
|
|
|
|
return api_response(data=result)
|
|
|
|
@app.get("/api/miners")
|
|
def list_miners(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
miners.id::text,
|
|
miners.site_id::text,
|
|
sites.name AS site_name,
|
|
miners.room_id::text,
|
|
rooms.name AS room_name,
|
|
miners.asset_tag,
|
|
miners.current_mac,
|
|
host(miners.current_ip) AS current_ip,
|
|
miners.hostname,
|
|
miners.miner_sn,
|
|
miners.power_sn,
|
|
miners.model,
|
|
miners.miner_type,
|
|
miners.api_version,
|
|
miners.firmware_version,
|
|
miners.platform,
|
|
miners.chip,
|
|
miners.status,
|
|
miners.first_seen_at,
|
|
miners.last_seen_at,
|
|
miners.updated_at
|
|
FROM miners
|
|
LEFT JOIN sites ON sites.id = miners.site_id
|
|
LEFT JOIN rooms ON rooms.id = miners.room_id
|
|
ORDER BY rooms.sort_order, miners.current_ip, miners.current_mac;
|
|
"""
|
|
)
|
|
miners = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=miners)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/miners/{miner_id}")
|
|
def get_miner(miner_id: str, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
miners.id::text,
|
|
miners.site_id::text,
|
|
sites.name AS site_name,
|
|
miners.room_id::text,
|
|
rooms.name AS room_name,
|
|
miners.asset_tag,
|
|
miners.current_mac,
|
|
host(miners.current_ip) AS current_ip,
|
|
miners.hostname,
|
|
miners.miner_sn,
|
|
miners.power_sn,
|
|
miners.model,
|
|
miners.miner_type,
|
|
miners.api_version,
|
|
miners.firmware_version,
|
|
miners.platform,
|
|
miners.chip,
|
|
miners.status,
|
|
miners.notes,
|
|
miners.first_seen_at,
|
|
miners.last_seen_at,
|
|
miners.created_at,
|
|
miners.updated_at
|
|
FROM miners
|
|
LEFT JOIN sites ON sites.id = miners.site_id
|
|
LEFT JOIN rooms ON rooms.id = miners.room_id
|
|
WHERE miners.id = %s;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
miner = cur.fetchone()
|
|
|
|
if not miner:
|
|
raise HTTPException(status_code=404, detail="Miner not found")
|
|
|
|
return api_response(data=dict(miner))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/miners/{miner_id}/network-identities")
|
|
def list_miner_network_identities(miner_id: str, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
miner_id::text,
|
|
mac_address,
|
|
host(ip_address) AS ip_address,
|
|
hostname,
|
|
source,
|
|
is_current,
|
|
first_seen_at,
|
|
last_seen_at,
|
|
created_at,
|
|
updated_at
|
|
FROM miner_network_identities
|
|
WHERE miner_id = %s
|
|
ORDER BY is_current DESC, last_seen_at DESC;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
identities = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=identities)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/miners/{miner_id}/hashboards")
|
|
def list_miner_hashboards(miner_id: str, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
miner_id::text,
|
|
slot,
|
|
pcb_sn,
|
|
chip_data,
|
|
temperature_c,
|
|
chip_frequency,
|
|
mhs_av,
|
|
mhs_1m,
|
|
mhs_15m,
|
|
hs_rt,
|
|
factory_ghs,
|
|
effective_chips,
|
|
upfreq_complete,
|
|
first_seen_at,
|
|
last_seen_at,
|
|
updated_at
|
|
FROM miner_hashboards
|
|
WHERE miner_id = %s
|
|
ORDER BY slot;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
hashboards = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=hashboards)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/miners/{miner_id}/status-snapshots")
|
|
def list_miner_status_snapshots(miner_id: str, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
miner_id::text,
|
|
host(observed_ip) AS observed_ip,
|
|
observed_at,
|
|
mhs_av,
|
|
mhs_1m,
|
|
mhs_15m,
|
|
hs_rt,
|
|
power_w,
|
|
power_rate_w_per_th,
|
|
env_temp_c,
|
|
chip_temp_min_c,
|
|
chip_temp_max_c,
|
|
chip_temp_avg_c,
|
|
fan_speed_in_rpm,
|
|
fan_speed_out_rpm,
|
|
uptime_seconds,
|
|
hash_stable,
|
|
pool_rejected_percent,
|
|
created_at
|
|
FROM miner_status_snapshots
|
|
WHERE miner_id = %s
|
|
ORDER BY observed_at DESC
|
|
LIMIT 100;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
snapshots = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=snapshots)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
class DiscoverOneMinerRequest(BaseModel):
|
|
ip_address: str
|
|
port: int = Field(default=4028, ge=1, le=65535)
|
|
scan_range_id: str | None = None
|
|
timeout_ms: int = Field(default=5000, ge=500, le=30000)
|
|
|
|
|
|
def clean_ip_text(value: Any) -> str | None:
|
|
if value is None:
|
|
return None
|
|
return str(value).split("/")[0]
|
|
|
|
|
|
def to_bool_or_none(value: Any) -> bool | None:
|
|
if value is None:
|
|
return None
|
|
|
|
if isinstance(value, bool):
|
|
return value
|
|
|
|
text = str(value).strip().lower()
|
|
|
|
if text in ("true", "1", "yes", "y", "on"):
|
|
return True
|
|
|
|
if text in ("false", "0", "no", "n", "off"):
|
|
return False
|
|
|
|
return None
|
|
|
|
|
|
def get_msg_dict(result: dict, command_name: str) -> dict:
|
|
if not result.get("success"):
|
|
raise HTTPException(status_code=502, detail=f"{command_name} failed: {result}")
|
|
|
|
parsed_json = result.get("parsed_json")
|
|
|
|
if not isinstance(parsed_json, dict):
|
|
raise HTTPException(status_code=502, detail=f"{command_name} did not return JSON")
|
|
|
|
if parsed_json.get("STATUS") == "E":
|
|
raise HTTPException(status_code=502, detail=f"{command_name} returned error: {parsed_json}")
|
|
|
|
msg = parsed_json.get("Msg", {})
|
|
|
|
if isinstance(msg, dict):
|
|
return msg
|
|
|
|
return {}
|
|
|
|
|
|
def find_scan_range_context(cur, ip_address: str, scan_range_id: str | None) -> dict:
|
|
if scan_range_id:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
scan_ranges.id::text AS scan_range_id,
|
|
scan_ranges.room_id::text,
|
|
rooms.site_id::text,
|
|
rooms.name AS room_name,
|
|
scan_ranges.name AS scan_range_name
|
|
FROM scan_ranges
|
|
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
|
|
WHERE scan_ranges.id = %s;
|
|
""",
|
|
(scan_range_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
|
|
if row:
|
|
return dict(row)
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
scan_ranges.id::text AS scan_range_id,
|
|
scan_ranges.room_id::text,
|
|
rooms.site_id::text,
|
|
rooms.name AS room_name,
|
|
scan_ranges.name AS scan_range_name,
|
|
scan_ranges.range_type,
|
|
scan_ranges.cidr,
|
|
host(scan_ranges.start_ip) AS start_ip,
|
|
scan_ranges.end_ip::text
|
|
FROM scan_ranges
|
|
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
|
|
WHERE scan_ranges.enabled = true;
|
|
"""
|
|
)
|
|
|
|
ip_obj = ipaddress.ip_address(ip_address)
|
|
|
|
for row in cur.fetchall():
|
|
row_dict = dict(row)
|
|
|
|
if row_dict["range_type"] == "cidr" and row_dict["cidr"]:
|
|
network = ipaddress.ip_network(row_dict["cidr"], strict=False)
|
|
if ip_obj in network:
|
|
return row_dict
|
|
|
|
if row_dict["range_type"] == "start_end" and row_dict["start_ip"] and row_dict["end_ip"]:
|
|
start_ip = ipaddress.ip_address(clean_ip_text(row_dict["start_ip"]))
|
|
end_ip = ipaddress.ip_address(clean_ip_text(row_dict["end_ip"]))
|
|
|
|
if int(start_ip) <= int(ip_obj) <= int(end_ip):
|
|
return row_dict
|
|
|
|
return {
|
|
"scan_range_id": scan_range_id,
|
|
"room_id": None,
|
|
"site_id": None,
|
|
"room_name": None,
|
|
"scan_range_name": None,
|
|
}
|
|
|
|
|
|
def find_existing_miner(cur, miner_sn: str | None, mac_address: str | None, ip_address: str) -> dict | None:
|
|
if miner_sn:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text
|
|
FROM miners
|
|
WHERE miner_sn = %s
|
|
LIMIT 1;
|
|
""",
|
|
(miner_sn,),
|
|
)
|
|
row = cur.fetchone()
|
|
if row:
|
|
return dict(row)
|
|
|
|
if mac_address:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text
|
|
FROM miners
|
|
WHERE lower(current_mac) = lower(%s)
|
|
LIMIT 1;
|
|
""",
|
|
(mac_address,),
|
|
)
|
|
row = cur.fetchone()
|
|
if row:
|
|
return dict(row)
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text
|
|
FROM miners
|
|
WHERE current_ip = %s
|
|
LIMIT 1;
|
|
""",
|
|
(ip_address,),
|
|
)
|
|
row = cur.fetchone()
|
|
|
|
if row:
|
|
return dict(row)
|
|
|
|
return None
|
|
|
|
|
|
@app.post("/api/miners/discover-one")
|
|
def discover_one_miner(request: DiscoverOneMinerRequest, current_user: dict = Depends(get_current_user)):
|
|
try:
|
|
ip_address = str(ipaddress.ip_address(request.ip_address))
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail=f"Invalid IP address: {exc}")
|
|
|
|
miner_info_result = send_whatsminer_read_command(
|
|
ip_address=ip_address,
|
|
port=request.port,
|
|
command="get_miner_info",
|
|
timeout_ms=request.timeout_ms,
|
|
params={
|
|
"info": "ip,proto,netmask,gateway,dns,hostname,mac,ledstat,minersn,powersn"
|
|
},
|
|
)
|
|
|
|
version_result = send_whatsminer_read_command(
|
|
ip_address=ip_address,
|
|
port=request.port,
|
|
command="get_version",
|
|
timeout_ms=request.timeout_ms,
|
|
)
|
|
|
|
summary_result = send_whatsminer_read_command(
|
|
ip_address=ip_address,
|
|
port=request.port,
|
|
command="summary",
|
|
timeout_ms=request.timeout_ms,
|
|
)
|
|
|
|
edevs_result = send_whatsminer_read_command(
|
|
ip_address=ip_address,
|
|
port=request.port,
|
|
command="edevs",
|
|
timeout_ms=request.timeout_ms,
|
|
)
|
|
|
|
pools_result = send_whatsminer_read_command(
|
|
ip_address=ip_address,
|
|
port=request.port,
|
|
command="pools",
|
|
timeout_ms=request.timeout_ms,
|
|
)
|
|
|
|
miner_info_json = miner_info_result.get("parsed_json") or {}
|
|
version_json = version_result.get("parsed_json") or {}
|
|
summary_json = summary_result.get("parsed_json") or {}
|
|
edevs_json = edevs_result.get("parsed_json") or {}
|
|
pools_json = pools_result.get("parsed_json") or {}
|
|
|
|
miner_info_msg = get_msg_dict(miner_info_result, "get_miner_info")
|
|
version_msg = get_msg_dict(version_result, "get_version")
|
|
try:
|
|
summary_msg = get_msg_dict(summary_result, "summary")
|
|
summary_warning = None
|
|
except HTTPException as exc:
|
|
summary_msg = {}
|
|
summary_warning = exc.detail
|
|
|
|
mac_address = miner_info_msg.get("mac")
|
|
if mac_address:
|
|
mac_address = str(mac_address).upper()
|
|
|
|
hostname = miner_info_msg.get("hostname")
|
|
miner_sn = miner_info_msg.get("minersn")
|
|
power_sn = miner_info_msg.get("powersn")
|
|
|
|
miner_type = version_msg.get("miner_type")
|
|
model = miner_type.split("_")[0] if miner_type else None
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
context = find_scan_range_context(cur, ip_address, request.scan_range_id)
|
|
|
|
existing_miner = find_existing_miner(
|
|
cur=cur,
|
|
miner_sn=miner_sn,
|
|
mac_address=mac_address,
|
|
ip_address=ip_address,
|
|
)
|
|
|
|
if existing_miner:
|
|
miner_id = existing_miner["id"]
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE miners
|
|
SET site_id = COALESCE(%s, site_id),
|
|
room_id = COALESCE(%s, room_id),
|
|
current_mac = %s,
|
|
current_ip = %s,
|
|
hostname = %s,
|
|
miner_sn = %s,
|
|
power_sn = %s,
|
|
model = %s,
|
|
miner_type = %s,
|
|
api_version = %s,
|
|
firmware_version = %s,
|
|
platform = %s,
|
|
chip = %s,
|
|
status = 'online',
|
|
last_seen_at = now(),
|
|
updated_at = now()
|
|
WHERE id = %s
|
|
RETURNING id::text;
|
|
""",
|
|
(
|
|
context.get("site_id"),
|
|
context.get("room_id"),
|
|
mac_address,
|
|
ip_address,
|
|
hostname,
|
|
miner_sn,
|
|
power_sn,
|
|
model,
|
|
miner_type,
|
|
version_msg.get("api_ver"),
|
|
version_msg.get("fw_ver"),
|
|
version_msg.get("platform"),
|
|
version_msg.get("chip"),
|
|
miner_id,
|
|
),
|
|
)
|
|
miner_id = cur.fetchone()["id"]
|
|
action = "miner.updated"
|
|
|
|
else:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO miners (
|
|
site_id,
|
|
room_id,
|
|
current_mac,
|
|
current_ip,
|
|
hostname,
|
|
miner_sn,
|
|
power_sn,
|
|
model,
|
|
miner_type,
|
|
api_version,
|
|
firmware_version,
|
|
platform,
|
|
chip,
|
|
status
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'online')
|
|
RETURNING id::text;
|
|
""",
|
|
(
|
|
context.get("site_id"),
|
|
context.get("room_id"),
|
|
mac_address,
|
|
ip_address,
|
|
hostname,
|
|
miner_sn,
|
|
power_sn,
|
|
model,
|
|
miner_type,
|
|
version_msg.get("api_ver"),
|
|
version_msg.get("fw_ver"),
|
|
version_msg.get("platform"),
|
|
version_msg.get("chip"),
|
|
),
|
|
)
|
|
miner_id = cur.fetchone()["id"]
|
|
action = "miner.created"
|
|
|
|
if mac_address:
|
|
cur.execute(
|
|
"""
|
|
UPDATE miner_network_identities
|
|
SET is_current = false,
|
|
updated_at = now()
|
|
WHERE miner_id = %s;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text
|
|
FROM miner_network_identities
|
|
WHERE miner_id = %s
|
|
AND lower(mac_address) = lower(%s)
|
|
LIMIT 1;
|
|
""",
|
|
(miner_id, mac_address),
|
|
)
|
|
identity = cur.fetchone()
|
|
|
|
if identity:
|
|
cur.execute(
|
|
"""
|
|
UPDATE miner_network_identities
|
|
SET ip_address = %s,
|
|
hostname = %s,
|
|
source = 'scanner',
|
|
is_current = true,
|
|
last_seen_at = now(),
|
|
updated_at = now()
|
|
WHERE id = %s;
|
|
""",
|
|
(ip_address, hostname, identity["id"]),
|
|
)
|
|
else:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO miner_network_identities (
|
|
miner_id,
|
|
mac_address,
|
|
ip_address,
|
|
hostname,
|
|
source,
|
|
is_current
|
|
)
|
|
VALUES (%s, %s, %s, %s, 'scanner', true);
|
|
""",
|
|
(miner_id, mac_address, ip_address, hostname),
|
|
)
|
|
|
|
for board in edevs_json.get("DEVS", []):
|
|
slot = board.get("Slot")
|
|
|
|
if slot is None:
|
|
continue
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO miner_hashboards (
|
|
miner_id,
|
|
slot,
|
|
pcb_sn,
|
|
chip_data,
|
|
temperature_c,
|
|
chip_frequency,
|
|
mhs_av,
|
|
mhs_1m,
|
|
mhs_15m,
|
|
hs_rt,
|
|
factory_ghs,
|
|
effective_chips,
|
|
upfreq_complete,
|
|
raw_json
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
ON CONFLICT ON CONSTRAINT uq_miner_hashboards_miner_slot
|
|
DO UPDATE SET
|
|
pcb_sn = EXCLUDED.pcb_sn,
|
|
chip_data = EXCLUDED.chip_data,
|
|
temperature_c = EXCLUDED.temperature_c,
|
|
chip_frequency = EXCLUDED.chip_frequency,
|
|
mhs_av = EXCLUDED.mhs_av,
|
|
mhs_1m = EXCLUDED.mhs_1m,
|
|
mhs_15m = EXCLUDED.mhs_15m,
|
|
hs_rt = EXCLUDED.hs_rt,
|
|
factory_ghs = EXCLUDED.factory_ghs,
|
|
effective_chips = EXCLUDED.effective_chips,
|
|
upfreq_complete = EXCLUDED.upfreq_complete,
|
|
raw_json = EXCLUDED.raw_json,
|
|
last_seen_at = now(),
|
|
updated_at = now();
|
|
""",
|
|
(
|
|
miner_id,
|
|
int(slot),
|
|
board.get("PCB SN"),
|
|
board.get("Chip Data"),
|
|
board.get("Temperature"),
|
|
board.get("Chip Frequency"),
|
|
board.get("MHS av"),
|
|
board.get("MHS 1m"),
|
|
board.get("MHS 15m"),
|
|
board.get("HS RT"),
|
|
board.get("Factory GHS"),
|
|
board.get("Effective Chips"),
|
|
board.get("Upfreq Complete"),
|
|
psycopg.types.json.Jsonb(json_safe(board)),
|
|
),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO miner_status_snapshots (
|
|
miner_id,
|
|
observed_ip,
|
|
mhs_av,
|
|
mhs_1m,
|
|
mhs_15m,
|
|
hs_rt,
|
|
power_w,
|
|
power_rate_w_per_th,
|
|
env_temp_c,
|
|
chip_temp_min_c,
|
|
chip_temp_max_c,
|
|
chip_temp_avg_c,
|
|
fan_speed_in_rpm,
|
|
fan_speed_out_rpm,
|
|
uptime_seconds,
|
|
hash_stable,
|
|
pool_rejected_percent,
|
|
raw_summary_json,
|
|
raw_version_json,
|
|
raw_miner_info_json
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);
|
|
""",
|
|
(
|
|
miner_id,
|
|
ip_address,
|
|
summary_msg.get("MHS av"),
|
|
summary_msg.get("MHS 1m"),
|
|
summary_msg.get("MHS 15m"),
|
|
summary_msg.get("HS RT"),
|
|
summary_msg.get("Power"),
|
|
summary_msg.get("Power Rate"),
|
|
summary_msg.get("Env Temp"),
|
|
summary_msg.get("Chip Temp Min"),
|
|
summary_msg.get("Chip Temp Max"),
|
|
summary_msg.get("Chip Temp Avg"),
|
|
summary_msg.get("Fan Speed In"),
|
|
summary_msg.get("Fan Speed Out"),
|
|
summary_msg.get("Uptime"),
|
|
to_bool_or_none(summary_msg.get("Hash Stable")),
|
|
summary_msg.get("Pool Rejected%"),
|
|
psycopg.types.json.Jsonb(json_safe(summary_json)),
|
|
psycopg.types.json.Jsonb(json_safe(version_json)),
|
|
psycopg.types.json.Jsonb(json_safe(miner_info_json)),
|
|
),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
|
VALUES (%s, %s, 'miner', %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
action,
|
|
miner_id,
|
|
psycopg.types.json.Jsonb(
|
|
{
|
|
"ip_address": ip_address,
|
|
"mac_address": mac_address,
|
|
"miner_sn": miner_sn,
|
|
"power_sn": power_sn,
|
|
"miner_type": miner_type,
|
|
"firmware_version": version_msg.get("fw_ver"),
|
|
"room_id": context.get("room_id"),
|
|
"scan_range_id": context.get("scan_range_id"),
|
|
}
|
|
),
|
|
),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
miners.id::text,
|
|
miners.site_id::text,
|
|
sites.name AS site_name,
|
|
miners.room_id::text,
|
|
rooms.name AS room_name,
|
|
miners.current_mac,
|
|
host(miners.current_ip) AS current_ip,
|
|
miners.hostname,
|
|
miners.miner_sn,
|
|
miners.power_sn,
|
|
miners.model,
|
|
miners.miner_type,
|
|
miners.api_version,
|
|
miners.firmware_version,
|
|
miners.platform,
|
|
miners.chip,
|
|
miners.status,
|
|
miners.first_seen_at,
|
|
miners.last_seen_at,
|
|
miners.updated_at
|
|
FROM miners
|
|
LEFT JOIN sites ON sites.id = miners.site_id
|
|
LEFT JOIN rooms ON rooms.id = miners.room_id
|
|
WHERE miners.id = %s;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
miner = dict(cur.fetchone())
|
|
|
|
conn.commit()
|
|
|
|
return api_response(
|
|
data={
|
|
"action": action,
|
|
"miner": miner,
|
|
"summary": {
|
|
"ip_address": ip_address,
|
|
"mac_address": mac_address,
|
|
"miner_sn": miner_sn,
|
|
"power_sn": power_sn,
|
|
"miner_type": miner_type,
|
|
"firmware_version": version_msg.get("fw_ver"),
|
|
"hashboards_seen": len(edevs_json.get("DEVS", [])),
|
|
"pools_seen": len(pools_json.get("POOLS", [])),
|
|
"hashrate_mhs_av": summary_msg.get("MHS av"),
|
|
"power_w": summary_msg.get("Power"),
|
|
"chip_temp_max_c": summary_msg.get("Chip Temp Max"),
|
|
"summary_warning": summary_warning,
|
|
},
|
|
}
|
|
)
|
|
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
@app.post("/api/scan-ranges/{scan_range_id}/discover-now")
|
|
def discover_scan_range_now(scan_range_id: str, request: ScanNowRequest, current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
room_id::text,
|
|
name,
|
|
range_type,
|
|
cidr,
|
|
host(start_ip) AS start_ip,
|
|
host(end_ip) AS end_ip,
|
|
enabled,
|
|
timeout_ms,
|
|
retry_limit,
|
|
parallelism_limit,
|
|
stagger_ms,
|
|
api_port,
|
|
scan_interval_seconds
|
|
FROM scan_ranges
|
|
WHERE id = %s;
|
|
""",
|
|
(scan_range_id,),
|
|
)
|
|
scan_range = cur.fetchone()
|
|
|
|
if not scan_range:
|
|
raise HTTPException(status_code=404, detail="Scan range not found")
|
|
|
|
if not scan_range["enabled"]:
|
|
raise HTTPException(status_code=409, detail="Scan range is disabled")
|
|
|
|
ip_list = expand_scan_range_for_scan(dict(scan_range), request.max_ips, request.specific_ips)
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO scan_runs (
|
|
scan_range_id,
|
|
triggered_by,
|
|
triggered_by_user_id,
|
|
status,
|
|
total_ips,
|
|
scanned_ips,
|
|
miners_found,
|
|
errors_count,
|
|
notes
|
|
)
|
|
VALUES (%s, 'manual', %s, 'running', %s, 0, 0, 0, %s)
|
|
RETURNING id::text;
|
|
""",
|
|
(
|
|
scan_range_id,
|
|
current_user["id"],
|
|
len(ip_list),
|
|
"TCP port 4028 scan with read-only miner discovery",
|
|
),
|
|
)
|
|
scan_run_id = cur.fetchone()["id"]
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
|
VALUES (%s, 'scan.discovery.started', 'scan_run', %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
scan_run_id,
|
|
psycopg.types.json.Jsonb(
|
|
{
|
|
"scan_range_id": scan_range_id,
|
|
"scan_range_name": scan_range["name"],
|
|
"total_ips": len(ip_list),
|
|
"mode": "tcp_4028_then_whatsminer_read_only_discovery",
|
|
}
|
|
),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
results = []
|
|
detected_count = 0
|
|
|
|
max_workers = min(scan_range["parallelism_limit"], len(ip_list))
|
|
if max_workers < 1:
|
|
max_workers = 1
|
|
|
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
future_map = {
|
|
executor.submit(
|
|
tcp_check_ip,
|
|
ip,
|
|
scan_range["api_port"],
|
|
scan_range["timeout_ms"],
|
|
): ip
|
|
for ip in ip_list
|
|
}
|
|
|
|
for future in as_completed(future_map):
|
|
result = future.result()
|
|
results.append(result)
|
|
|
|
if result["detected"]:
|
|
detected_count += 1
|
|
|
|
results = sorted(results, key=lambda item: ipaddress.ip_address(item["ip_address"]))
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
for result in results:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO scan_results (
|
|
scan_run_id,
|
|
ip_address,
|
|
port,
|
|
detected,
|
|
response_ms,
|
|
status,
|
|
error_message
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s);
|
|
""",
|
|
(
|
|
scan_run_id,
|
|
result["ip_address"],
|
|
result["port"],
|
|
result["detected"],
|
|
result["response_ms"],
|
|
result["status"],
|
|
result["error_message"],
|
|
),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE scan_ranges
|
|
SET last_scan_at = now(),
|
|
next_scan_at = now() + make_interval(secs => scan_interval_seconds),
|
|
updated_at = now()
|
|
WHERE id = %s;
|
|
""",
|
|
(scan_range_id,),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE scan_runs
|
|
SET scanned_ips = %s,
|
|
miners_found = %s,
|
|
errors_count = 0
|
|
WHERE id = %s;
|
|
""",
|
|
(
|
|
len(results),
|
|
detected_count,
|
|
scan_run_id,
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
discoveries = []
|
|
discovery_errors = []
|
|
|
|
for result in results:
|
|
if not result["detected"]:
|
|
continue
|
|
|
|
try:
|
|
discovery_response = discover_one_miner(
|
|
DiscoverOneMinerRequest(
|
|
ip_address=result["ip_address"],
|
|
port=result["port"],
|
|
scan_range_id=scan_range_id,
|
|
timeout_ms=scan_range["timeout_ms"],
|
|
),
|
|
current_user=current_user,
|
|
)
|
|
|
|
discoveries.append(discovery_response["data"])
|
|
|
|
except HTTPException as exc:
|
|
discovery_errors.append(
|
|
{
|
|
"ip_address": result["ip_address"],
|
|
"status_code": exc.status_code,
|
|
"detail": exc.detail,
|
|
}
|
|
)
|
|
|
|
except Exception as exc:
|
|
discovery_errors.append(
|
|
{
|
|
"ip_address": result["ip_address"],
|
|
"status_code": 500,
|
|
"detail": str(exc),
|
|
}
|
|
)
|
|
|
|
final_status = "completed"
|
|
if discovery_errors:
|
|
final_status = "completed_with_errors"
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
UPDATE scan_runs
|
|
SET finished_at = now(),
|
|
status = %s,
|
|
miners_found = %s,
|
|
errors_count = %s,
|
|
notes = %s
|
|
WHERE id = %s
|
|
RETURNING
|
|
id::text,
|
|
scan_range_id::text,
|
|
triggered_by,
|
|
started_at,
|
|
finished_at,
|
|
status,
|
|
total_ips,
|
|
scanned_ips,
|
|
miners_found,
|
|
errors_count,
|
|
notes;
|
|
""",
|
|
(
|
|
final_status,
|
|
len(discoveries),
|
|
len(discovery_errors),
|
|
"TCP port 4028 scan with read-only miner discovery",
|
|
scan_run_id,
|
|
),
|
|
)
|
|
scan_run = cur.fetchone()
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
|
VALUES (%s, 'scan.discovery.completed', 'scan_run', %s, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
scan_run_id,
|
|
psycopg.types.json.Jsonb(
|
|
{
|
|
"scan_range_id": scan_range_id,
|
|
"total_ips": len(ip_list),
|
|
"scanned_ips": len(results),
|
|
"port_4028_open": detected_count,
|
|
"miners_discovered": len(discoveries),
|
|
"discovery_errors": len(discovery_errors),
|
|
}
|
|
),
|
|
),
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
return api_response(
|
|
data={
|
|
"scan_run": dict(scan_run),
|
|
"summary": {
|
|
"scan_range_id": scan_range_id,
|
|
"scan_range_name": scan_range["name"],
|
|
"total_ips": len(ip_list),
|
|
"scanned_ips": len(results),
|
|
"port_4028_open": detected_count,
|
|
"miners_discovered": len(discoveries),
|
|
"discovery_errors": len(discovery_errors),
|
|
},
|
|
"discoveries": discoveries,
|
|
"discovery_errors": discovery_errors,
|
|
}
|
|
)
|
|
|
|
@app.get("/api/dashboard/summary")
|
|
def dashboard_summary(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
COUNT(*) AS total_miners,
|
|
COUNT(*) FILTER (WHERE status = 'online') AS online_miners,
|
|
COUNT(*) FILTER (WHERE current_mac IS NULL OR current_mac = '') AS missing_mac_count,
|
|
COUNT(*) FILTER (WHERE miner_type IS NULL OR miner_type = '') AS missing_miner_type_count,
|
|
COUNT(*) FILTER (WHERE firmware_version IS NULL OR firmware_version = '') AS missing_firmware_count,
|
|
COUNT(*) FILTER (WHERE last_seen_at >= now() - interval '30 minutes') AS seen_last_30_minutes,
|
|
COUNT(*) FILTER (WHERE last_seen_at < now() - interval '30 minutes') AS not_seen_last_30_minutes
|
|
FROM miners;
|
|
"""
|
|
)
|
|
totals = dict(cur.fetchone())
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
COALESCE(rooms.name, 'Unassigned') AS room_name,
|
|
COUNT(miners.id) AS miner_count,
|
|
COUNT(miners.id) FILTER (WHERE miners.status = 'online') AS online_count,
|
|
COUNT(miners.id) FILTER (WHERE miners.last_seen_at >= now() - interval '30 minutes') AS seen_last_30_minutes
|
|
FROM miners
|
|
LEFT JOIN rooms ON rooms.id = miners.room_id
|
|
GROUP BY rooms.name, rooms.sort_order
|
|
ORDER BY rooms.sort_order, rooms.name;
|
|
"""
|
|
)
|
|
by_room = [dict(row) for row in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
COALESCE(NULLIF(miner_type, ''), NULLIF(model, ''), 'Unknown') AS model,
|
|
COUNT(*) AS miner_count
|
|
FROM miners
|
|
GROUP BY COALESCE(NULLIF(miner_type, ''), NULLIF(model, ''), 'Unknown')
|
|
ORDER BY miner_count DESC, model;
|
|
"""
|
|
)
|
|
by_model = [dict(row) for row in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
COALESCE(NULLIF(firmware_version, ''), 'Unknown') AS firmware_version,
|
|
COUNT(*) AS miner_count
|
|
FROM miners
|
|
GROUP BY COALESCE(NULLIF(firmware_version, ''), 'Unknown')
|
|
ORDER BY miner_count DESC, firmware_version;
|
|
"""
|
|
)
|
|
by_firmware = [dict(row) for row in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
COALESCE(NULLIF(status, ''), 'Unknown') AS status,
|
|
COUNT(*) AS miner_count
|
|
FROM miners
|
|
GROUP BY COALESCE(NULLIF(status, ''), 'Unknown')
|
|
ORDER BY miner_count DESC, status;
|
|
"""
|
|
)
|
|
by_status = [dict(row) for row in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
WITH latest AS (
|
|
SELECT DISTINCT ON (miner_id)
|
|
miner_id,
|
|
observed_at,
|
|
power_w,
|
|
power_rate_w_per_th,
|
|
chip_temp_max_c,
|
|
chip_temp_avg_c,
|
|
mhs_av,
|
|
hash_stable
|
|
FROM miner_status_snapshots
|
|
ORDER BY miner_id, observed_at DESC
|
|
)
|
|
SELECT
|
|
COUNT(*) AS miners_with_snapshots,
|
|
ROUND(AVG(power_w)::numeric, 2)::float AS avg_power_w,
|
|
ROUND(SUM(power_w)::numeric, 2)::float AS total_power_w,
|
|
ROUND(AVG(power_rate_w_per_th)::numeric, 2)::float AS avg_power_rate_w_per_th,
|
|
ROUND(MAX(chip_temp_max_c)::numeric, 2)::float AS hottest_chip_temp_c,
|
|
ROUND(AVG(chip_temp_avg_c)::numeric, 2)::float AS avg_chip_temp_c,
|
|
ROUND(SUM(mhs_av)::numeric / 1000000, 2)::float AS total_hashrate_ths,
|
|
COUNT(*) FILTER (WHERE chip_temp_max_c >= 90) AS miners_chip_temp_90c_or_higher,
|
|
COUNT(*) FILTER (WHERE hash_stable = false) AS miners_hash_unstable
|
|
FROM latest;
|
|
"""
|
|
)
|
|
telemetry = dict(cur.fetchone())
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
COUNT(DISTINCT miner_id) AS miners_with_hashboards,
|
|
COUNT(*) AS total_hashboards,
|
|
COUNT(*) FILTER (WHERE temperature_c >= 90) AS hashboards_90c_or_higher,
|
|
COUNT(*) FILTER (WHERE pcb_sn IS NULL OR pcb_sn = '') AS hashboards_missing_pcb_sn
|
|
FROM miner_hashboards;
|
|
"""
|
|
)
|
|
hashboards = dict(cur.fetchone())
|
|
|
|
return api_response(
|
|
data={
|
|
"totals": totals,
|
|
"by_room": by_room,
|
|
"by_model": by_model,
|
|
"by_firmware": by_firmware,
|
|
"by_status": by_status,
|
|
"telemetry": telemetry,
|
|
"hashboards": hashboards,
|
|
}
|
|
)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
from fastapi import File, Query, UploadFile
|
|
import csv
|
|
import io
|
|
|
|
|
|
def csv_value(row: dict, possible_names: list[str]) -> str | None:
|
|
for name in possible_names:
|
|
if name in row and row[name] is not None:
|
|
value = str(row[name]).strip()
|
|
if value:
|
|
return value
|
|
return None
|
|
|
|
|
|
def normalize_mac_for_match(mac: str | None) -> str | None:
|
|
if not mac:
|
|
return None
|
|
|
|
cleaned = "".join(ch for ch in mac.lower() if ch in "0123456789abcdef")
|
|
|
|
if len(cleaned) != 12:
|
|
return None
|
|
|
|
return cleaned
|
|
|
|
|
|
def model_from_miner_type(miner_type: str | None) -> str | None:
|
|
if not miner_type:
|
|
return None
|
|
|
|
if "_" in miner_type:
|
|
return miner_type.split("_", 1)[0]
|
|
|
|
return miner_type
|
|
|
|
|
|
@app.post("/api/imports/whatsminer-tool-scan")
|
|
async def import_whatsminer_tool_scan(
|
|
dry_run: bool = Query(default=True),
|
|
update_existing: bool = Query(default=False),
|
|
file: UploadFile = File(...),
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
file_bytes = await file.read()
|
|
|
|
try:
|
|
csv_text = file_bytes.decode("utf-8-sig")
|
|
except UnicodeDecodeError:
|
|
csv_text = file_bytes.decode("latin-1")
|
|
|
|
reader = csv.DictReader(io.StringIO(csv_text))
|
|
rows = list(reader)
|
|
|
|
if not rows:
|
|
raise HTTPException(status_code=422, detail="CSV file has no rows")
|
|
|
|
matched_count = 0
|
|
unmatched_count = 0
|
|
rows_with_updates = 0
|
|
updates_applied = 0
|
|
skipped_no_useful_data = 0
|
|
|
|
update_samples = []
|
|
unmatched_samples = []
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
conn.autocommit = False
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
for row_number, row in enumerate(rows, start=2):
|
|
ip_address = csv_value(row, ["IP", "IP Address", "ip", "ip_address"])
|
|
mac_address = csv_value(row, ["MAC Addr", "MAC", "Mac", "mac", "mac_address"])
|
|
miner_type = csv_value(row, ["Miner Type", "MinerType", "Model", "model", "miner_type"])
|
|
firmware_version = csv_value(row, ["Firmware", "Firmware Version", "FW Version", "fw_ver", "firmware_version"])
|
|
|
|
if ip_address:
|
|
ip_address = clean_ip_text(ip_address)
|
|
|
|
normalized_mac = normalize_mac_for_match(mac_address)
|
|
model = model_from_miner_type(miner_type)
|
|
|
|
if not ip_address and not normalized_mac:
|
|
skipped_no_useful_data += 1
|
|
continue
|
|
|
|
miner = None
|
|
|
|
if normalized_mac:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
host(current_ip) AS current_ip,
|
|
current_mac,
|
|
model,
|
|
miner_type,
|
|
firmware_version
|
|
FROM miners
|
|
WHERE regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g') = %s
|
|
LIMIT 1;
|
|
""",
|
|
(normalized_mac,),
|
|
)
|
|
miner = cur.fetchone()
|
|
|
|
if not miner and ip_address:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
host(current_ip) AS current_ip,
|
|
current_mac,
|
|
model,
|
|
miner_type,
|
|
firmware_version
|
|
FROM miners
|
|
WHERE host(current_ip) = %s
|
|
LIMIT 1;
|
|
""",
|
|
(ip_address,),
|
|
)
|
|
miner = cur.fetchone()
|
|
|
|
if not miner:
|
|
unmatched_count += 1
|
|
|
|
if len(unmatched_samples) < 20:
|
|
unmatched_samples.append(
|
|
{
|
|
"row_number": row_number,
|
|
"ip_address": ip_address,
|
|
"mac_address": mac_address,
|
|
"miner_type": miner_type,
|
|
"firmware_version": firmware_version,
|
|
}
|
|
)
|
|
|
|
continue
|
|
|
|
matched_count += 1
|
|
|
|
set_clauses = []
|
|
params = []
|
|
proposed_changes = {}
|
|
|
|
if miner_type and (update_existing or not miner["miner_type"]):
|
|
set_clauses.append("miner_type = %s")
|
|
params.append(miner_type)
|
|
proposed_changes["miner_type"] = {
|
|
"old": miner["miner_type"],
|
|
"new": miner_type,
|
|
}
|
|
|
|
if model and (update_existing or not miner["model"]):
|
|
set_clauses.append("model = %s")
|
|
params.append(model)
|
|
proposed_changes["model"] = {
|
|
"old": miner["model"],
|
|
"new": model,
|
|
}
|
|
|
|
if firmware_version and (update_existing or not miner["firmware_version"]):
|
|
set_clauses.append("firmware_version = %s")
|
|
params.append(firmware_version)
|
|
proposed_changes["firmware_version"] = {
|
|
"old": miner["firmware_version"],
|
|
"new": firmware_version,
|
|
}
|
|
|
|
if not set_clauses:
|
|
continue
|
|
|
|
rows_with_updates += 1
|
|
|
|
if len(update_samples) < 50:
|
|
update_samples.append(
|
|
{
|
|
"row_number": row_number,
|
|
"miner_id": miner["id"],
|
|
"ip_address": miner["current_ip"],
|
|
"current_mac": miner["current_mac"],
|
|
"changes": proposed_changes,
|
|
}
|
|
)
|
|
|
|
if not dry_run:
|
|
set_sql = ", ".join(set_clauses)
|
|
params.append(miner["id"])
|
|
|
|
cur.execute(
|
|
f"""
|
|
UPDATE miners
|
|
SET {set_sql},
|
|
updated_at = now()
|
|
WHERE id = %s;
|
|
""",
|
|
tuple(params),
|
|
)
|
|
|
|
updates_applied += 1
|
|
|
|
if not dry_run:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
|
VALUES (%s, 'import.whatsminertool_csv.applied', 'import', NULL, %s);
|
|
""",
|
|
(
|
|
current_user["id"],
|
|
psycopg.types.json.Jsonb(
|
|
{
|
|
"filename": file.filename,
|
|
"rows_read": len(rows),
|
|
"matched_count": matched_count,
|
|
"unmatched_count": unmatched_count,
|
|
"rows_with_updates": rows_with_updates,
|
|
"updates_applied": updates_applied,
|
|
"update_existing": update_existing,
|
|
}
|
|
),
|
|
),
|
|
)
|
|
|
|
if dry_run:
|
|
conn.rollback()
|
|
else:
|
|
conn.commit()
|
|
|
|
return api_response(
|
|
data={
|
|
"filename": file.filename,
|
|
"dry_run": dry_run,
|
|
"update_existing": update_existing,
|
|
"rows_read": len(rows),
|
|
"matched_count": matched_count,
|
|
"unmatched_count": unmatched_count,
|
|
"skipped_no_useful_data": skipped_no_useful_data,
|
|
"rows_with_updates": rows_with_updates,
|
|
"updates_applied": updates_applied,
|
|
"update_samples": update_samples,
|
|
"unmatched_samples": unmatched_samples,
|
|
}
|
|
)
|
|
|
|
except HTTPException:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
|
|
@app.get("/tools/whatsminer-import", response_class=HTMLResponse)
|
|
def whatsminer_import_tool_page():
|
|
return """
|
|
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>MinerOps - WhatsMinerTool CSV Import</title>
|
|
<style>
|
|
body {
|
|
font-family: Arial, sans-serif;
|
|
margin: 30px;
|
|
background: #f4f6f8;
|
|
color: #1f2933;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1100px;
|
|
margin: auto;
|
|
}
|
|
|
|
.card {
|
|
background: white;
|
|
border: 1px solid #d9e2ec;
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
margin-bottom: 18px;
|
|
box-shadow: 0 2px 5px rgba(0,0,0,0.04);
|
|
}
|
|
|
|
h1, h2 {
|
|
margin-top: 0;
|
|
}
|
|
|
|
label {
|
|
display: block;
|
|
margin-top: 12px;
|
|
font-weight: bold;
|
|
}
|
|
|
|
input[type="text"],
|
|
input[type="password"],
|
|
input[type="file"] {
|
|
width: 100%;
|
|
max-width: 420px;
|
|
padding: 10px;
|
|
margin-top: 5px;
|
|
border: 1px solid #bcccdc;
|
|
border-radius: 5px;
|
|
}
|
|
|
|
button {
|
|
margin-top: 14px;
|
|
padding: 10px 16px;
|
|
border: 0;
|
|
border-radius: 5px;
|
|
background: #2563eb;
|
|
color: white;
|
|
cursor: pointer;
|
|
font-weight: bold;
|
|
}
|
|
|
|
button.secondary {
|
|
background: #475569;
|
|
}
|
|
|
|
button.danger {
|
|
background: #b91c1c;
|
|
}
|
|
|
|
button:disabled {
|
|
background: #94a3b8;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
pre {
|
|
background: #111827;
|
|
color: #f9fafb;
|
|
padding: 15px;
|
|
border-radius: 6px;
|
|
overflow-x: auto;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.status {
|
|
padding: 10px;
|
|
border-radius: 5px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.ok {
|
|
background: #dcfce7;
|
|
border: 1px solid #86efac;
|
|
}
|
|
|
|
.warn {
|
|
background: #fef9c3;
|
|
border: 1px solid #fde047;
|
|
}
|
|
|
|
.bad {
|
|
background: #fee2e2;
|
|
border: 1px solid #fca5a5;
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.metric {
|
|
background: #f8fafc;
|
|
border: 1px solid #e2e8f0;
|
|
padding: 12px;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.metric .label {
|
|
font-size: 12px;
|
|
color: #64748b;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.metric .value {
|
|
font-size: 24px;
|
|
font-weight: bold;
|
|
margin-top: 5px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card" style="border-left:4px solid #0b63ce;margin:12px 0;padding:12px;"><strong>New miner WebAPI reminder:</strong> Before importing or adding new WhatsMiners, enable WebAPI/API3 in WhatsMinerTool. Reboot, Suspend, Resume, and password changes need WebAPI enabled.</div>
|
|
<div class="container">
|
|
<h1>MinerOps - WhatsMinerTool CSV Import</h1>
|
|
|
|
<div class="card">
|
|
<h2>1. Login</h2>
|
|
<label>Username</label>
|
|
<input id="username" type="text" value="admin">
|
|
|
|
<label>Password</label>
|
|
<input id="password" type="password">
|
|
|
|
<button onclick="login()">Login</button>
|
|
|
|
<div id="loginStatus" class="status warn">Not logged in.</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>2. Upload CSV</h2>
|
|
|
|
<label>WhatsMinerTool CSV</label>
|
|
<input id="csvFile" type="file" accept=".csv,text/csv">
|
|
|
|
<label>
|
|
<input id="updateExisting" type="checkbox">
|
|
Allow CSV to overwrite existing model/firmware fields
|
|
</label>
|
|
|
|
<button onclick="previewImport()">Preview Import</button>
|
|
<button class="danger" onclick="applyImport()" id="applyButton" disabled>Apply Updates</button>
|
|
|
|
<div id="importStatus" class="status warn">Choose a CSV and preview first.</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>3. Preview Summary</h2>
|
|
|
|
<div class="grid">
|
|
<div class="metric">
|
|
<div class="label">Rows Read</div>
|
|
<div class="value" id="rowsRead">-</div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="label">Matched</div>
|
|
<div class="value" id="matchedCount">-</div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="label">Unmatched</div>
|
|
<div class="value" id="unmatchedCount">-</div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="label">Rows With Updates</div>
|
|
<div class="value" id="rowsWithUpdates">-</div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="label">Updates Applied</div>
|
|
<div class="value" id="updatesApplied">-</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Raw Result</h2>
|
|
<pre id="rawResult">No result yet.</pre>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let token = localStorage.getItem("minerops_token") || "";
|
|
|
|
function setLoginStatus(message, className) {
|
|
const element = document.getElementById("loginStatus");
|
|
element.textContent = message;
|
|
element.className = "status " + className;
|
|
}
|
|
|
|
function setImportStatus(message, className) {
|
|
const element = document.getElementById("importStatus");
|
|
element.textContent = message;
|
|
element.className = "status " + className;
|
|
}
|
|
|
|
function updateSummary(data) {
|
|
document.getElementById("rowsRead").textContent = data.rows_read ?? "-";
|
|
document.getElementById("matchedCount").textContent = data.matched_count ?? "-";
|
|
document.getElementById("unmatchedCount").textContent = data.unmatched_count ?? "-";
|
|
document.getElementById("rowsWithUpdates").textContent = data.rows_with_updates ?? "-";
|
|
document.getElementById("updatesApplied").textContent = data.updates_applied ?? "-";
|
|
}
|
|
|
|
function showRaw(value) {
|
|
document.getElementById("rawResult").textContent = JSON.stringify(value, null, 2);
|
|
}
|
|
|
|
async function apiPost(path) {
|
|
if (!token) {
|
|
throw new Error("Missing token. Please log in.");
|
|
}
|
|
|
|
const response = await fetch(path, {
|
|
method: "POST",
|
|
headers: {
|
|
"Authorization": "Bearer " + token
|
|
}
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(result.detail || "API error");
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async function refreshScannerStatusOnly() {
|
|
try {
|
|
scannerStatus = await apiGet("/api/scanner/status");
|
|
scannerPollCount += 1;
|
|
renderScannerStatus();
|
|
|
|
if (scannerStatus.running) {
|
|
setRaw("Scanner is running... Success: " + scannerStatus.last_success_count + " | Failed: " + scannerStatus.last_failure_count);
|
|
scannerPollTimer = setTimeout(refreshScannerStatusOnly, 5000);
|
|
} else {
|
|
setRaw("Scanner is idle. Last scan success: " + scannerStatus.last_success_count + " | Failed: " + scannerStatus.last_failure_count);
|
|
|
|
if (scannerPollTimer) {
|
|
clearTimeout(scannerPollTimer);
|
|
scannerPollTimer = null;
|
|
}
|
|
|
|
await refreshAll();
|
|
}
|
|
} catch (error) {
|
|
setRaw("Scanner refresh error: " + error.message);
|
|
}
|
|
}
|
|
|
|
async function runKnownMinerScanNow() {
|
|
try {
|
|
setRaw("Starting known-miner scan...");
|
|
|
|
const result = await apiPost("/api/scanner/run-known-now");
|
|
|
|
scannerPollCount = 0;
|
|
scannerStatus = result.data;
|
|
renderScannerStatus();
|
|
|
|
if (scannerPollTimer) {
|
|
clearTimeout(scannerPollTimer);
|
|
scannerPollTimer = null;
|
|
}
|
|
|
|
scannerPollTimer = setTimeout(refreshScannerStatusOnly, 5000);
|
|
|
|
setRaw(result.meta?.message || "Known-miner scan requested.");
|
|
} catch (error) {
|
|
setRaw("Scanner error: " + error.message);
|
|
}
|
|
}
|
|
|
|
async function login() {
|
|
const username = document.getElementById("username").value;
|
|
const password = document.getElementById("password").value;
|
|
|
|
const response = await fetch("/api/auth/login", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
username: username,
|
|
password: password
|
|
})
|
|
});
|
|
|
|
const result = await response.json();
|
|
showRaw(result);
|
|
|
|
if (!response.ok) {
|
|
setLoginStatus("Login failed: " + (result.detail || "Unknown error"), "bad");
|
|
return;
|
|
}
|
|
|
|
token = result.data.access_token;
|
|
localStorage.setItem("minerops_token", token);
|
|
|
|
setLoginStatus("Logged in. Token expires in " + result.data.expires_in + " seconds.", "ok");
|
|
}
|
|
|
|
async function runImport(dryRun) {
|
|
if (!token) {
|
|
setImportStatus("Please log in first.", "bad");
|
|
return;
|
|
}
|
|
|
|
const fileInput = document.getElementById("csvFile");
|
|
const file = fileInput.files[0];
|
|
|
|
if (!file) {
|
|
setImportStatus("Please choose a CSV file first.", "bad");
|
|
return;
|
|
}
|
|
|
|
const updateExisting = document.getElementById("updateExisting").checked;
|
|
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
|
|
const url = "/api/imports/whatsminer-tool-scan?dry_run=" + dryRun + "&update_existing=" + updateExisting;
|
|
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
"Authorization": "Bearer " + token
|
|
},
|
|
body: formData
|
|
});
|
|
|
|
const result = await response.json();
|
|
showRaw(result);
|
|
|
|
if (!response.ok) {
|
|
setImportStatus("Import failed: " + (result.detail || "Unknown error"), "bad");
|
|
return;
|
|
}
|
|
|
|
updateSummary(result.data);
|
|
|
|
if (dryRun) {
|
|
if (result.data.rows_with_updates > 0) {
|
|
setImportStatus("Preview complete. Updates are available. Review before applying.", "warn");
|
|
document.getElementById("applyButton").disabled = false;
|
|
} else {
|
|
setImportStatus("Preview complete. No updates needed.", "ok");
|
|
document.getElementById("applyButton").disabled = true;
|
|
}
|
|
} else {
|
|
setImportStatus("Import applied. Updates applied: " + result.data.updates_applied, "ok");
|
|
document.getElementById("applyButton").disabled = true;
|
|
}
|
|
}
|
|
|
|
function previewImport() {
|
|
runImport(true);
|
|
}
|
|
|
|
function applyImport() {
|
|
const ok = confirm("Apply CSV updates to MinerOps inventory?");
|
|
if (!ok) {
|
|
return;
|
|
}
|
|
|
|
runImport(false);
|
|
}
|
|
|
|
if (token) {
|
|
setLoginStatus("Saved token found in this browser.", "ok");
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
@app.get("/api/dashboard/miner-table")
|
|
def dashboard_miner_table(current_user: dict = Depends(get_current_user)):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
WITH latest AS (
|
|
SELECT DISTINCT ON (miner_id)
|
|
miner_id,
|
|
observed_at,
|
|
observed_ip,
|
|
mhs_av,
|
|
power_w,
|
|
power_rate_w_per_th,
|
|
chip_temp_max_c,
|
|
chip_temp_avg_c,
|
|
fan_speed_in_rpm,
|
|
fan_speed_out_rpm,
|
|
hash_stable,
|
|
pool_rejected_percent
|
|
FROM miner_status_snapshots
|
|
ORDER BY miner_id, observed_at DESC
|
|
)
|
|
SELECT
|
|
miners.id::text,
|
|
COALESCE(rooms.name, 'Unassigned') AS room_name,
|
|
host(miners.current_ip) AS current_ip,
|
|
miners.current_mac,
|
|
miners.hostname,
|
|
miners.miner_sn,
|
|
miners.power_sn,
|
|
miners.model,
|
|
miners.miner_type,
|
|
miners.firmware_version,
|
|
miners.status,
|
|
miners.last_seen_at,
|
|
latest.observed_at,
|
|
ROUND((latest.mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths,
|
|
ROUND(latest.power_w::numeric, 2)::float AS power_w,
|
|
ROUND(latest.power_rate_w_per_th::numeric, 2)::float AS power_rate_w_per_th,
|
|
ROUND(latest.chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c,
|
|
ROUND(latest.chip_temp_avg_c::numeric, 2)::float AS chip_temp_avg_c,
|
|
latest.fan_speed_in_rpm,
|
|
latest.fan_speed_out_rpm,
|
|
latest.hash_stable,
|
|
ROUND(latest.pool_rejected_percent::numeric, 4)::float AS pool_rejected_percent
|
|
FROM miners
|
|
LEFT JOIN rooms ON rooms.id = miners.room_id
|
|
LEFT JOIN latest ON latest.miner_id = miners.id
|
|
ORDER BY rooms.sort_order, miners.current_ip;
|
|
"""
|
|
)
|
|
rows = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=rows)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/dashboard/hot-miners")
|
|
def dashboard_hot_miners(
|
|
min_temp_c: float = 90,
|
|
limit: int = 100,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
WITH latest AS (
|
|
SELECT DISTINCT ON (miner_id)
|
|
miner_id,
|
|
observed_at,
|
|
chip_temp_max_c,
|
|
chip_temp_avg_c,
|
|
power_w,
|
|
mhs_av,
|
|
hash_stable
|
|
FROM miner_status_snapshots
|
|
ORDER BY miner_id, observed_at DESC
|
|
)
|
|
SELECT
|
|
miners.id::text,
|
|
COALESCE(rooms.name, 'Unassigned') AS room_name,
|
|
host(miners.current_ip) AS current_ip,
|
|
miners.current_mac,
|
|
miners.model,
|
|
miners.miner_type,
|
|
miners.firmware_version,
|
|
miners.status,
|
|
latest.observed_at,
|
|
ROUND(latest.chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c,
|
|
ROUND(latest.chip_temp_avg_c::numeric, 2)::float AS chip_temp_avg_c,
|
|
ROUND(latest.power_w::numeric, 2)::float AS power_w,
|
|
ROUND((latest.mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths,
|
|
latest.hash_stable
|
|
FROM miners
|
|
LEFT JOIN rooms ON rooms.id = miners.room_id
|
|
JOIN latest ON latest.miner_id = miners.id
|
|
WHERE latest.chip_temp_max_c >= %s
|
|
ORDER BY latest.chip_temp_max_c DESC
|
|
LIMIT %s;
|
|
""",
|
|
(min_temp_c, limit),
|
|
)
|
|
rows = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=rows)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/dashboard/unstable-miners")
|
|
def dashboard_unstable_miners(
|
|
limit: int = 100,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
WITH latest AS (
|
|
SELECT DISTINCT ON (miner_id)
|
|
miner_id,
|
|
observed_at,
|
|
chip_temp_max_c,
|
|
power_w,
|
|
mhs_av,
|
|
hash_stable,
|
|
pool_rejected_percent
|
|
FROM miner_status_snapshots
|
|
ORDER BY miner_id, observed_at DESC
|
|
)
|
|
SELECT
|
|
miners.id::text,
|
|
COALESCE(rooms.name, 'Unassigned') AS room_name,
|
|
host(miners.current_ip) AS current_ip,
|
|
miners.current_mac,
|
|
miners.model,
|
|
miners.miner_type,
|
|
miners.firmware_version,
|
|
miners.status,
|
|
latest.observed_at,
|
|
ROUND((latest.mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths,
|
|
ROUND(latest.power_w::numeric, 2)::float AS power_w,
|
|
ROUND(latest.chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c,
|
|
latest.hash_stable,
|
|
ROUND(latest.pool_rejected_percent::numeric, 4)::float AS pool_rejected_percent
|
|
FROM miners
|
|
LEFT JOIN rooms ON rooms.id = miners.room_id
|
|
JOIN latest ON latest.miner_id = miners.id
|
|
WHERE latest.hash_stable = false
|
|
OR latest.pool_rejected_percent >= 1
|
|
ORDER BY latest.hash_stable ASC, latest.pool_rejected_percent DESC NULLS LAST, latest.chip_temp_max_c DESC NULLS LAST
|
|
LIMIT %s;
|
|
""",
|
|
(limit,),
|
|
)
|
|
rows = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=rows)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/dashboard/stale-miners")
|
|
def dashboard_stale_miners(
|
|
minutes: int = 30,
|
|
limit: int = 200,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
miners.id::text,
|
|
COALESCE(rooms.name, 'Unassigned') AS room_name,
|
|
host(miners.current_ip) AS current_ip,
|
|
miners.current_mac,
|
|
miners.model,
|
|
miners.miner_type,
|
|
miners.firmware_version,
|
|
miners.status,
|
|
miners.last_seen_at,
|
|
ROUND(EXTRACT(EPOCH FROM (now() - miners.last_seen_at)) / 60.0, 1)::float AS minutes_since_seen
|
|
FROM miners
|
|
LEFT JOIN rooms ON rooms.id = miners.room_id
|
|
WHERE miners.last_seen_at < now() - make_interval(mins => %s)
|
|
ORDER BY miners.last_seen_at ASC
|
|
LIMIT %s;
|
|
""",
|
|
(minutes, limit),
|
|
)
|
|
rows = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=rows)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
MINEROPS_UNIFIED_CSS = r"""
|
|
:root {
|
|
--minerops-bg: #f4f7fb;
|
|
--minerops-card: #ffffff;
|
|
--minerops-border: #d9e2ec;
|
|
--minerops-text: #102a43;
|
|
--minerops-muted: #52606d;
|
|
--minerops-blue: #0b63ce;
|
|
--minerops-blue-dark: #084b9a;
|
|
--minerops-green: #0f766e;
|
|
--minerops-red: #dc2626;
|
|
}
|
|
|
|
body {
|
|
background: var(--minerops-bg) !important;
|
|
color: var(--minerops-text) !important;
|
|
font-family: "Segoe UI", Arial, sans-serif !important;
|
|
margin: 0 !important;
|
|
padding: 20px !important;
|
|
}
|
|
|
|
/* Hide old page headers/navs that made the pages look duplicated */
|
|
body > header:not(.minerops-shell-header) {
|
|
display: none !important;
|
|
}
|
|
|
|
body > h1 {
|
|
display: none !important;
|
|
}
|
|
|
|
body > .topbar {
|
|
display: none !important;
|
|
}
|
|
|
|
.app-header {
|
|
display: none !important;
|
|
}
|
|
|
|
#dashboardLocationMapLink {
|
|
display: none !important;
|
|
}
|
|
|
|
.minerops-shell-header {
|
|
background: linear-gradient(135deg, #0b63ce, #0f766e);
|
|
color: white;
|
|
border-radius: 16px;
|
|
padding: 18px 20px;
|
|
margin-bottom: 16px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 16px;
|
|
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.18);
|
|
}
|
|
|
|
.minerops-kicker {
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
opacity: 0.86;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.minerops-shell-header h1 {
|
|
display: block !important;
|
|
margin: 0;
|
|
color: white;
|
|
font-size: 28px;
|
|
line-height: 1.1;
|
|
}
|
|
|
|
.minerops-subtitle {
|
|
margin-top: 6px;
|
|
font-size: 14px;
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.minerops-nav {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
}
|
|
|
|
.minerops-nav-link {
|
|
display: inline-block;
|
|
border: 1px solid rgba(255, 255, 255, 0.45);
|
|
background: rgba(255, 255, 255, 0.14);
|
|
color: white !important;
|
|
border-radius: 999px;
|
|
padding: 8px 12px;
|
|
font-weight: 800;
|
|
font-size: 13px;
|
|
text-decoration: none !important;
|
|
}
|
|
|
|
.minerops-nav-link:hover {
|
|
background: rgba(255, 255, 255, 0.24);
|
|
}
|
|
|
|
.minerops-nav-link.active {
|
|
background: white;
|
|
color: var(--minerops-blue) !important;
|
|
}
|
|
|
|
.card {
|
|
background: var(--minerops-card) !important;
|
|
border: 1px solid var(--minerops-border) !important;
|
|
border-radius: 14px !important;
|
|
padding: 16px !important;
|
|
margin-bottom: 16px !important;
|
|
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06) !important;
|
|
}
|
|
|
|
.card h2 {
|
|
color: var(--minerops-text) !important;
|
|
margin-top: 0 !important;
|
|
}
|
|
|
|
.small,
|
|
.muted {
|
|
color: var(--minerops-muted) !important;
|
|
}
|
|
|
|
button,
|
|
input[type="button"],
|
|
input[type="submit"],
|
|
.issue-action-button {
|
|
background: var(--minerops-blue) !important;
|
|
color: white !important;
|
|
border: 0 !important;
|
|
border-radius: 8px !important;
|
|
padding: 8px 11px !important;
|
|
font-weight: 800 !important;
|
|
cursor: pointer !important;
|
|
text-decoration: none !important;
|
|
}
|
|
|
|
button:hover,
|
|
input[type="button"]:hover,
|
|
input[type="submit"]:hover,
|
|
.issue-action-button:hover {
|
|
background: var(--minerops-blue-dark) !important;
|
|
}
|
|
|
|
button.secondary {
|
|
background: #64748b !important;
|
|
}
|
|
|
|
button.danger,
|
|
.danger {
|
|
background: var(--minerops-red) !important;
|
|
color: white !important;
|
|
}
|
|
|
|
input,
|
|
select,
|
|
textarea {
|
|
border: 1px solid #bcccdc !important;
|
|
border-radius: 8px !important;
|
|
padding: 8px !important;
|
|
background: white !important;
|
|
color: var(--minerops-text) !important;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
background: white;
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
th {
|
|
background: #eef2ff !important;
|
|
color: #1e293b !important;
|
|
text-align: left;
|
|
font-size: 13px;
|
|
}
|
|
|
|
th,
|
|
td {
|
|
border-bottom: 1px solid #e2e8f0 !important;
|
|
padding: 8px !important;
|
|
vertical-align: top;
|
|
}
|
|
|
|
tr:hover td {
|
|
background: #f8fafc !important;
|
|
}
|
|
|
|
.status {
|
|
border-radius: 10px !important;
|
|
padding: 10px 12px !important;
|
|
margin-top: 10px !important;
|
|
border: 1px solid var(--minerops-border) !important;
|
|
background: #f8fafc !important;
|
|
}
|
|
|
|
.status.ok {
|
|
background: #ecfdf5 !important;
|
|
border-color: #6ee7b7 !important;
|
|
}
|
|
|
|
.status.warn {
|
|
background: #fff7ed !important;
|
|
border-color: #fdba74 !important;
|
|
}
|
|
|
|
.status.bad {
|
|
background: #fef2f2 !important;
|
|
border-color: #fca5a5 !important;
|
|
}
|
|
|
|
.sticky-mini-toolbar,
|
|
.card-toolbar {
|
|
background: rgba(255, 255, 255, 0.96) !important;
|
|
border: 1px solid var(--minerops-border) !important;
|
|
border-radius: 14px !important;
|
|
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.10) !important;
|
|
padding: 10px !important;
|
|
}
|
|
|
|
.sticky-mini-toolbar {
|
|
top: 8px !important;
|
|
}
|
|
|
|
/* Step 80: Header auth controls */
|
|
.minerops-auth {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 6px;
|
|
margin-left: 8px;
|
|
}
|
|
|
|
.minerops-auth input {
|
|
width: 115px !important;
|
|
min-width: 90px !important;
|
|
padding: 7px 8px !important;
|
|
border-radius: 999px !important;
|
|
border: 1px solid rgba(255, 255, 255, 0.55) !important;
|
|
background: rgba(255, 255, 255, 0.92) !important;
|
|
font-size: 12px !important;
|
|
}
|
|
|
|
.minerops-auth button {
|
|
border-radius: 999px !important;
|
|
padding: 7px 10px !important;
|
|
font-size: 12px !important;
|
|
background: rgba(255, 255, 255, 0.18) !important;
|
|
border: 1px solid rgba(255, 255, 255, 0.45) !important;
|
|
color: white !important;
|
|
}
|
|
|
|
.minerops-auth button:hover {
|
|
background: rgba(255, 255, 255, 0.28) !important;
|
|
}
|
|
|
|
.minerops-auth-user {
|
|
color: white;
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
padding: 7px 10px;
|
|
border-radius: 999px;
|
|
background: rgba(255, 255, 255, 0.14);
|
|
border: 1px solid rgba(255, 255, 255, 0.30);
|
|
}
|
|
|
|
.minerops-auth-message {
|
|
color: white;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
opacity: 0.92;
|
|
}
|
|
|
|
.minerops-hidden-login-card {
|
|
display: none !important;
|
|
}
|
|
|
|
@media (max-width: 950px) {
|
|
.minerops-auth {
|
|
width: 100%;
|
|
margin-left: 0;
|
|
}
|
|
|
|
.minerops-auth input {
|
|
width: 140px !important;
|
|
}
|
|
}
|
|
|
|
/* Step 83: Global miner search */
|
|
.minerops-global-search {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
margin-left: 8px;
|
|
}
|
|
|
|
.minerops-global-search input {
|
|
width: 230px !important;
|
|
min-width: 180px !important;
|
|
border-radius: 999px !important;
|
|
padding: 8px 12px !important;
|
|
border: 1px solid rgba(255, 255, 255, 0.55) !important;
|
|
background: rgba(255, 255, 255, 0.95) !important;
|
|
font-size: 13px !important;
|
|
}
|
|
|
|
.minerops-search-results {
|
|
position: absolute;
|
|
top: 40px;
|
|
right: 0;
|
|
z-index: 5000;
|
|
width: min(680px, 92vw);
|
|
max-height: 440px;
|
|
overflow: auto;
|
|
background: white;
|
|
color: var(--minerops-text);
|
|
border: 1px solid var(--minerops-border);
|
|
border-radius: 14px;
|
|
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.28);
|
|
padding: 8px;
|
|
display: none;
|
|
}
|
|
|
|
.minerops-search-results.open {
|
|
display: block;
|
|
}
|
|
|
|
.minerops-search-result {
|
|
display: grid;
|
|
grid-template-columns: 150px 110px 1fr;
|
|
gap: 8px;
|
|
padding: 9px;
|
|
border-radius: 10px;
|
|
cursor: pointer;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.minerops-search-result:hover,
|
|
.minerops-search-result.active {
|
|
background: #eff6ff;
|
|
}
|
|
|
|
.minerops-search-result-main {
|
|
font-weight: 900;
|
|
color: #0f172a;
|
|
}
|
|
|
|
.minerops-search-result-sub {
|
|
font-size: 12px;
|
|
color: #475569;
|
|
}
|
|
|
|
.minerops-search-empty {
|
|
padding: 10px;
|
|
color: #475569;
|
|
font-size: 13px;
|
|
}
|
|
|
|
@media (max-width: 950px) {
|
|
.minerops-global-search {
|
|
width: 100%;
|
|
margin-left: 0;
|
|
}
|
|
|
|
.minerops-global-search input {
|
|
width: 100% !important;
|
|
}
|
|
|
|
.minerops-search-results {
|
|
left: 0;
|
|
right: auto;
|
|
width: 92vw;
|
|
}
|
|
}
|
|
|
|
/* Step 84: Global scan action */
|
|
.minerops-header-scan {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
margin-left: 4px;
|
|
}
|
|
|
|
.minerops-scan-button {
|
|
background: rgba(255, 255, 255, 0.18) !important;
|
|
border: 1px solid rgba(255, 255, 255, 0.45) !important;
|
|
color: white !important;
|
|
border-radius: 999px !important;
|
|
padding: 8px 12px !important;
|
|
font-size: 13px !important;
|
|
font-weight: 800 !important;
|
|
}
|
|
|
|
.minerops-scan-button:hover {
|
|
background: rgba(255, 255, 255, 0.28) !important;
|
|
}
|
|
|
|
.minerops-scan-button.running {
|
|
background: #f59e0b !important;
|
|
color: #111827 !important;
|
|
}
|
|
|
|
.minerops-scan-status {
|
|
color: white;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
opacity: 0.92;
|
|
max-width: 260px;
|
|
}
|
|
|
|
@media (max-width: 950px) {
|
|
.minerops-header-scan {
|
|
width: 100%;
|
|
margin-left: 0;
|
|
}
|
|
|
|
.minerops-scan-status {
|
|
max-width: 100%;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 800px) {
|
|
body {
|
|
padding: 12px !important;
|
|
}
|
|
|
|
.minerops-shell-header {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.minerops-nav {
|
|
justify-content: flex-start;
|
|
}
|
|
}
|
|
"""
|
|
|
|
@app.get("/assets/minerops-unified.css")
|
|
async def minerops_unified_css():
|
|
return HTMLResponse(content=MINEROPS_UNIFIED_CSS, media_type="text/css")
|
|
|
|
MINEROPS_SHELL_AUTH_JS = r"""
|
|
(function () {
|
|
const TOKEN_KEYS = [
|
|
"minerops_token",
|
|
"access_token",
|
|
"token",
|
|
"auth_token",
|
|
"authToken"
|
|
];
|
|
|
|
const USERNAME_KEY = "minerops_username";
|
|
|
|
function escapeHtml(value) {
|
|
return String(value || "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function looksLikeToken(value) {
|
|
if (!value || typeof value !== "string") {
|
|
return false;
|
|
}
|
|
|
|
if (value.split(".").length === 3 && value.length > 30) {
|
|
return true;
|
|
}
|
|
|
|
return value.length > 40 && !value.includes(" ");
|
|
}
|
|
|
|
function findTokenRecursive(value) {
|
|
if (!value) {
|
|
return "";
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
return looksLikeToken(value) ? value : "";
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
const found = findTokenRecursive(item);
|
|
|
|
if (found) {
|
|
return found;
|
|
}
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
if (typeof value === "object") {
|
|
const preferredKeys = [
|
|
"access_token",
|
|
"accessToken",
|
|
"token",
|
|
"jwt",
|
|
"auth_token",
|
|
"authToken"
|
|
];
|
|
|
|
for (const key of preferredKeys) {
|
|
if (looksLikeToken(value[key])) {
|
|
return value[key];
|
|
}
|
|
}
|
|
|
|
for (const key of Object.keys(value)) {
|
|
if (key.toLowerCase().includes("token")) {
|
|
const found = findTokenRecursive(value[key]);
|
|
|
|
if (found) {
|
|
return found;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const key of Object.keys(value)) {
|
|
const found = findTokenRecursive(value[key]);
|
|
|
|
if (found) {
|
|
return found;
|
|
}
|
|
}
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function getToken() {
|
|
for (const key of TOKEN_KEYS) {
|
|
const value = localStorage.getItem(key);
|
|
|
|
if (value) {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function setToken(token) {
|
|
for (const key of TOKEN_KEYS) {
|
|
localStorage.setItem(key, token);
|
|
}
|
|
}
|
|
|
|
function clearToken() {
|
|
for (const key of TOKEN_KEYS) {
|
|
localStorage.removeItem(key);
|
|
}
|
|
|
|
localStorage.removeItem(USERNAME_KEY);
|
|
}
|
|
|
|
function authHeaders() {
|
|
const token = getToken();
|
|
|
|
if (!token) {
|
|
return {};
|
|
}
|
|
|
|
return {
|
|
"Authorization": "Bearer " + token
|
|
};
|
|
}
|
|
|
|
function findLegacyLoginContainer(heading) {
|
|
let node = heading.parentElement;
|
|
let best = null;
|
|
|
|
for (let i = 0; i < 7 && node && node !== document.body; i++) {
|
|
const hasPassword = node.querySelector && node.querySelector('input[type="password"]');
|
|
const hasClearToken = node.textContent && node.textContent.toLowerCase().includes("clear token");
|
|
const isCard = node.classList && (node.classList.contains("card") || node.classList.contains("panel"));
|
|
|
|
if (hasPassword || hasClearToken) {
|
|
best = node;
|
|
}
|
|
|
|
if (isCard) {
|
|
best = node;
|
|
break;
|
|
}
|
|
|
|
node = node.parentElement;
|
|
}
|
|
|
|
return best || heading.parentElement;
|
|
}
|
|
|
|
function hideLegacyLoginCards() {
|
|
document.querySelectorAll("h1, h2, h3").forEach(heading => {
|
|
if (heading.closest(".minerops-shell-header")) {
|
|
return;
|
|
}
|
|
|
|
const title = heading.textContent.trim().toLowerCase();
|
|
|
|
if (
|
|
title === "login" ||
|
|
title === "login / refresh" ||
|
|
title === "login/refresh"
|
|
) {
|
|
const container = findLegacyLoginContainer(heading);
|
|
|
|
if (container && !container.closest(".minerops-shell-header")) {
|
|
container.classList.add("minerops-hidden-login-card");
|
|
container.style.display = "none";
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function setLegacyLoginInputs(username, password) {
|
|
const usernameInputs = Array.from(document.querySelectorAll('input[type="text"], input:not([type])'))
|
|
.filter(input => !input.closest(".minerops-shell-header"));
|
|
|
|
const passwordInputs = Array.from(document.querySelectorAll('input[type="password"]'))
|
|
.filter(input => !input.closest(".minerops-shell-header"));
|
|
|
|
for (const input of usernameInputs) {
|
|
const idName = ((input.id || "") + " " + (input.name || "") + " " + (input.placeholder || "")).toLowerCase();
|
|
|
|
if (idName.includes("user") || idName.includes("admin") || idName.includes("login")) {
|
|
input.value = username;
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
}
|
|
}
|
|
|
|
for (const input of passwordInputs) {
|
|
input.value = password;
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
}
|
|
}
|
|
|
|
async function tryLegacyPageLogin(username, password) {
|
|
setLegacyLoginInputs(username, password);
|
|
|
|
const functionNames = [
|
|
"login",
|
|
"doLogin",
|
|
"loginUser",
|
|
"dashboardLogin",
|
|
"locationLogin"
|
|
];
|
|
|
|
for (const functionName of functionNames) {
|
|
if (typeof window[functionName] === "function") {
|
|
try {
|
|
const result = window[functionName]();
|
|
await Promise.resolve(result);
|
|
await delay(800);
|
|
|
|
if (getToken()) {
|
|
return true;
|
|
}
|
|
} catch (error) {
|
|
// Keep trying other methods.
|
|
}
|
|
}
|
|
}
|
|
|
|
const buttons = Array.from(document.querySelectorAll("button"))
|
|
.filter(button => !button.closest(".minerops-shell-header"))
|
|
.filter(button => button.textContent.trim().toLowerCase() === "login");
|
|
|
|
for (const button of buttons) {
|
|
try {
|
|
button.click();
|
|
await delay(1000);
|
|
|
|
if (getToken()) {
|
|
return true;
|
|
}
|
|
} catch (error) {
|
|
// Keep trying.
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function parseLoginResponse(response) {
|
|
const text = await response.text();
|
|
let data = null;
|
|
|
|
try {
|
|
data = text ? JSON.parse(text) : {};
|
|
} catch (error) {
|
|
data = text;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const message = data && typeof data === "object"
|
|
? (data.detail || data.message || data.error || "Login failed.")
|
|
: (text || "Login failed.");
|
|
|
|
throw new Error(message);
|
|
}
|
|
|
|
return findTokenRecursive(data) || findTokenRecursive(text);
|
|
}
|
|
|
|
async function tryJsonLogin(username, password) {
|
|
const response = await fetch("/api/auth/login", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
username: username,
|
|
password: password
|
|
})
|
|
});
|
|
|
|
return await parseLoginResponse(response);
|
|
}
|
|
|
|
async function tryFormLogin(username, password) {
|
|
const form = new URLSearchParams();
|
|
form.set("username", username);
|
|
form.set("password", password);
|
|
|
|
const response = await fetch("/api/auth/login", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded"
|
|
},
|
|
body: form.toString()
|
|
});
|
|
|
|
return await parseLoginResponse(response);
|
|
}
|
|
|
|
async function getCurrentUser() {
|
|
const token = getToken();
|
|
|
|
if (!token) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch("/api/auth/me", {
|
|
headers: authHeaders()
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function userDisplayName(user) {
|
|
const stored = localStorage.getItem(USERNAME_KEY);
|
|
|
|
const candidates = [
|
|
stored,
|
|
user && user.username,
|
|
user && user.user_name,
|
|
user && user.name,
|
|
user && user.email
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
if (!candidate) {
|
|
continue;
|
|
}
|
|
|
|
const value = String(candidate).trim();
|
|
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
|
|
if (value.toLowerCase() === "signed in") {
|
|
continue;
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
async function loginFromHeader() {
|
|
const username = document.getElementById("mineropsHeaderUsername")?.value || "";
|
|
const password = document.getElementById("mineropsHeaderPassword")?.value || "";
|
|
const message = document.getElementById("mineropsHeaderAuthMessage");
|
|
|
|
if (!username || !password) {
|
|
if (message) {
|
|
message.textContent = "Enter username and password.";
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (message) {
|
|
message.textContent = "Signing in...";
|
|
}
|
|
|
|
let token = "";
|
|
let lastError = null;
|
|
|
|
try {
|
|
token = await tryJsonLogin(username, password);
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
|
|
if (!token) {
|
|
try {
|
|
token = await tryFormLogin(username, password);
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
}
|
|
|
|
if (token) {
|
|
setToken(token);
|
|
localStorage.setItem(USERNAME_KEY, username);
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
|
|
if (message) {
|
|
message.textContent = "Trying page login...";
|
|
}
|
|
|
|
const legacyWorked = await tryLegacyPageLogin(username, password);
|
|
|
|
if (legacyWorked || getToken()) {
|
|
localStorage.setItem(USERNAME_KEY, username);
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
|
|
if (message) {
|
|
message.textContent = lastError ? lastError.message : "Login failed. No token was returned.";
|
|
}
|
|
}
|
|
|
|
function logoutFromHeader() {
|
|
clearToken();
|
|
window.location.reload();
|
|
}
|
|
|
|
async function renderHeaderAuth() {
|
|
const nav = document.querySelector(".minerops-nav");
|
|
|
|
if (!nav) {
|
|
return;
|
|
}
|
|
|
|
let authArea = document.getElementById("mineropsHeaderAuth");
|
|
|
|
if (!authArea) {
|
|
authArea = document.createElement("div");
|
|
authArea.id = "mineropsHeaderAuth";
|
|
authArea.className = "minerops-auth";
|
|
nav.appendChild(authArea);
|
|
}
|
|
|
|
const user = await getCurrentUser();
|
|
|
|
if (user) {
|
|
const displayName = userDisplayName(user);
|
|
|
|
authArea.innerHTML = displayName
|
|
? `
|
|
<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>
|
|
`;
|
|
|
|
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"""
|
|
(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 setMinerControlStatus(message, className) {
|
|
const status = document.getElementById("minerControlStatus");
|
|
|
|
if (!status) {
|
|
return;
|
|
}
|
|
|
|
status.textContent = message;
|
|
status.className = "status " + (className || "warn");
|
|
}
|
|
|
|
async function fetchJson(url, options) {
|
|
const response = await fetch(url, options || {});
|
|
const text = await response.text();
|
|
|
|
let data = null;
|
|
|
|
try {
|
|
data = text ? JSON.parse(text) : {};
|
|
} catch (error) {
|
|
data = text;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
let message = text || "Request failed.";
|
|
|
|
if (data && typeof data === "object") {
|
|
if (typeof data.detail === "string") {
|
|
message = data.detail;
|
|
} else if (data.detail && data.detail.message) {
|
|
message = data.detail.message;
|
|
} else if (data.message) {
|
|
message = data.message;
|
|
} else {
|
|
message = JSON.stringify(data);
|
|
}
|
|
}
|
|
|
|
throw new Error(message);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
async function postRebootCommand(dryRun, confirmText) {
|
|
const minerId = minerIdFromPage();
|
|
|
|
if (!minerId) {
|
|
setMinerControlStatus("Could not detect miner ID from page.", "bad");
|
|
return;
|
|
}
|
|
|
|
const token = getToken();
|
|
|
|
if (!token) {
|
|
setMinerControlStatus("Log in first.", "bad");
|
|
return;
|
|
}
|
|
|
|
const label = dryRun ? "dry-run reboot" : "Reboot";
|
|
setMinerControlStatus("Sending " + label + " request...", "warn");
|
|
|
|
try {
|
|
const data = await fetchJson("/api/miners/" + encodeURIComponent(minerId) + "/commands/reboot", {
|
|
method: "POST",
|
|
headers: authHeaders(),
|
|
body: JSON.stringify({
|
|
dry_run: dryRun,
|
|
confirm_text: confirmText || ""
|
|
})
|
|
});
|
|
|
|
if (dryRun) {
|
|
setMinerControlStatus(
|
|
"Dry run logged. No reboot was sent. Target: " + (data.target_ip || "unknown IP") + " / " + (data.target_mac || "unknown MAC"),
|
|
"ok"
|
|
);
|
|
} else {
|
|
setMinerControlStatus(
|
|
"Reboot command sent to " + (data.target_ip || "unknown IP") + ". Watch miner status for reconnect.",
|
|
"ok"
|
|
);
|
|
}
|
|
|
|
await loadMinerCommandHistory();
|
|
} catch (error) {
|
|
setMinerControlStatus(label + " error: " + error.message, "bad");
|
|
await loadMinerCommandHistory();
|
|
}
|
|
}
|
|
|
|
async function dryRunMinerReboot() {
|
|
await postRebootCommand(true, "DRYRUN");
|
|
}
|
|
|
|
async function realRebootMiner() {
|
|
const confirmed = confirm(
|
|
"Reboot this miner now?\n\nThis will send the reboot command immediately."
|
|
);
|
|
|
|
if (!confirmed) {
|
|
setMinerControlStatus("Real reboot cancelled.", "warn");
|
|
return;
|
|
}
|
|
|
|
await postRebootCommand(false, "REBOOT");
|
|
}
|
|
|
|
async function postPowerCommand(endpointName, label) {
|
|
const minerId = minerIdFromPage();
|
|
|
|
if (!minerId) {
|
|
setMinerControlStatus("Could not detect miner ID from page.", "bad");
|
|
return;
|
|
}
|
|
|
|
const token = getToken();
|
|
|
|
if (!token) {
|
|
setMinerControlStatus("Log in first.", "bad");
|
|
return;
|
|
}
|
|
|
|
setMinerControlStatus("Sending " + label + " command...", "warn");
|
|
|
|
try {
|
|
const data = await fetchJson("/api/miners/" + encodeURIComponent(minerId) + "/commands/" + endpointName, {
|
|
method: "POST",
|
|
headers: authHeaders(),
|
|
body: JSON.stringify({
|
|
dry_run: false,
|
|
confirm_text: "CONFIRM"
|
|
})
|
|
});
|
|
|
|
const commandName = data.whatsminer_command || endpointName;
|
|
|
|
setMinerControlStatus(
|
|
label + " command sent to " + (data.target_ip || "unknown IP") + " using: " + commandName + ".",
|
|
"ok"
|
|
);
|
|
|
|
await loadMinerCommandHistory();
|
|
} catch (error) {
|
|
setMinerControlStatus(label + " error: " + error.message, "bad");
|
|
await loadMinerCommandHistory();
|
|
}
|
|
}
|
|
|
|
async function powerOffMiner() {
|
|
const confirmed = confirm(
|
|
"Suspend this miner now?\n\nThis will use the authenticated WhatsMiner API3 service stop command."
|
|
);
|
|
|
|
if (!confirmed) {
|
|
setMinerControlStatus("Suspend cancelled.", "warn");
|
|
return;
|
|
}
|
|
|
|
await postPowerCommand("suspend", "Suspend");
|
|
}
|
|
|
|
async function powerOnMiner() {
|
|
const confirmed = confirm(
|
|
"Resume this miner now?\n\nThis will use the authenticated WhatsMiner API3 service start command."
|
|
);
|
|
|
|
if (!confirmed) {
|
|
setMinerControlStatus("Resume cancelled.", "warn");
|
|
return;
|
|
}
|
|
|
|
await postPowerCommand("resume", "Resume");
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) {
|
|
return "-";
|
|
}
|
|
|
|
try {
|
|
return new Date(value).toLocaleString();
|
|
} catch (error) {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
async function loadMinerCommandHistory() {
|
|
const minerId = minerIdFromPage();
|
|
const body = document.getElementById("minerCommandHistoryBody");
|
|
|
|
if (!minerId || !body) {
|
|
return;
|
|
}
|
|
|
|
const token = getToken();
|
|
|
|
if (!token) {
|
|
body.innerHTML = '<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();
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", setupMinerControlCard);
|
|
} else {
|
|
setupMinerControlCard();
|
|
}
|
|
|
|
setTimeout(setupMinerControlCard, 600);
|
|
|
|
window.dryRunMinerReboot = dryRunMinerReboot;
|
|
window.realRebootMiner = realRebootMiner;
|
|
window.suspendMiner = powerOffMiner;
|
|
window.suspendMiner = powerOffMiner;
|
|
window.powerOffMiner = powerOffMiner;
|
|
window.resumeMiner = powerOnMiner;
|
|
window.resumeMiner = powerOnMiner;
|
|
window.powerOnMiner = powerOnMiner;
|
|
window.loadMinerCommandHistory = loadMinerCommandHistory;
|
|
})();
|
|
"""
|
|
|
|
@app.get("/assets/minerops-miner-control.js")
|
|
async def minerops_miner_control_js():
|
|
return HTMLResponse(content=MINEROPS_MINER_CONTROL_JS, media_type="text/javascript")
|
|
|
|
@app.get("/dashboard", response_class=HTMLResponse)
|
|
def minerops_dashboard_page():
|
|
return """
|
|
<!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()">
|
|
</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 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");
|
|
|
|
renderSummary();
|
|
renderScannerStatus();
|
|
renderIssues();
|
|
renderOpenRepairs();
|
|
renderRepairEvents();
|
|
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 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;
|
|
|
|
if (unit && row.room_name !== unit) {
|
|
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 renderCurrentTable() {
|
|
const rows = getCurrentRows().filter(rowMatchesFilters);
|
|
|
|
if (currentTable === "stale") {
|
|
renderStaleTable(rows);
|
|
return;
|
|
}
|
|
|
|
renderMinerTable(rows);
|
|
}
|
|
|
|
function renderMinerTable(rows) {
|
|
document.getElementById("mainTableHead").innerHTML = `
|
|
<tr>
|
|
<th>Unit</th>
|
|
<th>MAC</th>
|
|
<th>IP</th>
|
|
<th>Model</th>
|
|
<th>Firmware</th>
|
|
<th>App Status</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>';
|
|
|
|
return `
|
|
<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>${status}</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("");
|
|
}
|
|
|
|
function renderStaleTable(rows) {
|
|
document.getElementById("mainTableHead").innerHTML = `
|
|
<tr>
|
|
<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();
|
|
}
|
|
</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>
|
|
</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()">
|
|
</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 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");
|
|
|
|
renderSummary();
|
|
renderScannerStatus();
|
|
renderIssues();
|
|
renderOpenRepairs();
|
|
renderRepairEvents();
|
|
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 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;
|
|
|
|
if (unit && row.room_name !== unit) {
|
|
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 renderCurrentTable() {
|
|
const rows = getCurrentRows().filter(rowMatchesFilters);
|
|
|
|
if (currentTable === "stale") {
|
|
renderStaleTable(rows);
|
|
return;
|
|
}
|
|
|
|
renderMinerTable(rows);
|
|
}
|
|
|
|
function renderMinerTable(rows) {
|
|
document.getElementById("mainTableHead").innerHTML = `
|
|
<tr>
|
|
<th>Unit</th>
|
|
<th>MAC</th>
|
|
<th>IP</th>
|
|
<th>Model</th>
|
|
<th>Firmware</th>
|
|
<th>App Status</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>';
|
|
|
|
return `
|
|
<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>${status}</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("");
|
|
}
|
|
|
|
function renderStaleTable(rows) {
|
|
document.getElementById("mainTableHead").innerHTML = `
|
|
<tr>
|
|
<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();
|
|
}
|
|
</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>
|
|
|
|
</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>
|
|
<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-miner-control.js"></script>
|
|
<script src="/assets/minerops-api3-credentials.js"></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
|
|
|
|
|
|
SCHEDULED_SCANNER_STATE = {
|
|
"enabled": os.getenv("SCHEDULED_SCAN_ENABLED", "true").lower() == "true",
|
|
"interval_seconds": int(os.getenv("SCHEDULED_SCAN_INTERVAL_SECONDS", "120")),
|
|
"max_workers": int(os.getenv("SCHEDULED_SCAN_MAX_WORKERS", "40")),
|
|
"running": False,
|
|
"thread_started": False,
|
|
"last_started_at": None,
|
|
"last_finished_at": None,
|
|
"next_run_at": None,
|
|
"last_duration_seconds": None,
|
|
"last_reason": None,
|
|
"last_total_miners": 0,
|
|
"last_success_count": 0,
|
|
"last_failure_count": 0,
|
|
"last_errors": [],
|
|
"total_runs": 0,
|
|
}
|
|
|
|
SCHEDULED_SCANNER_LOCK = threading.Lock()
|
|
SCHEDULED_SCANNER_STOP = threading.Event()
|
|
|
|
|
|
def scheduled_now_iso():
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def get_known_miners_for_scheduled_scan():
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
miners.id::text AS miner_id,
|
|
host(miners.current_ip) AS ip_address,
|
|
miners.room_id::text AS room_id,
|
|
COALESCE(rooms.name, 'Unassigned') AS room_name,
|
|
miners.current_mac
|
|
FROM miners
|
|
LEFT JOIN rooms ON rooms.id = miners.room_id
|
|
WHERE miners.current_ip IS NOT NULL
|
|
AND miners.room_id IN (
|
|
SELECT DISTINCT room_id
|
|
FROM scan_ranges
|
|
WHERE enabled = true
|
|
AND room_id IS NOT NULL
|
|
)
|
|
ORDER BY rooms.sort_order, miners.current_ip;
|
|
"""
|
|
)
|
|
return [dict(row) for row in cur.fetchall()]
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
def call_discover_one_miner_flexible(ip_address: str, room_id: str | None = None):
|
|
func = globals().get("discover_one_miner")
|
|
|
|
if func is None:
|
|
raise RuntimeError("discover_one_miner function was not found")
|
|
|
|
signature = inspect.signature(func)
|
|
params = signature.parameters
|
|
|
|
candidate_kwargs = {
|
|
"ip_address": ip_address,
|
|
"ip": ip_address,
|
|
"host": ip_address,
|
|
"room_id": room_id,
|
|
"source": "scheduled_known_miner_scan",
|
|
}
|
|
|
|
kwargs = {}
|
|
|
|
for name, param in params.items():
|
|
if name in candidate_kwargs:
|
|
kwargs[name] = candidate_kwargs[name]
|
|
|
|
missing_required = []
|
|
|
|
for name, param in params.items():
|
|
if param.default is inspect._empty and name not in kwargs:
|
|
if param.kind in (
|
|
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
inspect.Parameter.KEYWORD_ONLY,
|
|
):
|
|
missing_required.append(name)
|
|
|
|
if missing_required:
|
|
raise RuntimeError(
|
|
"discover_one_miner requires unsupported argument(s): "
|
|
+ ", ".join(missing_required)
|
|
)
|
|
|
|
result = func(**kwargs)
|
|
|
|
if inspect.iscoroutine(result):
|
|
result = asyncio.run(result)
|
|
|
|
return result
|
|
|
|
|
|
def refresh_one_known_miner_for_schedule(miner_row: dict):
|
|
ip_address = miner_row.get("ip_address")
|
|
room_id = miner_row.get("room_id")
|
|
|
|
if not ip_address:
|
|
raise RuntimeError("Miner has no IP address")
|
|
|
|
call_discover_one_miner_flexible(ip_address=ip_address, room_id=room_id)
|
|
|
|
return {
|
|
"miner_id": miner_row.get("miner_id"),
|
|
"ip_address": ip_address,
|
|
"mac": miner_row.get("current_mac"),
|
|
"room_name": miner_row.get("room_name"),
|
|
}
|
|
|
|
|
|
def run_scheduled_known_miner_scan(reason: str = "scheduled"):
|
|
if not SCHEDULED_SCANNER_LOCK.acquire(blocking=False):
|
|
SCHEDULED_SCANNER_STATE["last_reason"] = "skipped_overlap"
|
|
return
|
|
|
|
started = time.time()
|
|
|
|
SCHEDULED_SCANNER_STATE["running"] = True
|
|
SCHEDULED_SCANNER_STATE["last_started_at"] = scheduled_now_iso()
|
|
SCHEDULED_SCANNER_STATE["last_reason"] = reason
|
|
SCHEDULED_SCANNER_STATE["last_errors"] = []
|
|
|
|
success_count = 0
|
|
failure_count = 0
|
|
errors = []
|
|
|
|
try:
|
|
miners = get_known_miners_for_scheduled_scan()
|
|
SCHEDULED_SCANNER_STATE["last_total_miners"] = len(miners)
|
|
|
|
max_workers = max(1, int(SCHEDULED_SCANNER_STATE["max_workers"]))
|
|
|
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
future_map = {
|
|
executor.submit(refresh_one_known_miner_for_schedule, miner): miner
|
|
for miner in miners
|
|
}
|
|
|
|
for future in as_completed(future_map):
|
|
miner = future_map[future]
|
|
|
|
try:
|
|
future.result()
|
|
success_count += 1
|
|
except Exception as exc:
|
|
failure_count += 1
|
|
|
|
if len(errors) < 25:
|
|
errors.append(
|
|
{
|
|
"ip_address": miner.get("ip_address"),
|
|
"mac": miner.get("current_mac"),
|
|
"room_name": miner.get("room_name"),
|
|
"error": str(exc),
|
|
}
|
|
)
|
|
|
|
SCHEDULED_SCANNER_STATE["last_success_count"] = success_count
|
|
SCHEDULED_SCANNER_STATE["last_failure_count"] = failure_count
|
|
SCHEDULED_SCANNER_STATE["last_errors"] = errors
|
|
SCHEDULED_SCANNER_STATE["total_runs"] += 1
|
|
|
|
except Exception as exc:
|
|
SCHEDULED_SCANNER_STATE["last_errors"] = [
|
|
{
|
|
"error": str(exc),
|
|
"traceback": traceback.format_exc(limit=3),
|
|
}
|
|
]
|
|
SCHEDULED_SCANNER_STATE["last_failure_count"] = failure_count
|
|
|
|
finally:
|
|
finished = time.time()
|
|
|
|
SCHEDULED_SCANNER_STATE["running"] = False
|
|
SCHEDULED_SCANNER_STATE["last_finished_at"] = scheduled_now_iso()
|
|
SCHEDULED_SCANNER_STATE["last_duration_seconds"] = round(finished - started, 2)
|
|
SCHEDULED_SCANNER_STATE["next_run_at"] = datetime.fromtimestamp(
|
|
finished + int(SCHEDULED_SCANNER_STATE["interval_seconds"]),
|
|
tz=timezone.utc,
|
|
).isoformat()
|
|
|
|
SCHEDULED_SCANNER_LOCK.release()
|
|
|
|
|
|
def scheduled_scanner_loop():
|
|
interval = max(60, int(SCHEDULED_SCANNER_STATE["interval_seconds"]))
|
|
SCHEDULED_SCANNER_STATE["next_run_at"] = datetime.fromtimestamp(
|
|
time.time() + 10,
|
|
tz=timezone.utc,
|
|
).isoformat()
|
|
|
|
SCHEDULED_SCANNER_STOP.wait(10)
|
|
|
|
while not SCHEDULED_SCANNER_STOP.is_set():
|
|
if SCHEDULED_SCANNER_STATE["enabled"]:
|
|
run_scheduled_known_miner_scan(reason="scheduled")
|
|
|
|
SCHEDULED_SCANNER_STOP.wait(interval)
|
|
|
|
|
|
@app.on_event("startup")
|
|
def start_scheduled_scanner():
|
|
if SCHEDULED_SCANNER_STATE["thread_started"]:
|
|
return
|
|
|
|
SCHEDULED_SCANNER_STATE["thread_started"] = True
|
|
|
|
thread = threading.Thread(
|
|
target=scheduled_scanner_loop,
|
|
name="minerops-scheduled-scanner",
|
|
daemon=True,
|
|
)
|
|
thread.start()
|
|
|
|
|
|
@app.get("/api/scanner/status")
|
|
def scanner_status(current_user: dict = Depends(get_current_user)):
|
|
return api_response(data=SCHEDULED_SCANNER_STATE)
|
|
|
|
|
|
@app.post("/api/scanner/run-known-now")
|
|
def scanner_run_known_now(current_user: dict = Depends(get_current_user)):
|
|
if SCHEDULED_SCANNER_STATE["running"]:
|
|
return api_response(
|
|
data=SCHEDULED_SCANNER_STATE,
|
|
meta={"message": "Scanner is already running."},
|
|
)
|
|
|
|
thread = threading.Thread(
|
|
target=run_scheduled_known_miner_scan,
|
|
kwargs={"reason": "manual"},
|
|
name="minerops-manual-known-scan",
|
|
daemon=True,
|
|
)
|
|
thread.start()
|
|
|
|
return api_response(
|
|
data=SCHEDULED_SCANNER_STATE,
|
|
meta={"message": "Known-miner scan started."},
|
|
)
|
|
|
|
# Patch: allow the scheduled scanner to call discover_one_miner when it expects FastAPI request=None.
|
|
def call_discover_one_miner_flexible(ip_address: str, room_id: str | None = None):
|
|
func = globals().get("discover_one_miner")
|
|
|
|
if func is None:
|
|
raise RuntimeError("discover_one_miner function was not found")
|
|
|
|
signature = inspect.signature(func)
|
|
params = signature.parameters
|
|
|
|
candidate_kwargs = {
|
|
"request": None,
|
|
"ip_address": ip_address,
|
|
"ip": ip_address,
|
|
"host": ip_address,
|
|
"room_id": room_id,
|
|
"source": "scheduled_known_miner_scan",
|
|
}
|
|
|
|
kwargs = {}
|
|
|
|
for name, param in params.items():
|
|
if name in candidate_kwargs:
|
|
kwargs[name] = candidate_kwargs[name]
|
|
|
|
missing_required = []
|
|
|
|
for name, param in params.items():
|
|
if param.default is inspect._empty and name not in kwargs:
|
|
if param.kind in (
|
|
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
inspect.Parameter.KEYWORD_ONLY,
|
|
):
|
|
missing_required.append(name)
|
|
|
|
if missing_required:
|
|
raise RuntimeError(
|
|
"discover_one_miner requires unsupported argument(s): "
|
|
+ ", ".join(missing_required)
|
|
)
|
|
|
|
result = func(**kwargs)
|
|
|
|
if inspect.iscoroutine(result):
|
|
result = asyncio.run(result)
|
|
|
|
return result
|
|
|
|
# Patch 2: scheduled scanner passes a body-like request object to discover_one_miner.
|
|
from types import SimpleNamespace
|
|
|
|
|
|
def call_discover_one_miner_flexible(ip_address: str, room_id: str | None = None):
|
|
func = globals().get("discover_one_miner")
|
|
|
|
if func is None:
|
|
raise RuntimeError("discover_one_miner function was not found")
|
|
|
|
signature = inspect.signature(func)
|
|
params = signature.parameters
|
|
|
|
request_body = SimpleNamespace(
|
|
ip_address=ip_address,
|
|
room_id=room_id,
|
|
source="scheduled_known_miner_scan",
|
|
update_existing=True,
|
|
api_port=4028,
|
|
)
|
|
|
|
candidate_kwargs = {
|
|
"request": request_body,
|
|
"body": request_body,
|
|
"payload": request_body,
|
|
"ip_address": ip_address,
|
|
"ip": ip_address,
|
|
"host": ip_address,
|
|
"room_id": room_id,
|
|
"source": "scheduled_known_miner_scan",
|
|
}
|
|
|
|
kwargs = {}
|
|
|
|
for name, param in params.items():
|
|
if name in candidate_kwargs:
|
|
kwargs[name] = candidate_kwargs[name]
|
|
|
|
missing_required = []
|
|
|
|
for name, param in params.items():
|
|
if param.default is inspect._empty and name not in kwargs:
|
|
if param.kind in (
|
|
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
inspect.Parameter.KEYWORD_ONLY,
|
|
):
|
|
missing_required.append(name)
|
|
|
|
if missing_required:
|
|
raise RuntimeError(
|
|
"discover_one_miner requires unsupported argument(s): "
|
|
+ ", ".join(missing_required)
|
|
)
|
|
|
|
result = func(**kwargs)
|
|
|
|
if inspect.iscoroutine(result):
|
|
result = asyncio.run(result)
|
|
|
|
return result
|
|
|
|
# Patch 3: stronger fake request object for scheduled known-miner scans.
|
|
class ScheduledDiscoverRequest:
|
|
def __init__(self, ip_address: str, room_id: str | None = None):
|
|
self.ip_address = ip_address
|
|
self.port = 4028
|
|
self.api_port = 4028
|
|
self.room_id = room_id
|
|
self.source = "scheduled_known_miner_scan"
|
|
self.update_existing = True
|
|
self.timeout_seconds = 5
|
|
self.connect_timeout_seconds = 3
|
|
self.read_timeout_seconds = 5
|
|
self.scan_range_id = None
|
|
self.max_ips = None
|
|
self.specific_ips = None
|
|
|
|
def model_dump(self):
|
|
return self.__dict__
|
|
|
|
def dict(self):
|
|
return self.__dict__
|
|
|
|
def __getattr__(self, name):
|
|
return None
|
|
|
|
|
|
def call_discover_one_miner_flexible(ip_address: str, room_id: str | None = None):
|
|
func = globals().get("discover_one_miner")
|
|
|
|
if func is None:
|
|
raise RuntimeError("discover_one_miner function was not found")
|
|
|
|
signature = inspect.signature(func)
|
|
params = signature.parameters
|
|
|
|
request_body = ScheduledDiscoverRequest(
|
|
ip_address=ip_address,
|
|
room_id=room_id,
|
|
)
|
|
|
|
candidate_kwargs = {
|
|
"request": request_body,
|
|
"body": request_body,
|
|
"payload": request_body,
|
|
"ip_address": ip_address,
|
|
"ip": ip_address,
|
|
"host": ip_address,
|
|
"port": 4028,
|
|
"api_port": 4028,
|
|
"room_id": room_id,
|
|
"source": "scheduled_known_miner_scan",
|
|
}
|
|
|
|
kwargs = {}
|
|
|
|
for name, param in params.items():
|
|
if name in candidate_kwargs:
|
|
kwargs[name] = candidate_kwargs[name]
|
|
|
|
missing_required = []
|
|
|
|
for name, param in params.items():
|
|
if param.default is inspect._empty and name not in kwargs:
|
|
if param.kind in (
|
|
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
inspect.Parameter.KEYWORD_ONLY,
|
|
):
|
|
missing_required.append(name)
|
|
|
|
if missing_required:
|
|
raise RuntimeError(
|
|
"discover_one_miner requires unsupported argument(s): "
|
|
+ ", ".join(missing_required)
|
|
)
|
|
|
|
result = func(**kwargs)
|
|
|
|
if inspect.iscoroutine(result):
|
|
result = asyncio.run(result)
|
|
|
|
return result
|
|
|
|
# Patch 4: scheduled scanner request object with timeout_ms fields.
|
|
class ScheduledDiscoverRequestV2:
|
|
def __init__(self, ip_address: str, room_id: str | None = None):
|
|
self.ip_address = ip_address
|
|
self.host = ip_address
|
|
self.ip = ip_address
|
|
|
|
self.port = 4028
|
|
self.api_port = 4028
|
|
|
|
self.room_id = room_id
|
|
self.source = "scheduled_known_miner_scan"
|
|
self.update_existing = True
|
|
|
|
# The existing Whatsminer read code expects millisecond timeout fields.
|
|
self.timeout_ms = 5000
|
|
self.connect_timeout_ms = 3000
|
|
self.read_timeout_ms = 5000
|
|
self.command_timeout_ms = 5000
|
|
|
|
# Also include second-based versions in case other code uses them.
|
|
self.timeout_seconds = 5
|
|
self.connect_timeout_seconds = 3
|
|
self.read_timeout_seconds = 5
|
|
|
|
self.retries = 1
|
|
self.retry_count = 1
|
|
|
|
self.scan_range_id = None
|
|
self.max_ips = None
|
|
self.specific_ips = None
|
|
|
|
def model_dump(self):
|
|
return self.__dict__
|
|
|
|
def dict(self):
|
|
return self.__dict__
|
|
|
|
def __getattr__(self, name):
|
|
if "timeout_ms" in name:
|
|
return 5000
|
|
|
|
if "timeout_seconds" in name:
|
|
return 5
|
|
|
|
if name in ("port", "api_port"):
|
|
return 4028
|
|
|
|
if name in ("retries", "retry_count"):
|
|
return 1
|
|
|
|
return None
|
|
|
|
|
|
def call_discover_one_miner_flexible(ip_address: str, room_id: str | None = None):
|
|
func = globals().get("discover_one_miner")
|
|
|
|
if func is None:
|
|
raise RuntimeError("discover_one_miner function was not found")
|
|
|
|
signature = inspect.signature(func)
|
|
params = signature.parameters
|
|
|
|
request_body = ScheduledDiscoverRequestV2(
|
|
ip_address=ip_address,
|
|
room_id=room_id,
|
|
)
|
|
|
|
candidate_kwargs = {
|
|
"request": request_body,
|
|
"body": request_body,
|
|
"payload": request_body,
|
|
"ip_address": ip_address,
|
|
"ip": ip_address,
|
|
"host": ip_address,
|
|
"port": 4028,
|
|
"api_port": 4028,
|
|
"room_id": room_id,
|
|
"source": "scheduled_known_miner_scan",
|
|
"timeout_ms": 5000,
|
|
"connect_timeout_ms": 3000,
|
|
"read_timeout_ms": 5000,
|
|
}
|
|
|
|
kwargs = {}
|
|
|
|
for name, param in params.items():
|
|
if name in candidate_kwargs:
|
|
kwargs[name] = candidate_kwargs[name]
|
|
|
|
missing_required = []
|
|
|
|
for name, param in params.items():
|
|
if param.default is inspect._empty and name not in kwargs:
|
|
if param.kind in (
|
|
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
inspect.Parameter.KEYWORD_ONLY,
|
|
):
|
|
missing_required.append(name)
|
|
|
|
if missing_required:
|
|
raise RuntimeError(
|
|
"discover_one_miner requires unsupported argument(s): "
|
|
+ ", ".join(missing_required)
|
|
)
|
|
|
|
result = func(**kwargs)
|
|
|
|
if inspect.iscoroutine(result):
|
|
result = asyncio.run(result)
|
|
|
|
return result
|
|
|
|
# Patch 5: scheduled scanner passes an internal system user so FastAPI Depends is not used directly.
|
|
def call_discover_one_miner_flexible(ip_address: str, room_id: str | None = None):
|
|
func = globals().get("discover_one_miner")
|
|
|
|
if func is None:
|
|
raise RuntimeError("discover_one_miner function was not found")
|
|
|
|
signature = inspect.signature(func)
|
|
params = signature.parameters
|
|
|
|
request_body = ScheduledDiscoverRequestV2(
|
|
ip_address=ip_address,
|
|
room_id=room_id,
|
|
)
|
|
|
|
system_user = {
|
|
"id": "00000000-0000-0000-0000-000000000000",
|
|
"user_id": "00000000-0000-0000-0000-000000000000",
|
|
"username": "system-scanner",
|
|
"role": "system",
|
|
"is_admin": True,
|
|
}
|
|
|
|
candidate_kwargs = {
|
|
"request": request_body,
|
|
"body": request_body,
|
|
"payload": request_body,
|
|
|
|
"ip_address": ip_address,
|
|
"ip": ip_address,
|
|
"host": ip_address,
|
|
|
|
"port": 4028,
|
|
"api_port": 4028,
|
|
|
|
"room_id": room_id,
|
|
"source": "scheduled_known_miner_scan",
|
|
|
|
"timeout_ms": 5000,
|
|
"connect_timeout_ms": 3000,
|
|
"read_timeout_ms": 5000,
|
|
|
|
"current_user": system_user,
|
|
"user": system_user,
|
|
"auth_user": system_user,
|
|
}
|
|
|
|
kwargs = {}
|
|
|
|
for name, param in params.items():
|
|
if name in candidate_kwargs:
|
|
kwargs[name] = candidate_kwargs[name]
|
|
|
|
missing_required = []
|
|
|
|
for name, param in params.items():
|
|
if param.default is inspect._empty and name not in kwargs:
|
|
if param.kind in (
|
|
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
inspect.Parameter.KEYWORD_ONLY,
|
|
):
|
|
missing_required.append(name)
|
|
|
|
if missing_required:
|
|
raise RuntimeError(
|
|
"discover_one_miner requires unsupported argument(s): "
|
|
+ ", ".join(missing_required)
|
|
)
|
|
|
|
result = func(**kwargs)
|
|
|
|
if inspect.iscoroutine(result):
|
|
result = asyncio.run(result)
|
|
|
|
return result
|
|
|
|
# Patch 6: scheduled scanner uses a real user from the users table for audit logs.
|
|
def get_scheduled_scanner_user():
|
|
conn = None
|
|
try:
|
|
conn = get_db_connection()
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text AS id,
|
|
username
|
|
FROM users
|
|
ORDER BY
|
|
CASE WHEN username = 'admin' THEN 0 ELSE 1 END,
|
|
username
|
|
LIMIT 1;
|
|
"""
|
|
)
|
|
user = cur.fetchone()
|
|
|
|
if not user:
|
|
raise RuntimeError("No users exist. Cannot run scheduled scanner audit logs.")
|
|
|
|
return {
|
|
"id": user["id"],
|
|
"user_id": user["id"],
|
|
"username": user["username"],
|
|
"role": "system-scanner",
|
|
"is_admin": True,
|
|
}
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
def call_discover_one_miner_flexible(ip_address: str, room_id: str | None = None):
|
|
func = globals().get("discover_one_miner")
|
|
|
|
if func is None:
|
|
raise RuntimeError("discover_one_miner function was not found")
|
|
|
|
request_body = ScheduledDiscoverRequestV2(
|
|
ip_address=ip_address,
|
|
room_id=room_id,
|
|
)
|
|
|
|
system_user = get_scheduled_scanner_user()
|
|
|
|
result = func(
|
|
request=request_body,
|
|
current_user=system_user,
|
|
)
|
|
|
|
if inspect.iscoroutine(result):
|
|
result = asyncio.run(result)
|
|
|
|
return result
|
|
|
|
@app.get("/api/dashboard/issues")
|
|
def dashboard_issues(
|
|
min_temp_c: float = 90,
|
|
stale_minutes: int = 30,
|
|
rejected_percent: float = 1,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
from collections import Counter, defaultdict
|
|
|
|
def to_float(value):
|
|
if value is None:
|
|
return None
|
|
|
|
try:
|
|
return float(value)
|
|
except Exception:
|
|
return None
|
|
|
|
def is_blank(value):
|
|
return value is None or str(value).strip() == ""
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
WITH latest AS (
|
|
SELECT DISTINCT ON (miner_id)
|
|
miner_id,
|
|
observed_at,
|
|
observed_ip,
|
|
mhs_av,
|
|
power_w,
|
|
power_rate_w_per_th,
|
|
chip_temp_max_c,
|
|
chip_temp_avg_c,
|
|
hash_stable,
|
|
pool_rejected_percent
|
|
FROM miner_status_snapshots
|
|
ORDER BY miner_id, observed_at DESC
|
|
),
|
|
hb AS (
|
|
SELECT
|
|
miner_id,
|
|
COUNT(*) AS hashboard_count,
|
|
COUNT(*) FILTER (
|
|
WHERE pcb_sn IS NULL
|
|
OR trim(pcb_sn) = ''
|
|
OR lower(trim(pcb_sn)) = 'none'
|
|
OR lower(trim(pcb_sn)) = 'unknown'
|
|
) AS missing_hashboard_sn_count
|
|
FROM miner_hashboards
|
|
GROUP BY miner_id
|
|
)
|
|
SELECT
|
|
miners.id::text AS miner_id,
|
|
COALESCE(rooms.name, 'Unassigned') AS room_name,
|
|
host(miners.current_ip) AS current_ip,
|
|
miners.current_mac,
|
|
miners.hostname,
|
|
miners.miner_sn,
|
|
miners.power_sn,
|
|
miners.model,
|
|
miners.miner_type,
|
|
miners.firmware_version,
|
|
miners.status,
|
|
miners.last_seen_at,
|
|
latest.observed_at,
|
|
ROUND((latest.mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths,
|
|
ROUND(latest.power_w::numeric, 2)::float AS power_w,
|
|
ROUND(latest.power_rate_w_per_th::numeric, 2)::float AS power_rate_w_per_th,
|
|
ROUND(latest.chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c,
|
|
ROUND(latest.chip_temp_avg_c::numeric, 2)::float AS chip_temp_avg_c,
|
|
latest.hash_stable,
|
|
ROUND(latest.pool_rejected_percent::numeric, 4)::float AS pool_rejected_percent,
|
|
COALESCE(hb.hashboard_count, 0) AS hashboard_count,
|
|
COALESCE(hb.missing_hashboard_sn_count, 0) AS missing_hashboard_sn_count,
|
|
CASE
|
|
WHEN miners.last_seen_at IS NULL THEN true
|
|
WHEN miners.last_seen_at < now() - make_interval(mins => %s) THEN true
|
|
ELSE false
|
|
END AS is_stale,
|
|
ROUND((EXTRACT(EPOCH FROM (now() - miners.last_seen_at)) / 60.0)::numeric, 1)::float AS minutes_since_seen
|
|
FROM miners
|
|
LEFT JOIN rooms ON rooms.id = miners.room_id
|
|
LEFT JOIN latest ON latest.miner_id = miners.id
|
|
LEFT JOIN hb ON hb.miner_id = miners.id
|
|
ORDER BY rooms.sort_order, miners.current_mac;
|
|
""",
|
|
(stale_minutes,),
|
|
)
|
|
|
|
rows = [dict(row) for row in cur.fetchall()]
|
|
|
|
firmware_counts_by_model = defaultdict(Counter)
|
|
|
|
for row in rows:
|
|
model_key = row.get("miner_type") or row.get("model")
|
|
firmware = row.get("firmware_version")
|
|
|
|
if not is_blank(model_key) and not is_blank(firmware):
|
|
firmware_counts_by_model[model_key][firmware] += 1
|
|
|
|
common_firmware_by_model = {}
|
|
|
|
for model_key, counter in firmware_counts_by_model.items():
|
|
total = sum(counter.values())
|
|
|
|
if total < 5:
|
|
continue
|
|
|
|
firmware, count = counter.most_common(1)[0]
|
|
|
|
if count / total >= 0.7:
|
|
common_firmware_by_model[model_key] = firmware
|
|
|
|
issues = []
|
|
|
|
def add_issue(row, issue_type, severity, title, detail, value=None):
|
|
issues.append(
|
|
{
|
|
"issue_type": issue_type,
|
|
"severity": severity,
|
|
"title": title,
|
|
"detail": detail,
|
|
"value": value,
|
|
"miner_id": row.get("miner_id"),
|
|
"room_name": row.get("room_name"),
|
|
"current_mac": row.get("current_mac"),
|
|
"current_ip": row.get("current_ip"),
|
|
"model": row.get("miner_type") or row.get("model"),
|
|
"firmware_version": row.get("firmware_version"),
|
|
"last_seen_at": row.get("last_seen_at"),
|
|
"observed_at": row.get("observed_at"),
|
|
}
|
|
)
|
|
|
|
for row in rows:
|
|
chip_temp_max_c = to_float(row.get("chip_temp_max_c"))
|
|
pool_rejected_percent = to_float(row.get("pool_rejected_percent"))
|
|
minutes_since_seen = to_float(row.get("minutes_since_seen"))
|
|
|
|
if chip_temp_max_c is not None and chip_temp_max_c >= min_temp_c:
|
|
severity = "critical" if chip_temp_max_c >= 95 else "warning"
|
|
|
|
add_issue(
|
|
row,
|
|
"HOT_MINER",
|
|
severity,
|
|
"Hot miner",
|
|
f"Max chip temperature is {chip_temp_max_c:.1f} C.",
|
|
chip_temp_max_c,
|
|
)
|
|
|
|
if row.get("hash_stable") is False:
|
|
add_issue(
|
|
row,
|
|
"HASH_UNSTABLE",
|
|
"warning",
|
|
"Hash unstable",
|
|
"Miner reports hash_stable=false.",
|
|
None,
|
|
)
|
|
|
|
if pool_rejected_percent is not None and pool_rejected_percent >= rejected_percent:
|
|
add_issue(
|
|
row,
|
|
"HIGH_REJECTED",
|
|
"warning",
|
|
"High rejected pool percentage",
|
|
f"Pool rejected percentage is {pool_rejected_percent:.4f}%.",
|
|
pool_rejected_percent,
|
|
)
|
|
|
|
if row.get("is_stale") is True:
|
|
value_text = "unknown" if minutes_since_seen is None else f"{minutes_since_seen:.1f} minutes"
|
|
|
|
add_issue(
|
|
row,
|
|
"STALE_MINER",
|
|
"critical",
|
|
"Stale miner",
|
|
f"Miner has not been seen in {value_text}.",
|
|
minutes_since_seen,
|
|
)
|
|
|
|
if is_blank(row.get("current_mac")):
|
|
add_issue(
|
|
row,
|
|
"MISSING_MAC",
|
|
"critical",
|
|
"Missing MAC address",
|
|
"Miner does not have a current MAC address recorded.",
|
|
None,
|
|
)
|
|
|
|
if is_blank(row.get("miner_sn")):
|
|
add_issue(
|
|
row,
|
|
"MISSING_MINER_SN",
|
|
"warning",
|
|
"Missing miner serial number",
|
|
"Miner serial number is not recorded.",
|
|
None,
|
|
)
|
|
|
|
if is_blank(row.get("power_sn")):
|
|
add_issue(
|
|
row,
|
|
"MISSING_POWER_SN",
|
|
"warning",
|
|
"Missing power supply serial number",
|
|
"Power supply serial number is not recorded.",
|
|
None,
|
|
)
|
|
|
|
missing_hashboard_sn_count = int(row.get("missing_hashboard_sn_count") or 0)
|
|
hashboard_count = int(row.get("hashboard_count") or 0)
|
|
|
|
if hashboard_count > 0 and missing_hashboard_sn_count > 0:
|
|
add_issue(
|
|
row,
|
|
"MISSING_HASHBOARD_SN",
|
|
"warning",
|
|
"Missing hashboard serials",
|
|
f"{missing_hashboard_sn_count} of {hashboard_count} hashboards are missing PCB serial numbers.",
|
|
missing_hashboard_sn_count,
|
|
)
|
|
|
|
model_key = row.get("miner_type") or row.get("model")
|
|
firmware = row.get("firmware_version")
|
|
expected_firmware = common_firmware_by_model.get(model_key)
|
|
|
|
if (
|
|
not is_blank(model_key)
|
|
and not is_blank(firmware)
|
|
and not is_blank(expected_firmware)
|
|
and firmware != expected_firmware
|
|
):
|
|
add_issue(
|
|
row,
|
|
"FIRMWARE_MINOR_VERSION",
|
|
"info",
|
|
"Firmware differs from model majority",
|
|
f"{model_key} is running {firmware}; most miners of this model are running {expected_firmware}.",
|
|
firmware,
|
|
)
|
|
|
|
severity_rank = {
|
|
"critical": 0,
|
|
"warning": 1,
|
|
"info": 2,
|
|
}
|
|
|
|
issues.sort(
|
|
key=lambda issue: (
|
|
severity_rank.get(issue["severity"], 99),
|
|
issue["issue_type"],
|
|
issue.get("room_name") or "",
|
|
issue.get("current_mac") or "",
|
|
)
|
|
)
|
|
|
|
severity_counts = Counter(issue["severity"] for issue in issues)
|
|
type_counts = Counter(issue["issue_type"] for issue in issues)
|
|
|
|
return api_response(
|
|
data=issues,
|
|
meta={
|
|
"total_issues": len(issues),
|
|
"severity_counts": dict(severity_counts),
|
|
"type_counts": dict(type_counts),
|
|
"min_temp_c": min_temp_c,
|
|
"stale_minutes": stale_minutes,
|
|
"rejected_percent": rejected_percent,
|
|
},
|
|
)
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
# -----------------------------
|
|
# Phase 2: Maintenance / repair history
|
|
# -----------------------------
|
|
|
|
from pydantic import BaseModel
|
|
from uuid import uuid4
|
|
|
|
|
|
class MaintenanceEventCreate(BaseModel):
|
|
event_type: str
|
|
event_status: str = "completed"
|
|
event_title: str
|
|
description: str | None = None
|
|
event_at: str | None = None
|
|
|
|
old_mac: str | None = None
|
|
new_mac: str | None = None
|
|
old_ip: str | None = None
|
|
new_ip: str | None = None
|
|
|
|
component_type: str | None = None
|
|
component_slot: str | None = None
|
|
component_serial: str | None = None
|
|
|
|
cost_amount: float | None = None
|
|
downtime_minutes: int | None = None
|
|
|
|
|
|
class MaintenanceEventUpdate(BaseModel):
|
|
event_type: str | None = None
|
|
event_status: str | None = None
|
|
event_title: str | None = None
|
|
description: str | None = None
|
|
event_at: str | None = None
|
|
|
|
old_mac: str | None = None
|
|
new_mac: str | None = None
|
|
old_ip: str | None = None
|
|
new_ip: str | None = None
|
|
|
|
component_type: str | None = None
|
|
component_slot: str | None = None
|
|
component_serial: str | None = None
|
|
|
|
cost_amount: float | None = None
|
|
downtime_minutes: int | None = None
|
|
|
|
|
|
def get_current_user_id_for_maintenance(current_user: dict):
|
|
if isinstance(current_user, dict):
|
|
return current_user.get("id") or current_user.get("user_id")
|
|
|
|
return None
|
|
|
|
|
|
def ensure_maintenance_tables():
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS miner_maintenance_events (
|
|
id uuid PRIMARY KEY,
|
|
miner_id uuid NOT NULL REFERENCES miners(id) ON DELETE CASCADE,
|
|
|
|
event_type text NOT NULL,
|
|
event_status text NOT NULL DEFAULT 'completed',
|
|
event_title text NOT NULL,
|
|
description text,
|
|
|
|
event_at timestamptz NOT NULL DEFAULT now(),
|
|
|
|
old_mac text,
|
|
new_mac text,
|
|
old_ip inet,
|
|
new_ip inet,
|
|
|
|
component_type text,
|
|
component_slot text,
|
|
component_serial text,
|
|
|
|
cost_amount numeric(12, 2),
|
|
downtime_minutes integer,
|
|
|
|
created_by_user_id uuid NULL REFERENCES users(id) ON DELETE SET NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_miner_id
|
|
ON miner_maintenance_events(miner_id);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_event_at
|
|
ON miner_maintenance_events(event_at DESC);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_event_type
|
|
ON miner_maintenance_events(event_type);
|
|
"""
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.on_event("startup")
|
|
def startup_ensure_maintenance_tables():
|
|
ensure_maintenance_tables()
|
|
|
|
|
|
@app.get("/api/maintenance/event-types")
|
|
def maintenance_event_types(current_user: dict = Depends(get_current_user)):
|
|
return api_response(
|
|
data=[
|
|
{"value": "general_note", "label": "General note"},
|
|
{"value": "control_board_changed", "label": "Control board changed"},
|
|
{"value": "mac_changed", "label": "MAC changed"},
|
|
{"value": "hashboard_replaced", "label": "Hashboard replaced"},
|
|
{"value": "psu_replaced", "label": "PSU replaced"},
|
|
{"value": "fan_replaced", "label": "Fan replaced"},
|
|
{"value": "cleaned", "label": "Cleaned"},
|
|
{"value": "moved", "label": "Moved"},
|
|
{"value": "firmware_updated", "label": "Firmware updated"},
|
|
{"value": "troubleshooting", "label": "Troubleshooting"},
|
|
]
|
|
)
|
|
|
|
|
|
@app.get("/api/miners/{miner_id}/maintenance-events")
|
|
def list_miner_maintenance_events(
|
|
miner_id: str,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
ensure_maintenance_tables()
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
e.id::text,
|
|
e.miner_id::text,
|
|
e.event_type,
|
|
e.event_status,
|
|
e.event_title,
|
|
e.description,
|
|
e.event_at,
|
|
e.old_mac,
|
|
e.new_mac,
|
|
host(e.old_ip) AS old_ip,
|
|
host(e.new_ip) AS new_ip,
|
|
e.component_type,
|
|
e.component_slot,
|
|
e.component_serial,
|
|
e.cost_amount::float,
|
|
e.downtime_minutes,
|
|
e.created_by_user_id::text,
|
|
users.username AS created_by_username,
|
|
e.created_at,
|
|
e.updated_at
|
|
FROM miner_maintenance_events e
|
|
LEFT JOIN users ON users.id = e.created_by_user_id
|
|
WHERE e.miner_id = %s::uuid
|
|
ORDER BY e.event_at DESC, e.created_at DESC;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
|
|
rows = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=rows)
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/miners/{miner_id}/maintenance-events")
|
|
def create_miner_maintenance_event(
|
|
miner_id: str,
|
|
request: MaintenanceEventCreate,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
ensure_maintenance_tables()
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
event_id = str(uuid4())
|
|
user_id = get_current_user_id_for_maintenance(current_user)
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text
|
|
FROM miners
|
|
WHERE id = %s::uuid;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
|
|
miner = cur.fetchone()
|
|
|
|
if not miner:
|
|
raise HTTPException(status_code=404, detail="Miner not found")
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO miner_maintenance_events (
|
|
id,
|
|
miner_id,
|
|
event_type,
|
|
event_status,
|
|
event_title,
|
|
description,
|
|
event_at,
|
|
old_mac,
|
|
new_mac,
|
|
old_ip,
|
|
new_ip,
|
|
component_type,
|
|
component_slot,
|
|
component_serial,
|
|
cost_amount,
|
|
downtime_minutes,
|
|
created_by_user_id
|
|
)
|
|
VALUES (
|
|
%s::uuid,
|
|
%s::uuid,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
COALESCE(%s::timestamptz, now()),
|
|
%s,
|
|
%s,
|
|
NULLIF(%s, '')::inet,
|
|
NULLIF(%s, '')::inet,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s::uuid
|
|
)
|
|
RETURNING
|
|
id::text,
|
|
miner_id::text,
|
|
event_type,
|
|
event_status,
|
|
event_title,
|
|
description,
|
|
event_at,
|
|
old_mac,
|
|
new_mac,
|
|
host(old_ip) AS old_ip,
|
|
host(new_ip) AS new_ip,
|
|
component_type,
|
|
component_slot,
|
|
component_serial,
|
|
cost_amount::float,
|
|
downtime_minutes,
|
|
created_by_user_id::text,
|
|
created_at,
|
|
updated_at;
|
|
""",
|
|
(
|
|
event_id,
|
|
miner_id,
|
|
request.event_type,
|
|
request.event_status,
|
|
request.event_title,
|
|
request.description,
|
|
request.event_at,
|
|
request.old_mac,
|
|
request.new_mac,
|
|
request.old_ip,
|
|
request.new_ip,
|
|
request.component_type,
|
|
request.component_slot,
|
|
request.component_serial,
|
|
request.cost_amount,
|
|
request.downtime_minutes,
|
|
user_id,
|
|
),
|
|
)
|
|
|
|
row = dict(cur.fetchone())
|
|
|
|
conn.commit()
|
|
|
|
return api_response(data=row)
|
|
|
|
except Exception:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
|
|
raise
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.patch("/api/miners/{miner_id}/maintenance-events/{event_id}")
|
|
def update_miner_maintenance_event(
|
|
miner_id: str,
|
|
event_id: str,
|
|
request: MaintenanceEventUpdate,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
ensure_maintenance_tables()
|
|
|
|
update_data = request.model_dump(exclude_unset=True)
|
|
|
|
if not update_data:
|
|
raise HTTPException(status_code=400, detail="No fields supplied for update")
|
|
|
|
allowed_fields = {
|
|
"event_type",
|
|
"event_status",
|
|
"event_title",
|
|
"description",
|
|
"event_at",
|
|
"old_mac",
|
|
"new_mac",
|
|
"old_ip",
|
|
"new_ip",
|
|
"component_type",
|
|
"component_slot",
|
|
"component_serial",
|
|
"cost_amount",
|
|
"downtime_minutes",
|
|
}
|
|
|
|
set_parts = []
|
|
values = []
|
|
|
|
for key, value in update_data.items():
|
|
if key not in allowed_fields:
|
|
continue
|
|
|
|
if key in ("old_ip", "new_ip"):
|
|
set_parts.append(f"{key} = NULLIF(%s, '')::inet")
|
|
elif key == "event_at":
|
|
set_parts.append(f"{key} = %s::timestamptz")
|
|
else:
|
|
set_parts.append(f"{key} = %s")
|
|
|
|
values.append(value)
|
|
|
|
if not set_parts:
|
|
raise HTTPException(status_code=400, detail="No valid fields supplied for update")
|
|
|
|
set_parts.append("updated_at = now()")
|
|
|
|
values.append(miner_id)
|
|
values.append(event_id)
|
|
|
|
sql = f"""
|
|
UPDATE miner_maintenance_events
|
|
SET {", ".join(set_parts)}
|
|
WHERE miner_id = %s::uuid
|
|
AND id = %s::uuid
|
|
RETURNING
|
|
id::text,
|
|
miner_id::text,
|
|
event_type,
|
|
event_status,
|
|
event_title,
|
|
description,
|
|
event_at,
|
|
old_mac,
|
|
new_mac,
|
|
host(old_ip) AS old_ip,
|
|
host(new_ip) AS new_ip,
|
|
component_type,
|
|
component_slot,
|
|
component_serial,
|
|
cost_amount::float,
|
|
downtime_minutes,
|
|
created_by_user_id::text,
|
|
created_at,
|
|
updated_at;
|
|
"""
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(sql, values)
|
|
row = cur.fetchone()
|
|
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Maintenance event not found")
|
|
|
|
row = dict(row)
|
|
|
|
conn.commit()
|
|
|
|
return api_response(data=row)
|
|
|
|
except Exception:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
|
|
raise
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.delete("/api/miners/{miner_id}/maintenance-events/{event_id}")
|
|
def delete_miner_maintenance_event(
|
|
miner_id: str,
|
|
event_id: str,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
ensure_maintenance_tables()
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
DELETE FROM miner_maintenance_events
|
|
WHERE miner_id = %s::uuid
|
|
AND id = %s::uuid
|
|
RETURNING id::text;
|
|
""",
|
|
(miner_id, event_id),
|
|
)
|
|
|
|
row = cur.fetchone()
|
|
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Maintenance event not found")
|
|
|
|
conn.commit()
|
|
|
|
return api_response(data={"deleted": True, "id": event_id})
|
|
|
|
except Exception:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
|
|
raise
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
class MinerMacChangeRequest(BaseModel):
|
|
new_mac: str
|
|
old_mac: str | None = None
|
|
new_ip: str | None = None
|
|
event_title: str | None = None
|
|
description: str | None = None
|
|
component_serial: str | None = None
|
|
downtime_minutes: int | None = None
|
|
|
|
|
|
def normalize_mac_display(mac: str | None):
|
|
if mac is None:
|
|
return None
|
|
|
|
cleaned = "".join(ch for ch in str(mac).strip().lower() if ch in "0123456789abcdef")
|
|
|
|
if len(cleaned) != 12:
|
|
return str(mac).strip().upper()
|
|
|
|
return ":".join(cleaned[i:i + 2] for i in range(0, 12, 2)).upper()
|
|
|
|
|
|
def normalize_mac_key_for_change(mac: str | None):
|
|
if mac is None:
|
|
return None
|
|
|
|
return "".join(ch for ch in str(mac).strip().lower() if ch in "0123456789abcdef")
|
|
|
|
|
|
@app.post("/api/miners/{miner_id}/change-mac")
|
|
def change_miner_mac_address(
|
|
miner_id: str,
|
|
request: MinerMacChangeRequest,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
ensure_maintenance_tables()
|
|
|
|
new_mac = normalize_mac_display(request.new_mac)
|
|
new_mac_key = normalize_mac_key_for_change(new_mac)
|
|
|
|
if not new_mac_key or len(new_mac_key) != 12:
|
|
raise HTTPException(status_code=400, detail="New MAC address is invalid")
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
user_id = get_current_user_id_for_maintenance(current_user)
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
current_mac,
|
|
host(current_ip) AS current_ip,
|
|
hostname
|
|
FROM miners
|
|
WHERE id = %s::uuid;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
|
|
miner = cur.fetchone()
|
|
|
|
if not miner:
|
|
raise HTTPException(status_code=404, detail="Miner not found")
|
|
|
|
miner = dict(miner)
|
|
|
|
old_mac = normalize_mac_display(request.old_mac or miner.get("current_mac"))
|
|
old_ip = miner.get("current_ip")
|
|
new_ip = request.new_ip or old_ip
|
|
|
|
if normalize_mac_key_for_change(old_mac) == new_mac_key:
|
|
raise HTTPException(status_code=400, detail="New MAC is the same as the current/old MAC")
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE miners
|
|
SET
|
|
current_mac = %s,
|
|
current_ip = COALESCE(NULLIF(%s, '')::inet, current_ip)
|
|
WHERE id = %s::uuid
|
|
RETURNING
|
|
id::text,
|
|
current_mac,
|
|
host(current_ip) AS current_ip;
|
|
""",
|
|
(
|
|
new_mac,
|
|
new_ip,
|
|
miner_id,
|
|
),
|
|
)
|
|
|
|
updated_miner = dict(cur.fetchone())
|
|
|
|
# Mark all known identities for this miner as not current.
|
|
cur.execute(
|
|
"""
|
|
UPDATE miner_network_identities
|
|
SET is_current = false
|
|
WHERE miner_id = %s::uuid;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
|
|
# If this MAC already exists in the identity history, mark it current.
|
|
cur.execute(
|
|
"""
|
|
UPDATE miner_network_identities
|
|
SET
|
|
is_current = true,
|
|
ip_address = COALESCE(NULLIF(%s, '')::inet, ip_address),
|
|
hostname = COALESCE(NULLIF(%s, ''), hostname),
|
|
last_seen_at = now()
|
|
WHERE miner_id = %s::uuid
|
|
AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') = %s
|
|
RETURNING id::text;
|
|
""",
|
|
(
|
|
new_ip,
|
|
miner.get("hostname"),
|
|
miner_id,
|
|
new_mac_key,
|
|
),
|
|
)
|
|
|
|
existing_identity = cur.fetchone()
|
|
|
|
if not existing_identity:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO miner_network_identities (
|
|
id,
|
|
miner_id,
|
|
mac_address,
|
|
ip_address,
|
|
hostname,
|
|
first_seen_at,
|
|
last_seen_at,
|
|
is_current
|
|
)
|
|
VALUES (
|
|
%s::uuid,
|
|
%s::uuid,
|
|
%s,
|
|
NULLIF(%s, '')::inet,
|
|
%s,
|
|
now(),
|
|
now(),
|
|
true
|
|
);
|
|
""",
|
|
(
|
|
str(uuid4()),
|
|
miner_id,
|
|
new_mac,
|
|
new_ip,
|
|
miner.get("hostname"),
|
|
),
|
|
)
|
|
|
|
event_id = str(uuid4())
|
|
event_title = request.event_title or "Control board / MAC address changed"
|
|
|
|
description_parts = []
|
|
|
|
if request.description:
|
|
description_parts.append(request.description)
|
|
|
|
description_parts.append(f"MAC changed from {old_mac or 'unknown'} to {new_mac}.")
|
|
|
|
if old_ip or new_ip:
|
|
description_parts.append(f"IP changed/tracked from {old_ip or 'unknown'} to {new_ip or 'unknown'}.")
|
|
|
|
event_description = " ".join(description_parts)
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO miner_maintenance_events (
|
|
id,
|
|
miner_id,
|
|
event_type,
|
|
event_status,
|
|
event_title,
|
|
description,
|
|
event_at,
|
|
old_mac,
|
|
new_mac,
|
|
old_ip,
|
|
new_ip,
|
|
component_type,
|
|
component_slot,
|
|
component_serial,
|
|
downtime_minutes,
|
|
created_by_user_id
|
|
)
|
|
VALUES (
|
|
%s::uuid,
|
|
%s::uuid,
|
|
'control_board_changed',
|
|
'completed',
|
|
%s,
|
|
%s,
|
|
now(),
|
|
%s,
|
|
%s,
|
|
NULLIF(%s, '')::inet,
|
|
NULLIF(%s, '')::inet,
|
|
'control_board',
|
|
'control_board',
|
|
%s,
|
|
%s,
|
|
%s::uuid
|
|
)
|
|
RETURNING
|
|
id::text,
|
|
miner_id::text,
|
|
event_type,
|
|
event_status,
|
|
event_title,
|
|
description,
|
|
event_at,
|
|
old_mac,
|
|
new_mac,
|
|
host(old_ip) AS old_ip,
|
|
host(new_ip) AS new_ip,
|
|
component_type,
|
|
component_slot,
|
|
component_serial,
|
|
downtime_minutes,
|
|
created_by_user_id::text,
|
|
created_at,
|
|
updated_at;
|
|
""",
|
|
(
|
|
event_id,
|
|
miner_id,
|
|
event_title,
|
|
event_description,
|
|
old_mac,
|
|
new_mac,
|
|
old_ip,
|
|
new_ip,
|
|
request.component_serial,
|
|
request.downtime_minutes,
|
|
user_id,
|
|
),
|
|
)
|
|
|
|
maintenance_event = dict(cur.fetchone())
|
|
|
|
conn.commit()
|
|
|
|
return api_response(
|
|
data={
|
|
"miner": updated_miner,
|
|
"maintenance_event": maintenance_event,
|
|
}
|
|
)
|
|
|
|
except Exception:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
|
|
raise
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
@app.get("/api/maintenance/events")
|
|
def list_all_maintenance_events(
|
|
limit: int = 100,
|
|
event_status: str | None = None,
|
|
event_type: str | None = None,
|
|
q: str | None = None,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
from collections import Counter
|
|
|
|
ensure_maintenance_tables()
|
|
|
|
safe_limit = max(1, min(int(limit or 100), 500))
|
|
|
|
where_parts = ["1 = 1"]
|
|
values = []
|
|
|
|
if event_status:
|
|
where_parts.append("e.event_status = %s")
|
|
values.append(event_status)
|
|
|
|
if event_type:
|
|
where_parts.append("e.event_type = %s")
|
|
values.append(event_type)
|
|
|
|
if q:
|
|
search_value = f"%{q.strip()}%"
|
|
|
|
where_parts.append(
|
|
"""
|
|
(
|
|
e.event_title ILIKE %s
|
|
OR e.description ILIKE %s
|
|
OR e.event_type ILIKE %s
|
|
OR e.event_status ILIKE %s
|
|
OR e.old_mac ILIKE %s
|
|
OR e.new_mac ILIKE %s
|
|
OR e.component_type ILIKE %s
|
|
OR e.component_slot ILIKE %s
|
|
OR e.component_serial ILIKE %s
|
|
OR miners.current_mac ILIKE %s
|
|
OR host(miners.current_ip) ILIKE %s
|
|
OR miners.miner_sn ILIKE %s
|
|
OR miners.power_sn ILIKE %s
|
|
OR rooms.name ILIKE %s
|
|
)
|
|
"""
|
|
)
|
|
|
|
values.extend([search_value] * 14)
|
|
|
|
values.append(safe_limit)
|
|
|
|
sql = f"""
|
|
SELECT
|
|
e.id::text AS event_id,
|
|
e.miner_id::text AS miner_id,
|
|
|
|
COALESCE(rooms.name, 'Unassigned') AS room_name,
|
|
|
|
miners.current_mac,
|
|
host(miners.current_ip) AS current_ip,
|
|
miners.hostname,
|
|
miners.miner_sn,
|
|
miners.power_sn,
|
|
miners.model,
|
|
miners.miner_type,
|
|
miners.firmware_version,
|
|
|
|
e.event_type,
|
|
e.event_status,
|
|
e.event_title,
|
|
e.description,
|
|
e.event_at,
|
|
|
|
e.old_mac,
|
|
e.new_mac,
|
|
host(e.old_ip) AS old_ip,
|
|
host(e.new_ip) AS new_ip,
|
|
|
|
e.component_type,
|
|
e.component_slot,
|
|
e.component_serial,
|
|
e.cost_amount::float,
|
|
e.downtime_minutes,
|
|
|
|
e.created_by_user_id::text,
|
|
users.username AS created_by_username,
|
|
e.created_at,
|
|
e.updated_at
|
|
FROM miner_maintenance_events e
|
|
INNER JOIN miners ON miners.id = e.miner_id
|
|
LEFT JOIN rooms ON rooms.id = miners.room_id
|
|
LEFT JOIN users ON users.id = e.created_by_user_id
|
|
WHERE {" AND ".join(where_parts)}
|
|
ORDER BY e.event_at DESC, e.created_at DESC
|
|
LIMIT %s;
|
|
"""
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(sql, values)
|
|
rows = [dict(row) for row in cur.fetchall()]
|
|
|
|
status_counts = Counter(row.get("event_status") for row in rows)
|
|
type_counts = Counter(row.get("event_type") for row in rows)
|
|
room_counts = Counter(row.get("room_name") for row in rows)
|
|
|
|
return api_response(
|
|
data=rows,
|
|
meta={
|
|
"returned": len(rows),
|
|
"limit": safe_limit,
|
|
"status_counts": dict(status_counts),
|
|
"type_counts": dict(type_counts),
|
|
"room_counts": dict(room_counts),
|
|
"filters": {
|
|
"event_status": event_status,
|
|
"event_type": event_type,
|
|
"q": q,
|
|
},
|
|
},
|
|
)
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
@app.get("/api/maintenance/events/export.csv")
|
|
def export_all_maintenance_events_csv(
|
|
limit: int = 5000,
|
|
event_status: str | None = None,
|
|
event_type: str | None = None,
|
|
q: str | None = None,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
import csv
|
|
import io
|
|
from datetime import datetime, timezone
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
ensure_maintenance_tables()
|
|
|
|
safe_limit = max(1, min(int(limit or 5000), 20000))
|
|
|
|
where_parts = ["1 = 1"]
|
|
values = []
|
|
|
|
if event_status:
|
|
where_parts.append("e.event_status = %s")
|
|
values.append(event_status)
|
|
|
|
if event_type:
|
|
where_parts.append("e.event_type = %s")
|
|
values.append(event_type)
|
|
|
|
if q:
|
|
search_value = f"%{q.strip()}%"
|
|
|
|
where_parts.append(
|
|
"""
|
|
(
|
|
e.event_title ILIKE %s
|
|
OR e.description ILIKE %s
|
|
OR e.event_type ILIKE %s
|
|
OR e.event_status ILIKE %s
|
|
OR e.old_mac ILIKE %s
|
|
OR e.new_mac ILIKE %s
|
|
OR e.component_type ILIKE %s
|
|
OR e.component_slot ILIKE %s
|
|
OR e.component_serial ILIKE %s
|
|
OR miners.current_mac ILIKE %s
|
|
OR host(miners.current_ip) ILIKE %s
|
|
OR miners.miner_sn ILIKE %s
|
|
OR miners.power_sn ILIKE %s
|
|
OR rooms.name ILIKE %s
|
|
)
|
|
"""
|
|
)
|
|
|
|
values.extend([search_value] * 14)
|
|
|
|
values.append(safe_limit)
|
|
|
|
sql = f"""
|
|
SELECT
|
|
e.event_at,
|
|
e.event_status,
|
|
e.event_type,
|
|
e.event_title,
|
|
e.description,
|
|
|
|
COALESCE(rooms.name, 'Unassigned') AS room_name,
|
|
|
|
miners.current_mac,
|
|
host(miners.current_ip) AS current_ip,
|
|
miners.hostname,
|
|
miners.miner_sn,
|
|
miners.power_sn,
|
|
miners.model,
|
|
miners.miner_type,
|
|
miners.firmware_version,
|
|
|
|
e.old_mac,
|
|
e.new_mac,
|
|
host(e.old_ip) AS old_ip,
|
|
host(e.new_ip) AS new_ip,
|
|
|
|
e.component_type,
|
|
e.component_slot,
|
|
e.component_serial,
|
|
e.cost_amount::float,
|
|
e.downtime_minutes,
|
|
|
|
users.username AS created_by_username,
|
|
e.created_at,
|
|
e.updated_at,
|
|
e.id::text AS event_id,
|
|
e.miner_id::text AS miner_id
|
|
FROM miner_maintenance_events e
|
|
INNER JOIN miners ON miners.id = e.miner_id
|
|
LEFT JOIN rooms ON rooms.id = miners.room_id
|
|
LEFT JOIN users ON users.id = e.created_by_user_id
|
|
WHERE {" AND ".join(where_parts)}
|
|
ORDER BY e.event_at DESC, e.created_at DESC
|
|
LIMIT %s;
|
|
"""
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(sql, values)
|
|
rows = [dict(row) for row in cur.fetchall()]
|
|
|
|
output = io.StringIO()
|
|
|
|
fieldnames = [
|
|
"event_at",
|
|
"event_status",
|
|
"event_type",
|
|
"event_title",
|
|
"description",
|
|
"room_name",
|
|
"current_mac",
|
|
"current_ip",
|
|
"hostname",
|
|
"miner_sn",
|
|
"power_sn",
|
|
"model",
|
|
"miner_type",
|
|
"firmware_version",
|
|
"old_mac",
|
|
"new_mac",
|
|
"old_ip",
|
|
"new_ip",
|
|
"component_type",
|
|
"component_slot",
|
|
"component_serial",
|
|
"cost_amount",
|
|
"downtime_minutes",
|
|
"created_by_username",
|
|
"created_at",
|
|
"updated_at",
|
|
"event_id",
|
|
"miner_id",
|
|
]
|
|
|
|
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
|
|
writer.writeheader()
|
|
|
|
for row in rows:
|
|
cleaned = {}
|
|
|
|
for field in fieldnames:
|
|
value = row.get(field)
|
|
|
|
if value is None:
|
|
cleaned[field] = ""
|
|
else:
|
|
cleaned[field] = str(value)
|
|
|
|
writer.writerow(cleaned)
|
|
|
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
filename = f"minerops-maintenance-events-{timestamp}.csv"
|
|
|
|
output.seek(0)
|
|
|
|
return StreamingResponse(
|
|
iter([output.getvalue()]),
|
|
media_type="text/csv",
|
|
headers={
|
|
"Content-Disposition": f'attachment; filename="{filename}"'
|
|
},
|
|
)
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
# -----------------------------
|
|
# Phase 3: Physical location mapping
|
|
# -----------------------------
|
|
|
|
class LocationSeedImportRequest(BaseModel):
|
|
schema_version: int = 1
|
|
generated_from: list[str] | None = None
|
|
notes: str | None = None
|
|
virtual_states: list[str] | None = None
|
|
summary: dict | None = None
|
|
positions: list[dict]
|
|
|
|
|
|
def normalize_mac_for_location(mac: str | None):
|
|
if mac is None:
|
|
return None
|
|
|
|
cleaned = "".join(ch for ch in str(mac).strip().lower() if ch in "0123456789abcdef")
|
|
|
|
if len(cleaned) != 12:
|
|
return None
|
|
|
|
return ":".join(cleaned[i:i + 2] for i in range(0, 12, 2)).upper()
|
|
|
|
|
|
def normalize_mac_key_for_location(mac: str | None):
|
|
normalized = normalize_mac_for_location(mac)
|
|
|
|
if normalized is None:
|
|
return None
|
|
|
|
return normalized.replace(":", "").lower()
|
|
|
|
|
|
def get_current_user_id_for_location(current_user: dict):
|
|
if isinstance(current_user, dict):
|
|
return current_user.get("id") or current_user.get("user_id")
|
|
|
|
return None
|
|
|
|
|
|
def ensure_location_tables():
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS location_positions (
|
|
id uuid PRIMARY KEY,
|
|
room_id uuid NULL REFERENCES rooms(id) ON DELETE SET NULL,
|
|
room_name text NOT NULL,
|
|
rack_name text NOT NULL,
|
|
shelf_name text NOT NULL,
|
|
shelf_index integer,
|
|
position_index integer NOT NULL,
|
|
position_label text,
|
|
|
|
position_kind text NOT NULL DEFAULT 'miner_slot',
|
|
position_status text NOT NULL DEFAULT 'available',
|
|
|
|
expected_mac text,
|
|
miner_id uuid NULL REFERENCES miners(id) ON DELETE SET NULL,
|
|
|
|
equipment_label text,
|
|
notes text,
|
|
|
|
source_file text,
|
|
source_sheet text,
|
|
source_cell text,
|
|
raw_value text,
|
|
|
|
conflict_reason text,
|
|
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
DO $$
|
|
BEGIN
|
|
ALTER TABLE location_positions
|
|
ADD CONSTRAINT uq_location_positions_slot
|
|
UNIQUE (room_name, rack_name, shelf_name, position_index);
|
|
EXCEPTION
|
|
WHEN duplicate_object OR duplicate_table THEN NULL;
|
|
END $$;
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_location_positions_room
|
|
ON location_positions(room_name);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_location_positions_rack
|
|
ON location_positions(room_name, rack_name);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_location_positions_expected_mac
|
|
ON location_positions(expected_mac);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_location_positions_miner_id
|
|
ON location_positions(miner_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS miner_location_states (
|
|
miner_id uuid PRIMARY KEY REFERENCES miners(id) ON DELETE CASCADE,
|
|
asset_state text NOT NULL DEFAULT 'waiting_placement',
|
|
location_position_id uuid NULL REFERENCES location_positions(id) ON DELETE SET NULL,
|
|
state_notes text,
|
|
updated_by_user_id uuid NULL REFERENCES users(id) ON DELETE SET NULL,
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_miner_location_states_asset_state
|
|
ON miner_location_states(asset_state);
|
|
|
|
CREATE TABLE IF NOT EXISTS location_import_issues (
|
|
id uuid PRIMARY KEY,
|
|
import_batch_id uuid NOT NULL,
|
|
issue_type text NOT NULL,
|
|
severity text NOT NULL DEFAULT 'warning',
|
|
message text NOT NULL,
|
|
mac_address text,
|
|
room_name text,
|
|
rack_name text,
|
|
shelf_name text,
|
|
position_index integer,
|
|
source_file text,
|
|
source_cell text,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
resolved_at timestamptz NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_location_import_issues_batch
|
|
ON location_import_issues(import_batch_id);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_location_import_issues_mac
|
|
ON location_import_issues(mac_address);
|
|
"""
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.on_event("startup")
|
|
def startup_ensure_location_tables():
|
|
ensure_location_tables()
|
|
|
|
|
|
def get_room_id_by_name(cur, room_name: str):
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text
|
|
FROM rooms
|
|
WHERE lower(name) = lower(%s)
|
|
LIMIT 1;
|
|
""",
|
|
(room_name,),
|
|
)
|
|
|
|
row = cur.fetchone()
|
|
|
|
if row:
|
|
return row["id"] if isinstance(row, dict) else row[0]
|
|
|
|
return None
|
|
|
|
|
|
def find_or_create_placeholder_miner_for_location(cur, mac_address: str, room_id: str | None):
|
|
mac_key = normalize_mac_key_for_location(mac_address)
|
|
|
|
if not mac_key:
|
|
return None, False
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text
|
|
FROM miners
|
|
WHERE regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g') = %s
|
|
LIMIT 1;
|
|
""",
|
|
(mac_key,),
|
|
)
|
|
|
|
row = cur.fetchone()
|
|
|
|
if row:
|
|
miner_id = row["id"] if isinstance(row, dict) else row[0]
|
|
|
|
if room_id:
|
|
cur.execute(
|
|
"""
|
|
UPDATE miners
|
|
SET room_id = COALESCE(%s::uuid, room_id)
|
|
WHERE id = %s::uuid;
|
|
""",
|
|
(room_id, miner_id),
|
|
)
|
|
|
|
return miner_id, False
|
|
|
|
miner_id = str(uuid4())
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT column_name
|
|
FROM information_schema.columns
|
|
WHERE table_name = 'miners';
|
|
"""
|
|
)
|
|
|
|
miner_columns = {row["column_name"] if isinstance(row, dict) else row[0] for row in cur.fetchall()}
|
|
|
|
insert_columns = []
|
|
insert_values_sql = []
|
|
values = []
|
|
|
|
def add_value(column_name, value, sql_cast=None):
|
|
if column_name not in miner_columns:
|
|
return
|
|
|
|
insert_columns.append(column_name)
|
|
|
|
if sql_cast:
|
|
insert_values_sql.append(f"%s::{sql_cast}")
|
|
else:
|
|
insert_values_sql.append("%s")
|
|
|
|
values.append(value)
|
|
|
|
def add_now(column_name):
|
|
if column_name not in miner_columns:
|
|
return
|
|
|
|
insert_columns.append(column_name)
|
|
insert_values_sql.append("now()")
|
|
|
|
add_value("id", miner_id, "uuid")
|
|
add_value("room_id", room_id, "uuid")
|
|
add_value("current_mac", mac_address)
|
|
add_value("status", "offline")
|
|
add_value("hostname", None)
|
|
add_value("model", None)
|
|
add_value("miner_type", None)
|
|
add_value("firmware_version", None)
|
|
add_value("miner_sn", None)
|
|
add_value("power_sn", None)
|
|
add_now("created_at")
|
|
add_now("updated_at")
|
|
|
|
sql = f"""
|
|
INSERT INTO miners ({", ".join(insert_columns)})
|
|
VALUES ({", ".join(insert_values_sql)})
|
|
RETURNING id::text;
|
|
"""
|
|
|
|
cur.execute(sql, values)
|
|
created = cur.fetchone()
|
|
|
|
if created:
|
|
miner_id = created["id"] if isinstance(created, dict) else created[0]
|
|
|
|
return miner_id, True
|
|
|
|
|
|
@app.post("/api/location/import-layout-seed")
|
|
def import_location_layout_seed(
|
|
request: LocationSeedImportRequest,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
ensure_location_tables()
|
|
|
|
if not request.positions:
|
|
raise HTTPException(status_code=400, detail="No positions found in layout seed")
|
|
|
|
import_batch_id = str(uuid4())
|
|
user_id = get_current_user_id_for_location(current_user)
|
|
|
|
conn = None
|
|
|
|
counts = {
|
|
"positions_seen": 0,
|
|
"positions_upserted": 0,
|
|
"miner_positions": 0,
|
|
"switch_positions": 0,
|
|
"blocked_positions": 0,
|
|
"empty_positions": 0,
|
|
"placeholder_miners_created": 0,
|
|
"existing_miners_matched": 0,
|
|
"duplicate_conflicts": 0,
|
|
"miners_marked_waiting_placement": 0,
|
|
}
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
DELETE FROM location_import_issues
|
|
WHERE resolved_at IS NULL;
|
|
"""
|
|
)
|
|
|
|
mac_counts = {}
|
|
|
|
for position in request.positions:
|
|
mac = normalize_mac_for_location(position.get("mac_address"))
|
|
|
|
if mac:
|
|
mac_counts[mac] = mac_counts.get(mac, 0) + 1
|
|
|
|
duplicate_macs = {mac for mac, count in mac_counts.items() if count > 1}
|
|
|
|
for position in request.positions:
|
|
counts["positions_seen"] += 1
|
|
|
|
room_name = str(position.get("room_name") or "").strip()
|
|
rack_name = str(position.get("rack_name") or "").strip()
|
|
shelf_name = str(position.get("shelf_name") or "").strip()
|
|
position_index = int(position.get("position_index") or 0)
|
|
|
|
if not room_name or not rack_name or not shelf_name or position_index <= 0:
|
|
continue
|
|
|
|
item_type = str(position.get("item_type") or "empty").strip().lower()
|
|
mac_address = normalize_mac_for_location(position.get("mac_address"))
|
|
room_id = get_room_id_by_name(cur, room_name)
|
|
|
|
position_kind = "miner_slot"
|
|
position_status = str(position.get("position_status") or "available").strip().lower()
|
|
equipment_label = position.get("label")
|
|
expected_mac = None
|
|
miner_id = None
|
|
conflict_reason = position.get("conflict_reason")
|
|
|
|
if item_type == "switch":
|
|
position_kind = "switch"
|
|
position_status = "switch"
|
|
counts["switch_positions"] += 1
|
|
|
|
elif item_type == "blocked":
|
|
position_kind = "blocked"
|
|
position_status = "blocked"
|
|
counts["blocked_positions"] += 1
|
|
|
|
elif item_type == "miner" and mac_address:
|
|
position_kind = "miner_slot"
|
|
expected_mac = mac_address
|
|
counts["miner_positions"] += 1
|
|
|
|
miner_id, was_created = find_or_create_placeholder_miner_for_location(cur, mac_address, room_id)
|
|
|
|
if was_created:
|
|
counts["placeholder_miners_created"] += 1
|
|
else:
|
|
counts["existing_miners_matched"] += 1
|
|
|
|
if mac_address in duplicate_macs:
|
|
position_status = "conflict"
|
|
conflict_reason = "duplicate_mac_in_layout"
|
|
counts["duplicate_conflicts"] += 1
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO location_import_issues (
|
|
id,
|
|
import_batch_id,
|
|
issue_type,
|
|
severity,
|
|
message,
|
|
mac_address,
|
|
room_name,
|
|
rack_name,
|
|
shelf_name,
|
|
position_index,
|
|
source_file,
|
|
source_cell
|
|
)
|
|
VALUES (
|
|
%s::uuid,
|
|
%s::uuid,
|
|
'duplicate_mac_in_layout',
|
|
'warning',
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s
|
|
);
|
|
""",
|
|
(
|
|
str(uuid4()),
|
|
import_batch_id,
|
|
f"Duplicate MAC {mac_address} appears in more than one physical map position.",
|
|
mac_address,
|
|
room_name,
|
|
rack_name,
|
|
shelf_name,
|
|
position_index,
|
|
position.get("source_file"),
|
|
position.get("source_cell"),
|
|
),
|
|
)
|
|
else:
|
|
position_status = "occupied"
|
|
|
|
else:
|
|
position_kind = "miner_slot"
|
|
position_status = "available"
|
|
counts["empty_positions"] += 1
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO location_positions (
|
|
id,
|
|
room_id,
|
|
room_name,
|
|
rack_name,
|
|
shelf_name,
|
|
shelf_index,
|
|
position_index,
|
|
position_label,
|
|
position_kind,
|
|
position_status,
|
|
expected_mac,
|
|
miner_id,
|
|
equipment_label,
|
|
notes,
|
|
source_file,
|
|
source_sheet,
|
|
source_cell,
|
|
raw_value,
|
|
conflict_reason,
|
|
updated_at
|
|
)
|
|
VALUES (
|
|
%s::uuid,
|
|
%s::uuid,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s::uuid,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
now()
|
|
)
|
|
ON CONFLICT (room_name, rack_name, shelf_name, position_index)
|
|
DO UPDATE SET
|
|
room_id = EXCLUDED.room_id,
|
|
position_label = EXCLUDED.position_label,
|
|
position_kind = EXCLUDED.position_kind,
|
|
position_status = EXCLUDED.position_status,
|
|
expected_mac = EXCLUDED.expected_mac,
|
|
miner_id = EXCLUDED.miner_id,
|
|
equipment_label = EXCLUDED.equipment_label,
|
|
notes = EXCLUDED.notes,
|
|
source_file = EXCLUDED.source_file,
|
|
source_sheet = EXCLUDED.source_sheet,
|
|
source_cell = EXCLUDED.source_cell,
|
|
raw_value = EXCLUDED.raw_value,
|
|
conflict_reason = EXCLUDED.conflict_reason,
|
|
updated_at = now()
|
|
RETURNING id::text;
|
|
""",
|
|
(
|
|
str(uuid4()),
|
|
room_id,
|
|
room_name,
|
|
rack_name,
|
|
shelf_name,
|
|
position.get("shelf_index"),
|
|
position_index,
|
|
position.get("position_label"),
|
|
position_kind,
|
|
position_status,
|
|
expected_mac,
|
|
miner_id,
|
|
equipment_label,
|
|
position.get("notes"),
|
|
position.get("source_file"),
|
|
position.get("source_sheet"),
|
|
position.get("source_cell"),
|
|
position.get("raw_value"),
|
|
conflict_reason,
|
|
),
|
|
)
|
|
|
|
position_row = cur.fetchone()
|
|
location_position_id = position_row["id"]
|
|
|
|
counts["positions_upserted"] += 1
|
|
|
|
if miner_id:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO miner_location_states (
|
|
miner_id,
|
|
asset_state,
|
|
location_position_id,
|
|
updated_by_user_id,
|
|
updated_at
|
|
)
|
|
VALUES (
|
|
%s::uuid,
|
|
'shelved',
|
|
%s::uuid,
|
|
%s::uuid,
|
|
now()
|
|
)
|
|
ON CONFLICT (miner_id)
|
|
DO UPDATE SET
|
|
asset_state = 'shelved',
|
|
location_position_id = EXCLUDED.location_position_id,
|
|
updated_by_user_id = EXCLUDED.updated_by_user_id,
|
|
updated_at = now();
|
|
""",
|
|
(
|
|
miner_id,
|
|
location_position_id,
|
|
user_id,
|
|
),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO miner_location_states (
|
|
miner_id,
|
|
asset_state,
|
|
location_position_id,
|
|
updated_by_user_id,
|
|
updated_at
|
|
)
|
|
SELECT
|
|
miners.id,
|
|
'waiting_placement',
|
|
NULL,
|
|
%s::uuid,
|
|
now()
|
|
FROM miners
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM location_positions
|
|
WHERE location_positions.miner_id = miners.id
|
|
)
|
|
ON CONFLICT (miner_id)
|
|
DO UPDATE SET
|
|
asset_state = CASE
|
|
WHEN miner_location_states.asset_state IN ('storage', 'repair', 'decommissioned')
|
|
THEN miner_location_states.asset_state
|
|
ELSE 'waiting_placement'
|
|
END,
|
|
location_position_id = CASE
|
|
WHEN miner_location_states.asset_state IN ('storage', 'repair', 'decommissioned')
|
|
THEN miner_location_states.location_position_id
|
|
ELSE NULL
|
|
END,
|
|
updated_by_user_id = EXCLUDED.updated_by_user_id,
|
|
updated_at = now();
|
|
""",
|
|
(user_id,),
|
|
)
|
|
|
|
counts["miners_marked_waiting_placement"] = cur.rowcount
|
|
|
|
conn.commit()
|
|
|
|
return api_response(
|
|
data={
|
|
"import_batch_id": import_batch_id,
|
|
"counts": counts,
|
|
"duplicate_macs": sorted(list(duplicate_macs)),
|
|
}
|
|
)
|
|
|
|
except Exception:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
|
|
raise
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/location/import-issues")
|
|
def list_location_import_issues(
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
ensure_location_tables()
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
import_batch_id::text,
|
|
issue_type,
|
|
severity,
|
|
message,
|
|
mac_address,
|
|
room_name,
|
|
rack_name,
|
|
shelf_name,
|
|
position_index,
|
|
source_file,
|
|
source_cell,
|
|
created_at,
|
|
resolved_at
|
|
FROM location_import_issues
|
|
WHERE resolved_at IS NULL
|
|
ORDER BY created_at DESC, mac_address, room_name, rack_name, shelf_name, position_index;
|
|
"""
|
|
)
|
|
|
|
rows = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=rows)
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/location/map")
|
|
def get_location_map(
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
ensure_location_tables()
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
WITH latest AS (
|
|
SELECT DISTINCT ON (miner_id)
|
|
miner_id,
|
|
observed_at,
|
|
ROUND((mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths,
|
|
ROUND(power_w::numeric, 2)::float AS power_w,
|
|
ROUND(chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c,
|
|
ROUND(chip_temp_avg_c::numeric, 2)::float AS chip_temp_avg_c,
|
|
hash_stable,
|
|
pool_rejected_percent
|
|
FROM miner_status_snapshots
|
|
ORDER BY miner_id, observed_at DESC
|
|
)
|
|
SELECT
|
|
lp.id::text AS location_position_id,
|
|
lp.room_name,
|
|
lp.rack_name,
|
|
lp.shelf_name,
|
|
lp.shelf_index,
|
|
lp.position_index,
|
|
lp.position_label,
|
|
lp.position_kind,
|
|
lp.position_status,
|
|
lp.expected_mac,
|
|
lp.equipment_label,
|
|
lp.notes,
|
|
lp.source_file,
|
|
lp.source_cell,
|
|
lp.conflict_reason,
|
|
|
|
miners.id::text AS miner_id,
|
|
miners.current_mac,
|
|
host(miners.current_ip) AS current_ip,
|
|
miners.status AS miner_status,
|
|
miners.hostname,
|
|
miners.miner_sn,
|
|
miners.power_sn,
|
|
miners.model,
|
|
miners.miner_type,
|
|
miners.firmware_version,
|
|
miners.last_seen_at,
|
|
|
|
COALESCE(mls.asset_state, 'unassigned') AS asset_state,
|
|
|
|
latest.observed_at,
|
|
latest.hashrate_ths,
|
|
latest.power_w,
|
|
latest.chip_temp_max_c,
|
|
latest.chip_temp_avg_c,
|
|
latest.hash_stable,
|
|
latest.pool_rejected_percent
|
|
FROM location_positions lp
|
|
LEFT JOIN rooms room_order ON lower(room_order.name) = lower(lp.room_name)
|
|
LEFT JOIN miners ON miners.id = lp.miner_id
|
|
LEFT JOIN miner_location_states mls ON mls.miner_id = miners.id
|
|
LEFT JOIN latest ON latest.miner_id = miners.id
|
|
ORDER BY
|
|
COALESCE(room_order.sort_order, 999999),
|
|
lp.room_name,
|
|
lp.rack_name,
|
|
lp.shelf_index,
|
|
lp.position_index;
|
|
"""
|
|
)
|
|
|
|
positions = [dict(row) for row in cur.fetchall()]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
miners.id::text AS miner_id,
|
|
COALESCE(mls.asset_state, 'waiting_placement') AS asset_state,
|
|
miners.current_mac,
|
|
host(miners.current_ip) AS current_ip,
|
|
miners.status,
|
|
miners.hostname,
|
|
miners.miner_sn,
|
|
miners.power_sn,
|
|
miners.model,
|
|
miners.miner_type,
|
|
miners.firmware_version,
|
|
miners.last_seen_at
|
|
FROM miners
|
|
LEFT JOIN miner_location_states mls ON mls.miner_id = miners.id
|
|
WHERE COALESCE(mls.asset_state, 'waiting_placement') IN (
|
|
'waiting_placement',
|
|
'storage',
|
|
'repair',
|
|
'decommissioned'
|
|
)
|
|
ORDER BY asset_state, miners.current_mac;
|
|
"""
|
|
)
|
|
|
|
virtual_miners = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(
|
|
data={
|
|
"positions": positions,
|
|
"virtual_miners": virtual_miners,
|
|
},
|
|
meta={
|
|
"position_count": len(positions),
|
|
"virtual_miner_count": len(virtual_miners),
|
|
},
|
|
)
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
@app.get("/locations", response_class=HTMLResponse)
|
|
def location_map_page():
|
|
return """
|
|
<!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>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
# -----------------------------
|
|
# Phase 3 Step 65: Legacy WhatsMiner / M30S summary fallback
|
|
# -----------------------------
|
|
|
|
class LegacySummaryRefreshRequest(BaseModel):
|
|
miner_id: str | None = None
|
|
ip_address: str | None = None
|
|
limit: int = 50
|
|
timeout_seconds: float = 1.5
|
|
only_missing: bool = True
|
|
|
|
|
|
def legacy_summary_to_float(value):
|
|
if value is None:
|
|
return None
|
|
|
|
try:
|
|
return float(str(value).replace(",", "").strip())
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def legacy_summary_to_int(value):
|
|
float_value = legacy_summary_to_float(value)
|
|
|
|
if float_value is None:
|
|
return None
|
|
|
|
return int(float_value)
|
|
|
|
|
|
def legacy_summary_to_bool(value):
|
|
if value is None:
|
|
return None
|
|
|
|
if isinstance(value, bool):
|
|
return value
|
|
|
|
text = str(value).strip().lower()
|
|
|
|
if text in ("true", "1", "yes", "y", "stable"):
|
|
return True
|
|
|
|
if text in ("false", "0", "no", "n", "unstable"):
|
|
return False
|
|
|
|
return None
|
|
|
|
|
|
def legacy_summary_get(summary: dict, *keys):
|
|
for key in keys:
|
|
if key in summary and summary.get(key) is not None:
|
|
return summary.get(key)
|
|
|
|
return None
|
|
|
|
|
|
def infer_legacy_whatsminer_model(summary: dict):
|
|
factory_ghs = legacy_summary_to_float(
|
|
legacy_summary_get(summary, "Factory GHS", "FactoryGHS", "factory_ghs")
|
|
)
|
|
|
|
target_mhs = legacy_summary_to_float(
|
|
legacy_summary_get(summary, "Target MHS", "TargetMHS", "target_mhs")
|
|
)
|
|
|
|
estimate_ghs = factory_ghs
|
|
|
|
if estimate_ghs is None and target_mhs is not None:
|
|
estimate_ghs = target_mhs / 1000.0
|
|
|
|
if estimate_ghs is None:
|
|
return "WhatsMiner Legacy"
|
|
|
|
if 70000 <= estimate_ghs <= 115000:
|
|
return "M30S+"
|
|
|
|
if 115000 < estimate_ghs <= 140000:
|
|
return "M30S++"
|
|
|
|
if 140000 < estimate_ghs <= 180000:
|
|
return "M50S++"
|
|
|
|
return "WhatsMiner Legacy"
|
|
|
|
|
|
def read_legacy_whatsminer_summary(ip_address: str, port: int = 4028, timeout_seconds: float = 1.5):
|
|
import json
|
|
import socket
|
|
|
|
payloads = [
|
|
b'{"command":"summary"}\n',
|
|
b'{"command":"summary"}',
|
|
]
|
|
|
|
last_error = None
|
|
|
|
for payload in payloads:
|
|
try:
|
|
chunks = []
|
|
|
|
with socket.create_connection((ip_address, port), timeout=timeout_seconds) as sock:
|
|
sock.settimeout(timeout_seconds)
|
|
sock.sendall(payload)
|
|
|
|
while True:
|
|
try:
|
|
chunk = sock.recv(4096)
|
|
except socket.timeout:
|
|
break
|
|
|
|
if not chunk:
|
|
break
|
|
|
|
chunks.append(chunk)
|
|
|
|
if not chunks:
|
|
continue
|
|
|
|
response_text = b"".join(chunks).decode("utf-8", errors="replace").strip()
|
|
parsed = json.loads(response_text)
|
|
|
|
summary_list = parsed.get("SUMMARY") or parsed.get("summary") or []
|
|
|
|
if not summary_list:
|
|
return {
|
|
"success": False,
|
|
"error": "No SUMMARY section returned",
|
|
"response_text": response_text,
|
|
}
|
|
|
|
return {
|
|
"success": True,
|
|
"summary": summary_list[0],
|
|
"response_text": response_text,
|
|
"parsed_json": parsed,
|
|
}
|
|
|
|
except Exception as error:
|
|
last_error = str(error)
|
|
|
|
return {
|
|
"success": False,
|
|
"error": last_error or "No response",
|
|
}
|
|
|
|
|
|
def miner_needs_legacy_summary_patch(miner_id: str):
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
WITH latest AS (
|
|
SELECT DISTINCT ON (miner_id)
|
|
miner_id,
|
|
mhs_av,
|
|
chip_temp_max_c,
|
|
chip_temp_avg_c,
|
|
observed_at
|
|
FROM miner_status_snapshots
|
|
WHERE miner_id = %s::uuid
|
|
ORDER BY miner_id, observed_at DESC
|
|
)
|
|
SELECT
|
|
miners.id::text,
|
|
miners.model,
|
|
miners.miner_type,
|
|
latest.mhs_av,
|
|
latest.chip_temp_max_c,
|
|
latest.chip_temp_avg_c
|
|
FROM miners
|
|
LEFT JOIN latest ON latest.miner_id = miners.id
|
|
WHERE miners.id = %s::uuid;
|
|
""",
|
|
(miner_id, miner_id),
|
|
)
|
|
|
|
row = cur.fetchone()
|
|
|
|
if not row:
|
|
return False
|
|
|
|
model_missing = row.get("model") is None or str(row.get("model")).strip() == ""
|
|
miner_type_missing = row.get("miner_type") is None or str(row.get("miner_type")).strip() == ""
|
|
hashrate_missing = row.get("mhs_av") is None
|
|
temp_missing = row.get("chip_temp_max_c") is None and row.get("chip_temp_avg_c") is None
|
|
|
|
return model_missing or miner_type_missing or hashrate_missing or temp_missing
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
def apply_legacy_summary_to_miner(miner_id: str, ip_address: str, timeout_seconds: float = 1.5):
|
|
read_result = read_legacy_whatsminer_summary(ip_address, 4028, timeout_seconds)
|
|
|
|
if not read_result.get("success"):
|
|
return {
|
|
"success": False,
|
|
"miner_id": miner_id,
|
|
"ip_address": ip_address,
|
|
"error": read_result.get("error"),
|
|
}
|
|
|
|
summary = read_result.get("summary") or {}
|
|
|
|
mhs_av = legacy_summary_to_float(
|
|
legacy_summary_get(summary, "MHS av", "MHS Av", "mhs_av", "MHS 5s", "HS RT")
|
|
)
|
|
|
|
power_w = legacy_summary_to_float(
|
|
legacy_summary_get(summary, "Power", "power", "Power W")
|
|
)
|
|
|
|
power_rate = legacy_summary_to_float(
|
|
legacy_summary_get(summary, "Power Rate", "PowerRate", "power_rate")
|
|
)
|
|
|
|
chip_temp_max = legacy_summary_to_float(
|
|
legacy_summary_get(summary, "Chip Temp Max", "ChipTempMax", "chip_temp_max", "Temperature")
|
|
)
|
|
|
|
chip_temp_avg = legacy_summary_to_float(
|
|
legacy_summary_get(summary, "Chip Temp Avg", "ChipTempAvg", "chip_temp_avg", "Temperature")
|
|
)
|
|
|
|
fan_in = legacy_summary_to_int(
|
|
legacy_summary_get(summary, "Fan Speed In", "FanSpeedIn", "fan_speed_in")
|
|
)
|
|
|
|
fan_out = legacy_summary_to_int(
|
|
legacy_summary_get(summary, "Fan Speed Out", "FanSpeedOut", "fan_speed_out")
|
|
)
|
|
|
|
hash_stable = legacy_summary_to_bool(
|
|
legacy_summary_get(summary, "Hash Stable", "HashStable", "hash_stable")
|
|
)
|
|
|
|
pool_rejected_percent = legacy_summary_to_float(
|
|
legacy_summary_get(summary, "Pool Rejected%", "Pool Rejected", "pool_rejected_percent")
|
|
)
|
|
|
|
inferred_model = infer_legacy_whatsminer_model(summary)
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
UPDATE miners
|
|
SET
|
|
status = 'online',
|
|
last_seen_at = now(),
|
|
current_ip = COALESCE(NULLIF(%s, '')::inet, current_ip),
|
|
model = COALESCE(NULLIF(model, ''), %s),
|
|
miner_type = COALESCE(NULLIF(miner_type, ''), %s)
|
|
WHERE id = %s::uuid
|
|
RETURNING
|
|
id::text,
|
|
current_mac,
|
|
host(current_ip) AS current_ip,
|
|
model,
|
|
miner_type;
|
|
""",
|
|
(
|
|
ip_address,
|
|
inferred_model,
|
|
inferred_model,
|
|
miner_id,
|
|
),
|
|
)
|
|
|
|
miner_row = cur.fetchone()
|
|
|
|
if not miner_row:
|
|
raise HTTPException(status_code=404, detail="Miner not found")
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT column_name
|
|
FROM information_schema.columns
|
|
WHERE table_name = 'miner_status_snapshots';
|
|
"""
|
|
)
|
|
|
|
snapshot_columns = {row["column_name"] for row in cur.fetchall()}
|
|
|
|
insert_columns = []
|
|
insert_values_sql = []
|
|
values = []
|
|
|
|
def add_value(column_name, value, sql_cast=None):
|
|
if column_name not in snapshot_columns:
|
|
return
|
|
|
|
insert_columns.append(column_name)
|
|
|
|
if sql_cast:
|
|
insert_values_sql.append(f"%s::{sql_cast}")
|
|
else:
|
|
insert_values_sql.append("%s")
|
|
|
|
values.append(value)
|
|
|
|
def add_now(column_name):
|
|
if column_name not in snapshot_columns:
|
|
return
|
|
|
|
insert_columns.append(column_name)
|
|
insert_values_sql.append("now()")
|
|
|
|
add_value("id", str(uuid4()), "uuid")
|
|
add_value("miner_id", miner_id, "uuid")
|
|
add_now("observed_at")
|
|
add_value("observed_ip", ip_address, "inet")
|
|
add_value("mhs_av", mhs_av)
|
|
add_value("power_w", power_w)
|
|
add_value("power_rate_w_per_th", power_rate)
|
|
add_value("chip_temp_max_c", chip_temp_max)
|
|
add_value("chip_temp_avg_c", chip_temp_avg)
|
|
add_value("fan_speed_in_rpm", fan_in)
|
|
add_value("fan_speed_out_rpm", fan_out)
|
|
add_value("hash_stable", hash_stable)
|
|
add_value("pool_rejected_percent", pool_rejected_percent)
|
|
add_now("created_at")
|
|
|
|
if "raw_summary_json" in snapshot_columns:
|
|
import json
|
|
add_value("raw_summary_json", json.dumps(summary))
|
|
|
|
if len(insert_columns) > 0:
|
|
sql = f"""
|
|
INSERT INTO miner_status_snapshots (
|
|
{", ".join(insert_columns)}
|
|
)
|
|
VALUES (
|
|
{", ".join(insert_values_sql)}
|
|
);
|
|
"""
|
|
|
|
cur.execute(sql, values)
|
|
|
|
conn.commit()
|
|
|
|
return {
|
|
"success": True,
|
|
"miner_id": miner_id,
|
|
"ip_address": ip_address,
|
|
"inferred_model": inferred_model,
|
|
"mhs_av": mhs_av,
|
|
"hashrate_ths": None if mhs_av is None else round(mhs_av / 1000000.0, 2),
|
|
"power_w": power_w,
|
|
"chip_temp_max_c": chip_temp_max,
|
|
"chip_temp_avg_c": chip_temp_avg,
|
|
"hash_stable": hash_stable,
|
|
"pool_rejected_percent": pool_rejected_percent,
|
|
}
|
|
|
|
except Exception:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
|
|
raise
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/tools/legacy-summary-refresh-one")
|
|
def legacy_summary_refresh_one(
|
|
request: LegacySummaryRefreshRequest,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
if not request.miner_id and not request.ip_address:
|
|
raise HTTPException(status_code=400, detail="Provide miner_id or ip_address")
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
if request.miner_id:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text, host(current_ip) AS current_ip
|
|
FROM miners
|
|
WHERE id = %s::uuid;
|
|
""",
|
|
(request.miner_id,),
|
|
)
|
|
else:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text, host(current_ip) AS current_ip
|
|
FROM miners
|
|
WHERE current_ip = %s::inet
|
|
LIMIT 1;
|
|
""",
|
|
(request.ip_address,),
|
|
)
|
|
|
|
miner = cur.fetchone()
|
|
|
|
if not miner:
|
|
raise HTTPException(status_code=404, detail="Miner not found")
|
|
|
|
miner_id = miner["id"]
|
|
ip_address = request.ip_address or miner["current_ip"]
|
|
|
|
if not ip_address:
|
|
raise HTTPException(status_code=400, detail="Miner has no current IP address")
|
|
|
|
result = apply_legacy_summary_to_miner(
|
|
miner_id,
|
|
ip_address,
|
|
request.timeout_seconds,
|
|
)
|
|
|
|
return api_response(data=result)
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/tools/legacy-summary-refresh-missing")
|
|
def legacy_summary_refresh_missing(
|
|
request: LegacySummaryRefreshRequest,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
safe_limit = max(1, min(int(request.limit or 50), 300))
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
WITH latest AS (
|
|
SELECT DISTINCT ON (miner_id)
|
|
miner_id,
|
|
mhs_av,
|
|
chip_temp_max_c,
|
|
chip_temp_avg_c,
|
|
observed_at
|
|
FROM miner_status_snapshots
|
|
ORDER BY miner_id, observed_at DESC
|
|
)
|
|
SELECT
|
|
miners.id::text,
|
|
host(miners.current_ip) AS current_ip,
|
|
miners.current_mac,
|
|
miners.model,
|
|
miners.miner_type,
|
|
latest.mhs_av,
|
|
latest.chip_temp_max_c,
|
|
latest.chip_temp_avg_c
|
|
FROM miners
|
|
LEFT JOIN latest ON latest.miner_id = miners.id
|
|
WHERE miners.current_ip IS NOT NULL
|
|
AND (
|
|
miners.model IS NULL
|
|
OR trim(miners.model) = ''
|
|
OR miners.miner_type IS NULL
|
|
OR trim(miners.miner_type) = ''
|
|
OR latest.mhs_av IS NULL
|
|
OR latest.chip_temp_max_c IS NULL
|
|
)
|
|
ORDER BY miners.current_ip
|
|
LIMIT %s;
|
|
""",
|
|
(safe_limit,),
|
|
)
|
|
|
|
miners = [dict(row) for row in cur.fetchall()]
|
|
|
|
results = []
|
|
|
|
for miner in miners:
|
|
result = apply_legacy_summary_to_miner(
|
|
miner["id"],
|
|
miner["current_ip"],
|
|
request.timeout_seconds,
|
|
)
|
|
|
|
results.append(result)
|
|
|
|
return api_response(
|
|
data=results,
|
|
meta={
|
|
"attempted": len(results),
|
|
"success": len([row for row in results if row.get("success")]),
|
|
"failed": len([row for row in results if not row.get("success")]),
|
|
},
|
|
)
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
def legacy_find_miner_id_by_ip(ip_address: str):
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text
|
|
FROM miners
|
|
WHERE current_ip = %s::inet
|
|
LIMIT 1;
|
|
""",
|
|
(ip_address,),
|
|
)
|
|
|
|
row = cur.fetchone()
|
|
|
|
if not row:
|
|
return None
|
|
|
|
return row["id"]
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
try:
|
|
_minerops_original_discover_one_miner_legacy_summary = discover_one_miner
|
|
|
|
def discover_one_miner(request, current_user: dict = Depends(get_current_user)):
|
|
result = _minerops_original_discover_one_miner_legacy_summary(request, current_user)
|
|
|
|
try:
|
|
ip_address = getattr(request, "ip_address", None) or getattr(request, "ip", None)
|
|
|
|
if ip_address:
|
|
miner_id = legacy_find_miner_id_by_ip(ip_address)
|
|
|
|
if miner_id and miner_needs_legacy_summary_patch(miner_id):
|
|
apply_legacy_summary_to_miner(miner_id, ip_address, 1.5)
|
|
|
|
except Exception as legacy_error:
|
|
print(f"Legacy summary fallback failed: {legacy_error}")
|
|
|
|
return result
|
|
|
|
except NameError:
|
|
pass
|
|
|
|
# -----------------------------
|
|
# Phase 3 Step 66: Room / Unit ordering
|
|
# -----------------------------
|
|
|
|
class LocationRoomReorderRequest(BaseModel):
|
|
room_ids: list[str]
|
|
|
|
|
|
def ensure_room_order_ready():
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
ALTER TABLE rooms
|
|
ADD COLUMN IF NOT EXISTS sort_order integer;
|
|
"""
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
WITH ordered AS (
|
|
SELECT
|
|
id,
|
|
((row_number() OVER (ORDER BY sort_order NULLS LAST, name)) - 1) * 10 AS new_sort_order
|
|
FROM rooms
|
|
)
|
|
UPDATE rooms
|
|
SET sort_order = ordered.new_sort_order
|
|
FROM ordered
|
|
WHERE rooms.id = ordered.id
|
|
AND rooms.sort_order IS NULL;
|
|
"""
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/location/rooms-order")
|
|
def get_location_rooms_order(current_user: dict = Depends(get_current_user)):
|
|
ensure_location_tables()
|
|
ensure_room_order_ready()
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
rooms.id::text,
|
|
rooms.name,
|
|
COALESCE(rooms.sort_order, 999999) AS sort_order,
|
|
COUNT(location_positions.id)::int AS position_count,
|
|
COUNT(location_positions.id) FILTER (
|
|
WHERE location_positions.position_kind = 'miner_slot'
|
|
)::int AS miner_slot_count,
|
|
COUNT(location_positions.id) FILTER (
|
|
WHERE location_positions.position_kind = 'switch'
|
|
)::int AS switch_count,
|
|
COUNT(location_positions.id) FILTER (
|
|
WHERE location_positions.position_status = 'blocked'
|
|
OR location_positions.position_kind = 'blocked'
|
|
)::int AS blocked_count,
|
|
COUNT(location_positions.id) FILTER (
|
|
WHERE location_positions.miner_id IS NOT NULL
|
|
)::int AS assigned_miner_count
|
|
FROM rooms
|
|
LEFT JOIN location_positions
|
|
ON lower(location_positions.room_name) = lower(rooms.name)
|
|
GROUP BY rooms.id, rooms.name, rooms.sort_order
|
|
ORDER BY COALESCE(rooms.sort_order, 999999), rooms.name;
|
|
"""
|
|
)
|
|
|
|
rows = [dict(row) for row in cur.fetchall()]
|
|
|
|
return api_response(data=rows)
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/location/rooms/reorder")
|
|
def reorder_location_rooms(
|
|
request: LocationRoomReorderRequest,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
ensure_location_tables()
|
|
ensure_room_order_ready()
|
|
|
|
if not request.room_ids:
|
|
raise HTTPException(status_code=400, detail="No room IDs supplied")
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
for index, room_id in enumerate(request.room_ids):
|
|
cur.execute(
|
|
"""
|
|
UPDATE rooms
|
|
SET sort_order = %s
|
|
WHERE id = %s::uuid;
|
|
""",
|
|
(
|
|
index * 10,
|
|
room_id,
|
|
),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
name,
|
|
sort_order
|
|
FROM rooms
|
|
ORDER BY sort_order, name;
|
|
"""
|
|
)
|
|
|
|
rows = [dict(row) for row in cur.fetchall()]
|
|
|
|
conn.commit()
|
|
|
|
return api_response(data=rows)
|
|
|
|
except Exception:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
|
|
raise
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
# -----------------------------
|
|
# Phase 3 Step 67: Location management GUI APIs
|
|
# -----------------------------
|
|
|
|
class LocationRoomCreateRequest(BaseModel):
|
|
name: str
|
|
|
|
|
|
class LocationRackCreateRequest(BaseModel):
|
|
room_name: str
|
|
rack_name: str
|
|
shelf_count: int = 5
|
|
positions_per_shelf: int = 9
|
|
switch_shelf_index: int | None = None
|
|
switch_position_index: int | None = None
|
|
|
|
|
|
class LocationShelfCreateRequest(BaseModel):
|
|
room_name: str
|
|
rack_name: str
|
|
shelf_name: str | None = None
|
|
shelf_index: int | None = None
|
|
positions_per_shelf: int = 9
|
|
|
|
|
|
class LocationPositionTypeRequest(BaseModel):
|
|
room_name: str
|
|
rack_name: str
|
|
shelf_name: str
|
|
position_index: int
|
|
position_kind: str
|
|
equipment_label: str | None = None
|
|
notes: str | None = None
|
|
|
|
|
|
def location_get_table_columns(cur, table_name: str):
|
|
cur.execute(
|
|
"""
|
|
SELECT column_name
|
|
FROM information_schema.columns
|
|
WHERE table_name = %s;
|
|
""",
|
|
(table_name,),
|
|
)
|
|
|
|
return {row["column_name"] if isinstance(row, dict) else row[0] for row in cur.fetchall()}
|
|
|
|
|
|
def find_or_create_location_room(cur, room_name: str):
|
|
clean_name = str(room_name or "").strip()
|
|
|
|
if not clean_name:
|
|
raise HTTPException(status_code=400, detail="Room name is required")
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text, name
|
|
FROM rooms
|
|
WHERE lower(name) = lower(%s)
|
|
LIMIT 1;
|
|
""",
|
|
(clean_name,),
|
|
)
|
|
|
|
existing = cur.fetchone()
|
|
|
|
if existing:
|
|
return existing["id"], existing["name"], False
|
|
|
|
room_columns = location_get_table_columns(cur, "rooms")
|
|
|
|
insert_columns = []
|
|
insert_values_sql = []
|
|
values = []
|
|
|
|
def add_value(column_name, value, sql_cast=None):
|
|
if column_name not in room_columns:
|
|
return
|
|
|
|
insert_columns.append(column_name)
|
|
|
|
if sql_cast:
|
|
insert_values_sql.append(f"%s::{sql_cast}")
|
|
else:
|
|
insert_values_sql.append("%s")
|
|
|
|
values.append(value)
|
|
|
|
def add_now(column_name):
|
|
if column_name not in room_columns:
|
|
return
|
|
|
|
insert_columns.append(column_name)
|
|
insert_values_sql.append("now()")
|
|
|
|
room_id = str(uuid4())
|
|
|
|
add_value("id", room_id, "uuid")
|
|
add_value("name", clean_name)
|
|
|
|
if "site_id" in room_columns:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text
|
|
FROM sites
|
|
ORDER BY name
|
|
LIMIT 1;
|
|
"""
|
|
)
|
|
|
|
site = cur.fetchone()
|
|
|
|
if not site:
|
|
raise HTTPException(status_code=400, detail="No site exists to attach the room to")
|
|
|
|
site_id = site["id"] if isinstance(site, dict) else site[0]
|
|
add_value("site_id", site_id, "uuid")
|
|
|
|
if "sort_order" in room_columns:
|
|
cur.execute(
|
|
"""
|
|
SELECT COALESCE(MAX(sort_order), -10) + 10
|
|
FROM rooms;
|
|
"""
|
|
)
|
|
|
|
next_sort_order = cur.fetchone()[0]
|
|
add_value("sort_order", next_sort_order)
|
|
|
|
add_value("is_enabled", True)
|
|
add_value("enabled", True)
|
|
add_now("created_at")
|
|
add_now("updated_at")
|
|
|
|
sql = f"""
|
|
INSERT INTO rooms ({", ".join(insert_columns)})
|
|
VALUES ({", ".join(insert_values_sql)})
|
|
RETURNING id::text, name;
|
|
"""
|
|
|
|
cur.execute(sql, values)
|
|
created = cur.fetchone()
|
|
|
|
return created["id"], created["name"], True
|
|
|
|
|
|
def make_default_shelf_name(shelf_index: int):
|
|
if int(shelf_index) == 1:
|
|
return "shelf1(top)"
|
|
|
|
return f"shelf{int(shelf_index)}"
|
|
|
|
|
|
def create_location_slot(
|
|
cur,
|
|
room_id,
|
|
room_name,
|
|
rack_name,
|
|
shelf_name,
|
|
shelf_index,
|
|
position_index,
|
|
position_kind="miner_slot",
|
|
position_status="available",
|
|
equipment_label=None,
|
|
notes=None,
|
|
):
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO location_positions (
|
|
id,
|
|
room_id,
|
|
room_name,
|
|
rack_name,
|
|
shelf_name,
|
|
shelf_index,
|
|
position_index,
|
|
position_label,
|
|
position_kind,
|
|
position_status,
|
|
equipment_label,
|
|
notes,
|
|
source_file,
|
|
raw_value,
|
|
updated_at
|
|
)
|
|
VALUES (
|
|
%s::uuid,
|
|
%s::uuid,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
'manual_gui',
|
|
%s,
|
|
now()
|
|
)
|
|
ON CONFLICT (room_name, rack_name, shelf_name, position_index)
|
|
DO NOTHING
|
|
RETURNING id::text;
|
|
""",
|
|
(
|
|
str(uuid4()),
|
|
room_id,
|
|
room_name,
|
|
rack_name,
|
|
shelf_name,
|
|
shelf_index,
|
|
position_index,
|
|
f"Pos {position_index}",
|
|
position_kind,
|
|
position_status,
|
|
equipment_label,
|
|
notes,
|
|
equipment_label or notes,
|
|
),
|
|
)
|
|
|
|
row = cur.fetchone()
|
|
|
|
return row["id"] if row else None
|
|
|
|
|
|
@app.post("/api/location/rooms/create")
|
|
def create_location_room(
|
|
request: LocationRoomCreateRequest,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
ensure_location_tables()
|
|
ensure_room_order_ready()
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
room_id, room_name, was_created = find_or_create_location_room(cur, request.name)
|
|
|
|
conn.commit()
|
|
|
|
return api_response(
|
|
data={
|
|
"id": room_id,
|
|
"name": room_name,
|
|
"created": was_created,
|
|
}
|
|
)
|
|
|
|
except Exception:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
|
|
raise
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/location/racks/create")
|
|
def create_location_rack(
|
|
request: LocationRackCreateRequest,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
ensure_location_tables()
|
|
ensure_room_order_ready()
|
|
|
|
room_name = str(request.room_name or "").strip()
|
|
rack_name = str(request.rack_name or "").strip()
|
|
|
|
if not room_name:
|
|
raise HTTPException(status_code=400, detail="Room name is required")
|
|
|
|
if not rack_name:
|
|
raise HTTPException(status_code=400, detail="Rack name is required")
|
|
|
|
shelf_count = max(1, min(int(request.shelf_count or 1), 20))
|
|
positions_per_shelf = max(1, min(int(request.positions_per_shelf or 1), 50))
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
created_positions = 0
|
|
skipped_positions = 0
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
room_id, room_name, room_created = find_or_create_location_room(cur, room_name)
|
|
|
|
for shelf_index in range(1, shelf_count + 1):
|
|
shelf_name = make_default_shelf_name(shelf_index)
|
|
|
|
for position_index in range(1, positions_per_shelf + 1):
|
|
position_kind = "miner_slot"
|
|
position_status = "available"
|
|
equipment_label = None
|
|
notes = None
|
|
|
|
if (
|
|
request.switch_shelf_index is not None
|
|
and request.switch_position_index is not None
|
|
and int(request.switch_shelf_index) == shelf_index
|
|
and int(request.switch_position_index) == position_index
|
|
):
|
|
position_kind = "switch"
|
|
position_status = "switch"
|
|
equipment_label = f"Switch {rack_name}"
|
|
notes = "Created from location management GUI."
|
|
|
|
position_id = create_location_slot(
|
|
cur,
|
|
room_id,
|
|
room_name,
|
|
rack_name,
|
|
shelf_name,
|
|
shelf_index,
|
|
position_index,
|
|
position_kind,
|
|
position_status,
|
|
equipment_label,
|
|
notes,
|
|
)
|
|
|
|
if position_id:
|
|
created_positions += 1
|
|
else:
|
|
skipped_positions += 1
|
|
|
|
conn.commit()
|
|
|
|
return api_response(
|
|
data={
|
|
"room_name": room_name,
|
|
"rack_name": rack_name,
|
|
"shelf_count": shelf_count,
|
|
"positions_per_shelf": positions_per_shelf,
|
|
"created_positions": created_positions,
|
|
"skipped_existing_positions": skipped_positions,
|
|
}
|
|
)
|
|
|
|
except Exception:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
|
|
raise
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/location/shelves/create")
|
|
def create_location_shelf(
|
|
request: LocationShelfCreateRequest,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
ensure_location_tables()
|
|
ensure_room_order_ready()
|
|
|
|
room_name = str(request.room_name or "").strip()
|
|
rack_name = str(request.rack_name or "").strip()
|
|
|
|
if not room_name:
|
|
raise HTTPException(status_code=400, detail="Room name is required")
|
|
|
|
if not rack_name:
|
|
raise HTTPException(status_code=400, detail="Rack name is required")
|
|
|
|
positions_per_shelf = max(1, min(int(request.positions_per_shelf or 1), 50))
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
created_positions = 0
|
|
skipped_positions = 0
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
room_id, room_name, room_created = find_or_create_location_room(cur, room_name)
|
|
|
|
if request.shelf_index is None:
|
|
cur.execute(
|
|
"""
|
|
SELECT COALESCE(MAX(shelf_index), 0) + 1 AS next_shelf_index
|
|
FROM location_positions
|
|
WHERE lower(room_name) = lower(%s)
|
|
AND lower(rack_name) = lower(%s);
|
|
""",
|
|
(
|
|
room_name,
|
|
rack_name,
|
|
),
|
|
)
|
|
|
|
shelf_index = int(cur.fetchone()["next_shelf_index"])
|
|
else:
|
|
shelf_index = max(1, int(request.shelf_index))
|
|
|
|
shelf_name = str(request.shelf_name or "").strip()
|
|
|
|
if not shelf_name:
|
|
shelf_name = make_default_shelf_name(shelf_index)
|
|
|
|
for position_index in range(1, positions_per_shelf + 1):
|
|
position_id = create_location_slot(
|
|
cur,
|
|
room_id,
|
|
room_name,
|
|
rack_name,
|
|
shelf_name,
|
|
shelf_index,
|
|
position_index,
|
|
"miner_slot",
|
|
"available",
|
|
None,
|
|
None,
|
|
)
|
|
|
|
if position_id:
|
|
created_positions += 1
|
|
else:
|
|
skipped_positions += 1
|
|
|
|
conn.commit()
|
|
|
|
return api_response(
|
|
data={
|
|
"room_name": room_name,
|
|
"rack_name": rack_name,
|
|
"shelf_name": shelf_name,
|
|
"shelf_index": shelf_index,
|
|
"positions_per_shelf": positions_per_shelf,
|
|
"created_positions": created_positions,
|
|
"skipped_existing_positions": skipped_positions,
|
|
}
|
|
)
|
|
|
|
except Exception:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
|
|
raise
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/location/positions/set-type")
|
|
def set_location_position_type(
|
|
request: LocationPositionTypeRequest,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
ensure_location_tables()
|
|
|
|
room_name = str(request.room_name or "").strip()
|
|
rack_name = str(request.rack_name or "").strip()
|
|
shelf_name = str(request.shelf_name or "").strip()
|
|
position_index = int(request.position_index or 0)
|
|
position_kind = str(request.position_kind or "").strip().lower()
|
|
|
|
if position_kind not in ("miner_slot", "switch", "blocked"):
|
|
raise HTTPException(status_code=400, detail="position_kind must be miner_slot, switch, or blocked")
|
|
|
|
if not room_name or not rack_name or not shelf_name or position_index <= 0:
|
|
raise HTTPException(status_code=400, detail="Room, rack, shelf, and position are required")
|
|
|
|
if position_kind == "switch":
|
|
position_status = "switch"
|
|
equipment_label = request.equipment_label or f"Switch {rack_name}"
|
|
notes = request.notes or "Switch position."
|
|
|
|
elif position_kind == "blocked":
|
|
position_status = "blocked"
|
|
equipment_label = request.equipment_label
|
|
notes = request.notes or "Blocked/bad spot."
|
|
|
|
else:
|
|
equipment_label = request.equipment_label
|
|
notes = request.notes
|
|
|
|
conn_check = None
|
|
|
|
try:
|
|
conn_check = get_db_connection()
|
|
|
|
with conn_check.cursor(row_factory=psycopg.rows.dict_row) as check_cur:
|
|
check_cur.execute(
|
|
"""
|
|
SELECT miner_id
|
|
FROM location_positions
|
|
WHERE lower(room_name) = lower(%s)
|
|
AND lower(rack_name) = lower(%s)
|
|
AND lower(shelf_name) = lower(%s)
|
|
AND position_index = %s
|
|
LIMIT 1;
|
|
""",
|
|
(
|
|
room_name,
|
|
rack_name,
|
|
shelf_name,
|
|
position_index,
|
|
),
|
|
)
|
|
|
|
existing_position = check_cur.fetchone()
|
|
|
|
if existing_position and existing_position.get("miner_id"):
|
|
position_status = "occupied"
|
|
else:
|
|
position_status = "available"
|
|
|
|
finally:
|
|
if conn_check is not None:
|
|
conn_check.close()
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
UPDATE location_positions
|
|
SET
|
|
position_kind = %s,
|
|
position_status = %s,
|
|
equipment_label = %s,
|
|
notes = %s,
|
|
updated_at = now()
|
|
WHERE lower(room_name) = lower(%s)
|
|
AND lower(rack_name) = lower(%s)
|
|
AND lower(shelf_name) = lower(%s)
|
|
AND position_index = %s
|
|
RETURNING
|
|
id::text,
|
|
room_name,
|
|
rack_name,
|
|
shelf_name,
|
|
position_index,
|
|
position_kind,
|
|
position_status,
|
|
equipment_label,
|
|
notes;
|
|
""",
|
|
(
|
|
position_kind,
|
|
position_status,
|
|
equipment_label,
|
|
notes,
|
|
room_name,
|
|
rack_name,
|
|
shelf_name,
|
|
position_index,
|
|
),
|
|
)
|
|
|
|
row = cur.fetchone()
|
|
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Location position not found")
|
|
|
|
row = dict(row)
|
|
|
|
conn.commit()
|
|
|
|
return api_response(data=row)
|
|
|
|
except Exception:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
|
|
raise
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
# -----------------------------
|
|
# Phase 3 Step 67.1: Safe location delete APIs
|
|
# -----------------------------
|
|
|
|
class LocationRackDeleteRequest(BaseModel):
|
|
room_name: str
|
|
rack_name: str
|
|
allow_delete_occupied: bool = False
|
|
|
|
|
|
class LocationRoomDeleteRequest(BaseModel):
|
|
room_name: str
|
|
allow_delete_occupied: bool = False
|
|
delete_room_record: bool = True
|
|
|
|
|
|
@app.post("/api/location/racks/delete")
|
|
def delete_location_rack(
|
|
request: LocationRackDeleteRequest,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
ensure_location_tables()
|
|
|
|
room_name = str(request.room_name or "").strip()
|
|
rack_name = str(request.rack_name or "").strip()
|
|
|
|
if not room_name or not rack_name:
|
|
raise HTTPException(status_code=400, detail="Room name and rack name are required")
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
COUNT(*)::int AS position_count,
|
|
COUNT(*) FILTER (WHERE miner_id IS NOT NULL)::int AS assigned_miner_count
|
|
FROM location_positions
|
|
WHERE lower(room_name) = lower(%s)
|
|
AND lower(rack_name) = lower(%s);
|
|
""",
|
|
(
|
|
room_name,
|
|
rack_name,
|
|
),
|
|
)
|
|
|
|
counts = dict(cur.fetchone())
|
|
|
|
if counts["position_count"] == 0:
|
|
raise HTTPException(status_code=404, detail="Rack not found")
|
|
|
|
if counts["assigned_miner_count"] > 0 and not request.allow_delete_occupied:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Rack has assigned miners. Move miners first before deleting the rack.",
|
|
)
|
|
|
|
if request.allow_delete_occupied:
|
|
cur.execute(
|
|
"""
|
|
UPDATE miner_location_states
|
|
SET
|
|
asset_state = 'waiting_placement',
|
|
location_position_id = NULL,
|
|
updated_at = now()
|
|
WHERE location_position_id IN (
|
|
SELECT id
|
|
FROM location_positions
|
|
WHERE lower(room_name) = lower(%s)
|
|
AND lower(rack_name) = lower(%s)
|
|
);
|
|
""",
|
|
(
|
|
room_name,
|
|
rack_name,
|
|
),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
DELETE FROM location_positions
|
|
WHERE lower(room_name) = lower(%s)
|
|
AND lower(rack_name) = lower(%s);
|
|
""",
|
|
(
|
|
room_name,
|
|
rack_name,
|
|
),
|
|
)
|
|
|
|
deleted_positions = cur.rowcount
|
|
|
|
conn.commit()
|
|
|
|
return api_response(
|
|
data={
|
|
"room_name": room_name,
|
|
"rack_name": rack_name,
|
|
"deleted_positions": deleted_positions,
|
|
"assigned_miner_count": counts["assigned_miner_count"],
|
|
}
|
|
)
|
|
|
|
except Exception:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
|
|
raise
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/location/rooms/delete")
|
|
def delete_location_room(
|
|
request: LocationRoomDeleteRequest,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
ensure_location_tables()
|
|
|
|
room_name = str(request.room_name or "").strip()
|
|
|
|
if not room_name:
|
|
raise HTTPException(status_code=400, detail="Room name is required")
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
room_deleted = False
|
|
room_delete_reason = None
|
|
room_id = None
|
|
miner_reference_count = 0
|
|
scan_range_reference_count = 0
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
COUNT(*)::int AS position_count,
|
|
COUNT(*) FILTER (WHERE miner_id IS NOT NULL)::int AS assigned_miner_count
|
|
FROM location_positions
|
|
WHERE lower(room_name) = lower(%s);
|
|
""",
|
|
(room_name,),
|
|
)
|
|
|
|
counts = dict(cur.fetchone())
|
|
|
|
if counts["position_count"] == 0:
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text
|
|
FROM rooms
|
|
WHERE lower(name) = lower(%s)
|
|
LIMIT 1;
|
|
""",
|
|
(room_name,),
|
|
)
|
|
|
|
existing_room = cur.fetchone()
|
|
|
|
if not existing_room:
|
|
raise HTTPException(status_code=404, detail="Room not found")
|
|
|
|
if counts["assigned_miner_count"] > 0 and not request.allow_delete_occupied:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Room has assigned miners. Move miners first before deleting the room map.",
|
|
)
|
|
|
|
if request.allow_delete_occupied:
|
|
cur.execute(
|
|
"""
|
|
UPDATE miner_location_states
|
|
SET
|
|
asset_state = 'waiting_placement',
|
|
location_position_id = NULL,
|
|
updated_at = now()
|
|
WHERE location_position_id IN (
|
|
SELECT id
|
|
FROM location_positions
|
|
WHERE lower(room_name) = lower(%s)
|
|
);
|
|
""",
|
|
(room_name,),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
DELETE FROM location_positions
|
|
WHERE lower(room_name) = lower(%s);
|
|
""",
|
|
(room_name,),
|
|
)
|
|
|
|
deleted_positions = cur.rowcount
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text
|
|
FROM rooms
|
|
WHERE lower(name) = lower(%s)
|
|
LIMIT 1;
|
|
""",
|
|
(room_name,),
|
|
)
|
|
|
|
room = cur.fetchone()
|
|
|
|
if room:
|
|
room_id = room["id"]
|
|
|
|
if request.delete_room_record and room_id:
|
|
cur.execute(
|
|
"""
|
|
SELECT COUNT(*)::int AS count
|
|
FROM miners
|
|
WHERE room_id = %s::uuid;
|
|
""",
|
|
(room_id,),
|
|
)
|
|
|
|
miner_reference_count = cur.fetchone()["count"]
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM information_schema.columns
|
|
WHERE table_name = 'scan_ranges'
|
|
AND column_name = 'room_id'
|
|
) AS has_scan_range_room_id;
|
|
"""
|
|
)
|
|
|
|
has_scan_range_room_id = cur.fetchone()["has_scan_range_room_id"]
|
|
|
|
if has_scan_range_room_id:
|
|
cur.execute(
|
|
"""
|
|
SELECT COUNT(*)::int AS count
|
|
FROM scan_ranges
|
|
WHERE room_id = %s::uuid;
|
|
""",
|
|
(room_id,),
|
|
)
|
|
|
|
scan_range_reference_count = cur.fetchone()["count"]
|
|
|
|
if miner_reference_count == 0 and scan_range_reference_count == 0:
|
|
cur.execute(
|
|
"""
|
|
DELETE FROM rooms
|
|
WHERE id = %s::uuid;
|
|
""",
|
|
(room_id,),
|
|
)
|
|
|
|
room_deleted = cur.rowcount > 0
|
|
else:
|
|
room_delete_reason = (
|
|
"Room map positions were deleted, but the room record was kept because "
|
|
f"{miner_reference_count} miners and {scan_range_reference_count} scan ranges still reference it."
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
return api_response(
|
|
data={
|
|
"room_name": room_name,
|
|
"deleted_positions": deleted_positions,
|
|
"assigned_miner_count": counts["assigned_miner_count"],
|
|
"room_record_deleted": room_deleted,
|
|
"room_delete_reason": room_delete_reason,
|
|
"miner_reference_count": miner_reference_count,
|
|
"scan_range_reference_count": scan_range_reference_count,
|
|
}
|
|
)
|
|
|
|
except Exception:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
|
|
raise
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
# -----------------------------
|
|
# Phase 3 Step 68: Spreadsheet layout import
|
|
# -----------------------------
|
|
|
|
class LocationSpreadsheetImportRequest(BaseModel):
|
|
file_name: str
|
|
file_base64: str
|
|
room_name_override: str | None = None
|
|
|
|
|
|
def xlsx_column_name_to_number(cell_ref: str):
|
|
letters = ""
|
|
|
|
for ch in str(cell_ref):
|
|
if ch.isalpha():
|
|
letters += ch.upper()
|
|
else:
|
|
break
|
|
|
|
value = 0
|
|
|
|
for ch in letters:
|
|
value = value * 26 + (ord(ch) - ord("A") + 1)
|
|
|
|
return value
|
|
|
|
|
|
def parse_xlsx_shared_strings(zf):
|
|
import xml.etree.ElementTree as ET
|
|
|
|
try:
|
|
data = zf.read("xl/sharedStrings.xml")
|
|
except KeyError:
|
|
return []
|
|
|
|
root = ET.fromstring(data)
|
|
namespace = {"main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
|
|
|
|
strings = []
|
|
|
|
for si in root.findall("main:si", namespace):
|
|
text_parts = []
|
|
|
|
for text_node in si.findall(".//main:t", namespace):
|
|
text_parts.append(text_node.text or "")
|
|
|
|
strings.append("".join(text_parts))
|
|
|
|
return strings
|
|
|
|
|
|
def parse_xlsx_workbook_sheet_names(zf):
|
|
import xml.etree.ElementTree as ET
|
|
|
|
try:
|
|
workbook_data = zf.read("xl/workbook.xml")
|
|
rels_data = zf.read("xl/_rels/workbook.xml.rels")
|
|
except KeyError:
|
|
return [("Sheet1", "xl/worksheets/sheet1.xml")]
|
|
|
|
ns_main = {"main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
|
|
ns_rel = {"rel": "http://schemas.openxmlformats.org/package/2006/relationships"}
|
|
|
|
workbook_root = ET.fromstring(workbook_data)
|
|
rels_root = ET.fromstring(rels_data)
|
|
|
|
rel_map = {}
|
|
|
|
for rel in rels_root.findall("rel:Relationship", ns_rel):
|
|
rel_id = rel.attrib.get("Id")
|
|
target = rel.attrib.get("Target")
|
|
|
|
if rel_id and target:
|
|
if target.startswith("/"):
|
|
path = target.lstrip("/")
|
|
elif target.startswith("xl/"):
|
|
path = target
|
|
else:
|
|
path = "xl/" + target
|
|
|
|
rel_map[rel_id] = path
|
|
|
|
sheets = []
|
|
|
|
for sheet in workbook_root.findall(".//main:sheet", ns_main):
|
|
name = sheet.attrib.get("name", "Sheet")
|
|
rel_id = sheet.attrib.get("{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id")
|
|
path = rel_map.get(rel_id)
|
|
|
|
if path:
|
|
sheets.append((name, path))
|
|
|
|
if not sheets:
|
|
return [("Sheet1", "xl/worksheets/sheet1.xml")]
|
|
|
|
return sheets
|
|
|
|
|
|
def parse_xlsx_sheet_rows(zf, sheet_path, shared_strings):
|
|
import xml.etree.ElementTree as ET
|
|
|
|
try:
|
|
data = zf.read(sheet_path)
|
|
except KeyError:
|
|
return []
|
|
|
|
root = ET.fromstring(data)
|
|
namespace = {"main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
|
|
|
|
rows = []
|
|
|
|
for row in root.findall(".//main:sheetData/main:row", namespace):
|
|
row_number = int(row.attrib.get("r", "0"))
|
|
cells = {}
|
|
|
|
for cell in row.findall("main:c", namespace):
|
|
cell_ref = cell.attrib.get("r", "")
|
|
col_number = xlsx_column_name_to_number(cell_ref)
|
|
|
|
cell_type = cell.attrib.get("t")
|
|
value_node = cell.find("main:v", namespace)
|
|
|
|
value = None
|
|
|
|
if value_node is not None:
|
|
raw_value = value_node.text or ""
|
|
|
|
if cell_type == "s":
|
|
try:
|
|
value = shared_strings[int(raw_value)]
|
|
except Exception:
|
|
value = raw_value
|
|
else:
|
|
value = raw_value
|
|
|
|
inline_node = cell.find("main:is/main:t", namespace)
|
|
|
|
if inline_node is not None:
|
|
value = inline_node.text or ""
|
|
|
|
if value is not None:
|
|
cells[col_number] = str(value).strip()
|
|
|
|
if cells:
|
|
rows.append(
|
|
{
|
|
"row_number": row_number,
|
|
"cells": cells,
|
|
}
|
|
)
|
|
|
|
return rows
|
|
|
|
|
|
def parse_location_layout_xlsx(file_bytes: bytes, file_name: str, room_name_override: str | None = None):
|
|
import zipfile
|
|
import io
|
|
|
|
positions = []
|
|
parse_warnings = []
|
|
parsed_room_names = set()
|
|
parsed_rack_names = set()
|
|
|
|
with zipfile.ZipFile(io.BytesIO(file_bytes), "r") as zf:
|
|
shared_strings = parse_xlsx_shared_strings(zf)
|
|
sheets = parse_xlsx_workbook_sheet_names(zf)
|
|
|
|
for sheet_name, sheet_path in sheets:
|
|
rows = parse_xlsx_sheet_rows(zf, sheet_path, shared_strings)
|
|
|
|
current_room_name = room_name_override
|
|
current_rack_name = None
|
|
shelf_index_for_rack = 0
|
|
|
|
for row in rows:
|
|
cells = row["cells"]
|
|
|
|
first_col = min(cells.keys())
|
|
first_value = str(cells.get(first_col, "") or "").strip()
|
|
|
|
if not first_value:
|
|
continue
|
|
|
|
first_lower = first_value.lower()
|
|
|
|
if first_lower.startswith("unit ") or first_lower.startswith("room "):
|
|
if not room_name_override:
|
|
current_room_name = first_value
|
|
|
|
parsed_room_names.add(current_room_name)
|
|
continue
|
|
|
|
if first_lower.startswith("rack ") or first_lower in ("bathroom", "office"):
|
|
current_rack_name = first_value
|
|
parsed_rack_names.add(current_rack_name)
|
|
shelf_index_for_rack = 0
|
|
continue
|
|
|
|
if first_lower.startswith("shelf"):
|
|
if not current_room_name:
|
|
parse_warnings.append(f"{file_name} {sheet_name} row {row['row_number']}: shelf found before unit/room name.")
|
|
continue
|
|
|
|
if not current_rack_name:
|
|
parse_warnings.append(f"{file_name} {sheet_name} row {row['row_number']}: shelf found before rack name.")
|
|
continue
|
|
|
|
shelf_index_for_rack += 1
|
|
shelf_name = first_value
|
|
|
|
max_col = max(cells.keys()) if cells else first_col
|
|
|
|
for col_number in range(first_col + 1, max_col + 1):
|
|
raw_value = str(cells.get(col_number, "") or "").strip()
|
|
position_index = col_number - first_col
|
|
source_cell = f"R{row['row_number']}C{col_number}"
|
|
|
|
item_type = "empty"
|
|
position_status = "available"
|
|
mac_address = None
|
|
label = None
|
|
notes = None
|
|
conflict_reason = None
|
|
|
|
cleaned_mac = "".join(ch for ch in raw_value.lower() if ch in "0123456789abcdef")
|
|
|
|
if raw_value:
|
|
lower_value = raw_value.lower()
|
|
|
|
if "switch" in lower_value:
|
|
item_type = "switch"
|
|
position_status = "switch"
|
|
label = raw_value
|
|
|
|
elif "bad" in lower_value or "blocked" in lower_value or "unusable" in lower_value:
|
|
item_type = "blocked"
|
|
position_status = "blocked"
|
|
label = raw_value
|
|
notes = "Imported as permanent blocked/bad spot."
|
|
|
|
elif len(cleaned_mac) == 12:
|
|
item_type = "miner"
|
|
position_status = "occupied"
|
|
mac_address = normalize_mac_for_location(raw_value)
|
|
|
|
else:
|
|
item_type = "blocked"
|
|
position_status = "blocked"
|
|
label = raw_value
|
|
notes = "Imported as blocked because the cell was not empty, switch, or valid MAC."
|
|
|
|
positions.append(
|
|
{
|
|
"room_name": current_room_name,
|
|
"rack_name": current_rack_name,
|
|
"shelf_name": shelf_name,
|
|
"shelf_index": shelf_index_for_rack,
|
|
"position_index": position_index,
|
|
"position_label": f"Pos {position_index}",
|
|
"item_type": item_type,
|
|
"position_status": position_status,
|
|
"mac_address": mac_address,
|
|
"label": label,
|
|
"notes": notes,
|
|
"conflict_reason": conflict_reason,
|
|
"source_file": file_name,
|
|
"source_sheet": sheet_name,
|
|
"source_cell": source_cell,
|
|
"raw_value": raw_value,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"positions": positions,
|
|
"parse_warnings": parse_warnings,
|
|
"parsed_room_names": sorted(list(parsed_room_names)),
|
|
"parsed_rack_names": sorted(list(parsed_rack_names)),
|
|
}
|
|
|
|
|
|
@app.post("/api/location/import-spreadsheet")
|
|
def import_location_spreadsheet(
|
|
request: LocationSpreadsheetImportRequest,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
import base64
|
|
|
|
ensure_location_tables()
|
|
|
|
if not request.file_base64:
|
|
raise HTTPException(status_code=400, detail="No spreadsheet file data supplied")
|
|
|
|
file_name = request.file_name or "uploaded-layout.xlsx"
|
|
|
|
if not file_name.lower().endswith(".xlsx"):
|
|
raise HTTPException(status_code=400, detail="Only .xlsx files are supported")
|
|
|
|
try:
|
|
file_bytes = base64.b64decode(request.file_base64)
|
|
except Exception:
|
|
raise HTTPException(status_code=400, detail="Could not decode uploaded spreadsheet")
|
|
|
|
parsed = parse_location_layout_xlsx(
|
|
file_bytes,
|
|
file_name,
|
|
request.room_name_override,
|
|
)
|
|
|
|
positions = parsed["positions"]
|
|
|
|
if not positions:
|
|
raise HTTPException(status_code=400, detail="No layout positions found in spreadsheet")
|
|
|
|
seed_request = LocationSeedImportRequest(
|
|
schema_version=1,
|
|
generated_from=[file_name],
|
|
notes="Imported from Location Map spreadsheet upload GUI.",
|
|
positions=positions,
|
|
)
|
|
|
|
result = import_location_layout_seed(seed_request, current_user)
|
|
|
|
if isinstance(result, dict) and result.get("data") is not None:
|
|
result["data"]["spreadsheet_parse"] = {
|
|
"file_name": file_name,
|
|
"positions_parsed": len(positions),
|
|
"parse_warnings": parsed["parse_warnings"],
|
|
"parsed_room_names": parsed["parsed_room_names"],
|
|
"parsed_rack_names": parsed["parsed_rack_names"],
|
|
"room_name_override": request.room_name_override,
|
|
}
|
|
|
|
return result
|
|
|
|
# -----------------------------
|
|
# Phase 3 Step 69: Move miners between physical and virtual locations
|
|
# -----------------------------
|
|
|
|
class MinerLocationMoveRequest(BaseModel):
|
|
miner_id: str | None = None
|
|
mac_address: str | None = None
|
|
|
|
destination_type: str = "physical"
|
|
|
|
location_position_id: str | None = None
|
|
room_name: str | None = None
|
|
rack_name: str | None = None
|
|
shelf_name: str | None = None
|
|
position_index: int | None = None
|
|
|
|
asset_state: str | None = None
|
|
notes: str | None = None
|
|
|
|
|
|
def find_miner_for_location_move(cur, miner_id: str | None, mac_address: str | None):
|
|
if miner_id:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
current_mac,
|
|
host(current_ip) AS current_ip,
|
|
room_id::text
|
|
FROM miners
|
|
WHERE id = %s::uuid
|
|
LIMIT 1;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
|
|
row = cur.fetchone()
|
|
|
|
if row:
|
|
return dict(row)
|
|
|
|
if mac_address:
|
|
mac_key = normalize_mac_key_for_location(mac_address)
|
|
|
|
if not mac_key:
|
|
raise HTTPException(status_code=400, detail="MAC address is invalid")
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
current_mac,
|
|
host(current_ip) AS current_ip,
|
|
room_id::text
|
|
FROM miners
|
|
WHERE regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g') = %s
|
|
LIMIT 1;
|
|
""",
|
|
(mac_key,),
|
|
)
|
|
|
|
row = cur.fetchone()
|
|
|
|
if row:
|
|
return dict(row)
|
|
|
|
return None
|
|
|
|
|
|
def describe_location_position(row):
|
|
if not row:
|
|
return "Unplaced"
|
|
|
|
return (
|
|
f"{row.get('room_name') or '-'} / "
|
|
f"{row.get('rack_name') or '-'} / "
|
|
f"{row.get('shelf_name') or '-'} / "
|
|
f"Pos {row.get('position_index') or '-'}"
|
|
)
|
|
|
|
|
|
@app.post("/api/location/miners/move")
|
|
def move_miner_location(
|
|
request: MinerLocationMoveRequest,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
ensure_location_tables()
|
|
ensure_maintenance_tables()
|
|
|
|
valid_virtual_states = {
|
|
"waiting_placement",
|
|
"storage",
|
|
"repair",
|
|
"decommissioned",
|
|
}
|
|
|
|
destination_type = str(request.destination_type or "physical").strip().lower()
|
|
|
|
if destination_type not in ("physical", "virtual"):
|
|
raise HTTPException(status_code=400, detail="destination_type must be physical or virtual")
|
|
|
|
conn = None
|
|
|
|
try:
|
|
conn = get_db_connection()
|
|
|
|
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
miner = find_miner_for_location_move(cur, request.miner_id, request.mac_address)
|
|
|
|
if not miner:
|
|
raise HTTPException(status_code=404, detail="Miner not found")
|
|
|
|
miner_id = miner["id"]
|
|
current_mac = miner.get("current_mac")
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
room_name,
|
|
rack_name,
|
|
shelf_name,
|
|
position_index
|
|
FROM location_positions
|
|
WHERE miner_id = %s::uuid
|
|
ORDER BY room_name, rack_name, shelf_index, position_index;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
|
|
old_positions = [dict(row) for row in cur.fetchall()]
|
|
old_location_text = " / ".join([describe_location_position(row) for row in old_positions]) if old_positions else "Unplaced"
|
|
|
|
new_location_text = None
|
|
target_position_id = None
|
|
target_room_name = None
|
|
|
|
# Clear current physical positions first. If anything fails later, rollback restores them.
|
|
cur.execute(
|
|
"""
|
|
UPDATE location_positions
|
|
SET
|
|
miner_id = NULL,
|
|
expected_mac = NULL,
|
|
position_status = CASE
|
|
WHEN position_kind = 'miner_slot' THEN 'available'
|
|
ELSE position_status
|
|
END,
|
|
conflict_reason = NULL,
|
|
updated_at = now()
|
|
WHERE miner_id = %s::uuid;
|
|
""",
|
|
(miner_id,),
|
|
)
|
|
|
|
if destination_type == "physical":
|
|
if request.location_position_id:
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
room_id::text,
|
|
room_name,
|
|
rack_name,
|
|
shelf_name,
|
|
shelf_index,
|
|
position_index,
|
|
position_kind,
|
|
position_status,
|
|
miner_id::text AS assigned_miner_id,
|
|
expected_mac
|
|
FROM location_positions
|
|
WHERE id = %s::uuid
|
|
FOR UPDATE;
|
|
""",
|
|
(request.location_position_id,),
|
|
)
|
|
else:
|
|
if not request.room_name or not request.rack_name or not request.shelf_name or not request.position_index:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Physical moves require room, rack, shelf, and position",
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
id::text,
|
|
room_id::text,
|
|
room_name,
|
|
rack_name,
|
|
shelf_name,
|
|
shelf_index,
|
|
position_index,
|
|
position_kind,
|
|
position_status,
|
|
miner_id::text AS assigned_miner_id,
|
|
expected_mac
|
|
FROM location_positions
|
|
WHERE lower(room_name) = lower(%s)
|
|
AND lower(rack_name) = lower(%s)
|
|
AND lower(shelf_name) = lower(%s)
|
|
AND position_index = %s
|
|
FOR UPDATE;
|
|
""",
|
|
(
|
|
request.room_name,
|
|
request.rack_name,
|
|
request.shelf_name,
|
|
request.position_index,
|
|
),
|
|
)
|
|
|
|
target = cur.fetchone()
|
|
|
|
if not target:
|
|
raise HTTPException(status_code=404, detail="Target physical position not found")
|
|
|
|
target = dict(target)
|
|
|
|
if target.get("position_kind") != "miner_slot":
|
|
raise HTTPException(status_code=400, detail="Target position is not a miner slot")
|
|
|
|
if target.get("position_status") in ("switch", "blocked"):
|
|
raise HTTPException(status_code=400, detail="Target position is switch/blocked")
|
|
|
|
assigned_miner_id = target.get("assigned_miner_id")
|
|
|
|
if assigned_miner_id and assigned_miner_id != miner_id:
|
|
raise HTTPException(status_code=400, detail="Target position is already occupied")
|
|
|
|
target_position_id = target["id"]
|
|
target_room_name = target["room_name"]
|
|
new_location_text = describe_location_position(target)
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE location_positions
|
|
SET
|
|
miner_id = %s::uuid,
|
|
expected_mac = %s,
|
|
position_status = 'occupied',
|
|
conflict_reason = NULL,
|
|
updated_at = now()
|
|
WHERE id = %s::uuid;
|
|
""",
|
|
(
|
|
miner_id,
|
|
current_mac,
|
|
target_position_id,
|
|
),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id::text
|
|
FROM rooms
|
|
WHERE lower(name) = lower(%s)
|
|
LIMIT 1;
|
|
""",
|
|
(target_room_name,),
|
|
)
|
|
|
|
room = cur.fetchone()
|
|
room_id = room["id"] if room else None
|
|
|
|
if room_id:
|
|
cur.execute(
|
|
"""
|
|
UPDATE miners
|
|
SET room_id = %s::uuid
|
|
WHERE id = %s::uuid;
|
|
""",
|
|
(
|
|
room_id,
|
|
miner_id,
|
|
),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO miner_location_states (
|
|
miner_id,
|
|
asset_state,
|
|
location_position_id,
|
|
state_notes,
|
|
updated_by_user_id,
|
|
updated_at
|
|
)
|
|
VALUES (
|
|
%s::uuid,
|
|
'shelved',
|
|
%s::uuid,
|
|
%s,
|
|
%s::uuid,
|
|
now()
|
|
)
|
|
ON CONFLICT (miner_id)
|
|
DO UPDATE SET
|
|
asset_state = 'shelved',
|
|
location_position_id = EXCLUDED.location_position_id,
|
|
state_notes = EXCLUDED.state_notes,
|
|
updated_by_user_id = EXCLUDED.updated_by_user_id,
|
|
updated_at = now();
|
|
""",
|
|
(
|
|
miner_id,
|
|
target_position_id,
|
|
request.notes,
|
|
get_current_user_id_for_location(current_user),
|
|
),
|
|
)
|
|
|
|
new_asset_state = "shelved"
|
|
|
|
else:
|
|
asset_state = str(request.asset_state or "").strip().lower()
|
|
|
|
if asset_state not in valid_virtual_states:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="asset_state must be waiting_placement, storage, repair, or decommissioned",
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO miner_location_states (
|
|
miner_id,
|
|
asset_state,
|
|
location_position_id,
|
|
state_notes,
|
|
updated_by_user_id,
|
|
updated_at
|
|
)
|
|
VALUES (
|
|
%s::uuid,
|
|
%s,
|
|
NULL,
|
|
%s,
|
|
%s::uuid,
|
|
now()
|
|
)
|
|
ON CONFLICT (miner_id)
|
|
DO UPDATE SET
|
|
asset_state = EXCLUDED.asset_state,
|
|
location_position_id = NULL,
|
|
state_notes = EXCLUDED.state_notes,
|
|
updated_by_user_id = EXCLUDED.updated_by_user_id,
|
|
updated_at = now();
|
|
""",
|
|
(
|
|
miner_id,
|
|
asset_state,
|
|
request.notes,
|
|
get_current_user_id_for_location(current_user),
|
|
),
|
|
)
|
|
|
|
new_location_text = asset_state
|
|
new_asset_state = asset_state
|
|
|
|
event_description = (
|
|
f"Miner moved from {old_location_text} to {new_location_text}."
|
|
)
|
|
|
|
if request.notes:
|
|
event_description += f" Notes: {request.notes}"
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO miner_maintenance_events (
|
|
id,
|
|
miner_id,
|
|
event_type,
|
|
event_status,
|
|
event_title,
|
|
description,
|
|
event_at,
|
|
component_type,
|
|
component_slot,
|
|
created_by_user_id
|
|
)
|
|
VALUES (
|
|
%s::uuid,
|
|
%s::uuid,
|
|
'moved',
|
|
'completed',
|
|
'Miner location changed',
|
|
%s,
|
|
now(),
|
|
'location',
|
|
%s,
|
|
%s::uuid
|
|
)
|
|
RETURNING id::text;
|
|
""",
|
|
(
|
|
str(uuid4()),
|
|
miner_id,
|
|
event_description,
|
|
new_location_text,
|
|
get_current_user_id_for_location(current_user),
|
|
),
|
|
)
|
|
|
|
event = cur.fetchone()
|
|
|
|
conn.commit()
|
|
|
|
return api_response(
|
|
data={
|
|
"miner_id": miner_id,
|
|
"current_mac": current_mac,
|
|
"old_location": old_location_text,
|
|
"new_location": new_location_text,
|
|
"asset_state": new_asset_state,
|
|
"location_position_id": target_position_id,
|
|
"maintenance_event_id": event["id"] if event else None,
|
|
}
|
|
)
|
|
|
|
except Exception:
|
|
if conn is not None:
|
|
conn.rollback()
|
|
|
|
raise
|
|
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
# -----------------------------
|
|
# Step 86: Miner command control foundation
|
|
# -----------------------------
|
|
|
|
from fastapi import Request, HTTPException
|
|
|
|
async def minerops_command_db_connect():
|
|
import os
|
|
import asyncpg
|
|
|
|
database_url = os.environ.get("DATABASE_URL") or globals().get("DATABASE_URL")
|
|
|
|
if not database_url:
|
|
raise RuntimeError("DATABASE_URL is not configured.")
|
|
|
|
# asyncpg does not accept SQLAlchemy driver names like postgresql+psycopg://
|
|
database_url = database_url.replace("postgresql+psycopg://", "postgresql://", 1)
|
|
database_url = database_url.replace("postgresql+asyncpg://", "postgresql://", 1)
|
|
database_url = database_url.replace("postgres://", "postgresql://", 1)
|
|
|
|
return await asyncpg.connect(database_url)
|
|
|
|
|
|
async def ensure_miner_command_tables():
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
await conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS miner_command_events (
|
|
id TEXT PRIMARY KEY,
|
|
miner_id TEXT NOT NULL,
|
|
command_type TEXT NOT NULL,
|
|
command_status TEXT NOT NULL,
|
|
dry_run BOOLEAN NOT NULL DEFAULT TRUE,
|
|
requested_by_user_id TEXT,
|
|
requested_by_username TEXT,
|
|
target_ip TEXT,
|
|
target_mac TEXT,
|
|
request_payload JSONB,
|
|
response_payload JSONB,
|
|
error_message TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
completed_at TIMESTAMPTZ
|
|
);
|
|
""")
|
|
|
|
await conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS idx_miner_command_events_miner_id_created
|
|
ON miner_command_events (miner_id, created_at DESC);
|
|
""")
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
async def get_miner_command_target(miner_id: str):
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
columns = await conn.fetch("""
|
|
SELECT column_name
|
|
FROM information_schema.columns
|
|
WHERE table_name = 'miners'
|
|
""")
|
|
|
|
column_names = {row["column_name"] for row in columns}
|
|
|
|
def pick_column(candidates):
|
|
for candidate in candidates:
|
|
if candidate in column_names:
|
|
return candidate
|
|
|
|
return None
|
|
|
|
ip_col = pick_column([
|
|
"current_ip",
|
|
"ip_address",
|
|
"ip",
|
|
"last_ip",
|
|
"network_ip"
|
|
])
|
|
|
|
mac_col = pick_column([
|
|
"current_mac",
|
|
"mac_address",
|
|
"mac",
|
|
"expected_mac",
|
|
"network_mac"
|
|
])
|
|
|
|
model_col = pick_column([
|
|
"model",
|
|
"miner_model",
|
|
"miner_type",
|
|
"hardware_model"
|
|
])
|
|
|
|
select_parts = ["id::text AS miner_id"]
|
|
|
|
if ip_col:
|
|
select_parts.append(f"{ip_col}::text AS target_ip")
|
|
else:
|
|
select_parts.append("NULL::text AS target_ip")
|
|
|
|
if mac_col:
|
|
select_parts.append(f"{mac_col}::text AS target_mac")
|
|
else:
|
|
select_parts.append("NULL::text AS target_mac")
|
|
|
|
if model_col:
|
|
select_parts.append(f"{model_col}::text AS model")
|
|
else:
|
|
select_parts.append("NULL::text AS model")
|
|
|
|
sql = f"""
|
|
SELECT {", ".join(select_parts)}
|
|
FROM miners
|
|
WHERE id::text = $1
|
|
LIMIT 1
|
|
"""
|
|
|
|
row = await conn.fetchrow(sql, miner_id)
|
|
|
|
if not row:
|
|
return None
|
|
|
|
return dict(row)
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
async def insert_miner_command_event(
|
|
miner_id: str,
|
|
command_type: str,
|
|
command_status: str,
|
|
dry_run: bool,
|
|
target_ip: str | None = None,
|
|
target_mac: str | None = None,
|
|
requested_by_user_id: str | None = None,
|
|
requested_by_username: str | None = None,
|
|
request_payload: dict | None = None,
|
|
response_payload: dict | None = None,
|
|
error_message: str | None = None,
|
|
):
|
|
import json
|
|
import uuid
|
|
|
|
await ensure_miner_command_tables()
|
|
|
|
event_id = str(uuid.uuid4())
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO miner_command_events (
|
|
id,
|
|
miner_id,
|
|
command_type,
|
|
command_status,
|
|
dry_run,
|
|
requested_by_user_id,
|
|
requested_by_username,
|
|
target_ip,
|
|
target_mac,
|
|
request_payload,
|
|
response_payload,
|
|
error_message,
|
|
completed_at
|
|
)
|
|
VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
|
$10::jsonb,
|
|
$11::jsonb,
|
|
$12,
|
|
now()
|
|
)
|
|
""",
|
|
event_id,
|
|
miner_id,
|
|
command_type,
|
|
command_status,
|
|
dry_run,
|
|
requested_by_user_id,
|
|
requested_by_username,
|
|
target_ip,
|
|
target_mac,
|
|
json.dumps(request_payload or {}),
|
|
json.dumps(response_payload or {}),
|
|
error_message,
|
|
)
|
|
finally:
|
|
await conn.close()
|
|
|
|
return event_id
|
|
|
|
|
|
def minerops_command_require_bearer(request: Request):
|
|
auth_header = request.headers.get("authorization") or ""
|
|
|
|
if not auth_header.lower().startswith("bearer "):
|
|
raise HTTPException(status_code=401, detail="Login token required.")
|
|
|
|
|
|
async def minerops_command_user_from_request(request: Request):
|
|
username = None
|
|
user_id = None
|
|
|
|
# Best effort only. The existing auth system already owns real auth.
|
|
# This command layer requires a bearer token and records who the UI says is signed in when available.
|
|
try:
|
|
username = request.headers.get("x-minerops-username")
|
|
except Exception:
|
|
username = None
|
|
|
|
if not username:
|
|
username = "authenticated-user"
|
|
|
|
return user_id, username
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def startup_ensure_miner_command_tables():
|
|
await ensure_miner_command_tables()
|
|
|
|
|
|
|
|
# -----------------------------
|
|
# Step 87: Real reboot command helpers
|
|
# -----------------------------
|
|
|
|
def minerops_env_enabled_default_true(name: str) -> bool:
|
|
import os
|
|
|
|
value = os.environ.get(name)
|
|
|
|
if value is None or str(value).strip() == "":
|
|
return True
|
|
|
|
value = str(value).strip().lower()
|
|
|
|
return value not in {"0", "false", "no", "off", "disabled"}
|
|
|
|
def minerops_env_enabled(name: str) -> bool:
|
|
import os
|
|
|
|
value = str(os.environ.get(name, "") or "").strip().lower()
|
|
return value in {"1", "true", "yes", "on", "enabled"}
|
|
|
|
|
|
def minerops_get_whatsminer_api_port() -> int:
|
|
import os
|
|
|
|
raw = str(os.environ.get("MINEROPS_WHATSMINER_API_PORT", "4028") or "4028").strip()
|
|
|
|
try:
|
|
return int(raw)
|
|
except Exception:
|
|
return 4028
|
|
|
|
|
|
def minerops_normalize_target_ip(target_ip: str | None) -> str | None:
|
|
if not target_ip:
|
|
return target_ip
|
|
|
|
cleaned = str(target_ip).strip()
|
|
|
|
# Stored IPs can sometimes be CIDR notation like 10.30.0.53/32.
|
|
# The socket connection needs only the host address.
|
|
if "/" in cleaned:
|
|
cleaned = cleaned.split("/", 1)[0].strip()
|
|
|
|
if cleaned.startswith("[") and cleaned.endswith("]"):
|
|
cleaned = cleaned[1:-1].strip()
|
|
|
|
return cleaned
|
|
|
|
def minerops_send_whatsminer_reboot_sync(target_ip: str, port: int = 4028, timeout_seconds: float = 10.0) -> dict:
|
|
import json
|
|
import socket
|
|
import time
|
|
|
|
target_ip = minerops_normalize_target_ip(target_ip)
|
|
|
|
if not target_ip:
|
|
raise RuntimeError("No target IP available for reboot command.")
|
|
|
|
command = {"cmd": "reboot"}
|
|
payload = json.dumps(command).encode("utf-8") + b"\n"
|
|
|
|
started = time.time()
|
|
chunks = []
|
|
|
|
with socket.create_connection((target_ip, port), timeout=timeout_seconds) as sock:
|
|
sock.settimeout(timeout_seconds)
|
|
sock.sendall(payload)
|
|
|
|
try:
|
|
sock.shutdown(socket.SHUT_WR)
|
|
except Exception:
|
|
pass
|
|
|
|
while True:
|
|
try:
|
|
chunk = sock.recv(65536)
|
|
|
|
if not chunk:
|
|
break
|
|
|
|
chunks.append(chunk)
|
|
|
|
if sum(len(item) for item in chunks) > 1048576:
|
|
break
|
|
except socket.timeout:
|
|
break
|
|
|
|
raw_response = b"".join(chunks).decode("utf-8", errors="replace").strip()
|
|
|
|
parsed_response = None
|
|
|
|
if raw_response:
|
|
try:
|
|
parsed_response = json.loads(raw_response)
|
|
except Exception:
|
|
parsed_response = None
|
|
|
|
return {
|
|
"sent": True,
|
|
"target_ip": target_ip,
|
|
"target_port": port,
|
|
"command": command,
|
|
"duration_seconds": round(time.time() - started, 3),
|
|
"raw_response": raw_response,
|
|
"json_response": parsed_response,
|
|
}
|
|
|
|
|
|
async def minerops_send_whatsminer_reboot(target_ip: str) -> dict:
|
|
import asyncio
|
|
|
|
port = minerops_get_whatsminer_api_port()
|
|
|
|
return await asyncio.to_thread(
|
|
minerops_send_whatsminer_reboot_sync,
|
|
target_ip,
|
|
port,
|
|
10.0,
|
|
)
|
|
|
|
@app.post("/api/miners/{miner_id}/commands/reboot")
|
|
async def dry_run_reboot_miner_command(miner_id: str, request: Request):
|
|
minerops_command_require_bearer(request)
|
|
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
|
|
dry_run = bool(body.get("dry_run", True))
|
|
confirm_text = str(body.get("confirm_text", "") or "").strip()
|
|
|
|
target = await get_miner_command_target(miner_id)
|
|
|
|
if not target:
|
|
raise HTTPException(status_code=404, detail="Miner not found.")
|
|
|
|
user_id, username = await minerops_command_user_from_request(request)
|
|
|
|
request_payload = {
|
|
"dry_run": dry_run,
|
|
"confirm_text": confirm_text,
|
|
"source": "miner_detail_control_card",
|
|
"real_reboot_enabled": minerops_env_enabled_default_true("MINEROPS_ENABLE_REAL_REBOOT"),
|
|
}
|
|
|
|
if dry_run:
|
|
event_id = await insert_miner_command_event(
|
|
miner_id=miner_id,
|
|
command_type="reboot",
|
|
command_status="dry_run_planned",
|
|
dry_run=True,
|
|
target_ip=target.get("target_ip"),
|
|
target_mac=target.get("target_mac"),
|
|
requested_by_user_id=user_id,
|
|
requested_by_username=username,
|
|
request_payload=request_payload,
|
|
response_payload={
|
|
"message": "Dry run only. No reboot command was sent.",
|
|
"target": target,
|
|
},
|
|
)
|
|
|
|
return {
|
|
"ok": True,
|
|
"event_id": event_id,
|
|
"miner_id": miner_id,
|
|
"command_type": "reboot",
|
|
"command_status": "dry_run_planned",
|
|
"dry_run": True,
|
|
"target_ip": target.get("target_ip"),
|
|
"target_mac": target.get("target_mac"),
|
|
"model": target.get("model"),
|
|
"message": "Dry run complete. No reboot command was sent.",
|
|
}
|
|
|
|
if not minerops_env_enabled_default_true("MINEROPS_ENABLE_REAL_REBOOT"):
|
|
event_id = await insert_miner_command_event(
|
|
miner_id=miner_id,
|
|
command_type="reboot",
|
|
command_status="blocked_real_reboot_disabled",
|
|
dry_run=False,
|
|
target_ip=target.get("target_ip"),
|
|
target_mac=target.get("target_mac"),
|
|
requested_by_user_id=user_id,
|
|
requested_by_username=username,
|
|
request_payload=request_payload,
|
|
response_payload={
|
|
"message": "Real reboot is disabled. Set MINEROPS_ENABLE_REAL_REBOOT=true to enable.",
|
|
},
|
|
error_message="Real reboot disabled by environment setting.",
|
|
)
|
|
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail={
|
|
"message": "Real reboot is disabled. Set MINEROPS_ENABLE_REAL_REBOOT=true to enable.",
|
|
"event_id": event_id,
|
|
},
|
|
)
|
|
|
|
if confirm_text != "REBOOT":
|
|
event_id = await insert_miner_command_event(
|
|
miner_id=miner_id,
|
|
command_type="reboot",
|
|
command_status="blocked_confirmation_missing",
|
|
dry_run=False,
|
|
target_ip=target.get("target_ip"),
|
|
target_mac=target.get("target_mac"),
|
|
requested_by_user_id=user_id,
|
|
requested_by_username=username,
|
|
request_payload=request_payload,
|
|
response_payload={
|
|
"message": "Confirmation text did not match REBOOT.",
|
|
},
|
|
error_message="Confirmation text did not match REBOOT.",
|
|
)
|
|
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail={
|
|
"message": "Type REBOOT to confirm.",
|
|
"event_id": event_id,
|
|
},
|
|
)
|
|
|
|
target_ip = minerops_normalize_target_ip(target.get("target_ip"))
|
|
|
|
if not target_ip:
|
|
event_id = await insert_miner_command_event(
|
|
miner_id=miner_id,
|
|
command_type="reboot",
|
|
command_status="failed_no_target_ip",
|
|
dry_run=False,
|
|
target_ip=None,
|
|
target_mac=target.get("target_mac"),
|
|
requested_by_user_id=user_id,
|
|
requested_by_username=username,
|
|
request_payload=request_payload,
|
|
response_payload={
|
|
"message": "Miner has no current IP address.",
|
|
},
|
|
error_message="Miner has no current IP address.",
|
|
)
|
|
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail={
|
|
"message": "Miner has no current IP address.",
|
|
"event_id": event_id,
|
|
},
|
|
)
|
|
|
|
request_event_id = await insert_miner_command_event(
|
|
miner_id=miner_id,
|
|
command_type="reboot",
|
|
command_status="real_reboot_requested",
|
|
dry_run=False,
|
|
target_ip=target_ip,
|
|
target_mac=target.get("target_mac"),
|
|
requested_by_user_id=user_id,
|
|
requested_by_username=username,
|
|
request_payload=request_payload,
|
|
response_payload={
|
|
"message": "Real reboot requested. Command send starting.",
|
|
},
|
|
)
|
|
|
|
try:
|
|
reboot_result = await minerops_send_whatsminer_reboot(target_ip)
|
|
|
|
result_event_id = await insert_miner_command_event(
|
|
miner_id=miner_id,
|
|
command_type="reboot",
|
|
command_status="real_reboot_sent",
|
|
dry_run=False,
|
|
target_ip=target_ip,
|
|
target_mac=target.get("target_mac"),
|
|
requested_by_user_id=user_id,
|
|
requested_by_username=username,
|
|
request_payload=request_payload,
|
|
response_payload=reboot_result,
|
|
)
|
|
|
|
return {
|
|
"ok": True,
|
|
"request_event_id": request_event_id,
|
|
"event_id": result_event_id,
|
|
"miner_id": miner_id,
|
|
"command_type": "reboot",
|
|
"command_status": "real_reboot_sent",
|
|
"dry_run": False,
|
|
"target_ip": target_ip,
|
|
"target_mac": target.get("target_mac"),
|
|
"model": target.get("model"),
|
|
"message": "Real reboot command was sent.",
|
|
"response": reboot_result,
|
|
}
|
|
except Exception as exc:
|
|
result_event_id = await insert_miner_command_event(
|
|
miner_id=miner_id,
|
|
command_type="reboot",
|
|
command_status="real_reboot_send_failed",
|
|
dry_run=False,
|
|
target_ip=target_ip,
|
|
target_mac=target.get("target_mac"),
|
|
requested_by_user_id=user_id,
|
|
requested_by_username=username,
|
|
request_payload=request_payload,
|
|
response_payload={
|
|
"message": "Real reboot command failed.",
|
|
},
|
|
error_message=str(exc),
|
|
)
|
|
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail={
|
|
"message": "Real reboot command failed.",
|
|
"event_id": result_event_id,
|
|
"error": str(exc),
|
|
},
|
|
)
|
|
|
|
@app.get("/api/miners/{miner_id}/commands")
|
|
async def list_miner_command_events(miner_id: str, request: Request):
|
|
minerops_command_require_bearer(request)
|
|
await ensure_miner_command_tables()
|
|
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT
|
|
id,
|
|
miner_id,
|
|
command_type,
|
|
command_status,
|
|
dry_run,
|
|
requested_by_user_id,
|
|
requested_by_username,
|
|
target_ip,
|
|
target_mac,
|
|
request_payload,
|
|
response_payload,
|
|
error_message,
|
|
created_at,
|
|
completed_at
|
|
FROM miner_command_events
|
|
WHERE miner_id = $1
|
|
ORDER BY created_at DESC
|
|
LIMIT 25
|
|
""",
|
|
miner_id,
|
|
)
|
|
|
|
return {
|
|
"miner_id": miner_id,
|
|
"commands": [dict(row) for row in rows],
|
|
}
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
# -----------------------------
|
|
# Step 90: Power control commands
|
|
# -----------------------------
|
|
|
|
def minerops_whatsminer_power_command_name(action: str) -> str:
|
|
import os
|
|
|
|
if action == "power_off":
|
|
return str(os.environ.get("MINEROPS_WHATSMINER_POWER_OFF_CMD", "power_off") or "power_off").strip()
|
|
|
|
if action == "power_on":
|
|
return str(os.environ.get("MINEROPS_WHATSMINER_POWER_ON_CMD", "power_on") or "power_on").strip()
|
|
|
|
if action == "suspend":
|
|
return str(os.environ.get("MINEROPS_WHATSMINER_SUSPEND_CMD", "api3_service:stop") or "api3_service:stop").strip()
|
|
|
|
if action == "resume":
|
|
return str(os.environ.get("MINEROPS_WHATSMINER_RESUME_CMD", "api3_service:start") or "api3_service:start").strip()
|
|
|
|
raise RuntimeError(f"Unknown power action: {action}")
|
|
|
|
|
|
def minerops_send_whatsminer_command_sync(
|
|
target_ip: str,
|
|
command_name: str,
|
|
port: int = 4028,
|
|
timeout_seconds: float = 10.0,
|
|
) -> dict:
|
|
import json
|
|
import socket
|
|
import time
|
|
|
|
target_ip = minerops_normalize_target_ip(target_ip)
|
|
|
|
if not target_ip:
|
|
raise RuntimeError("No target IP available for command.")
|
|
|
|
command = {"cmd": command_name}
|
|
payload = json.dumps(command).encode("utf-8") + b"\n"
|
|
|
|
started = time.time()
|
|
chunks = []
|
|
|
|
with socket.create_connection((target_ip, port), timeout=timeout_seconds) as sock:
|
|
sock.settimeout(timeout_seconds)
|
|
sock.sendall(payload)
|
|
|
|
try:
|
|
sock.shutdown(socket.SHUT_WR)
|
|
except Exception:
|
|
pass
|
|
|
|
while True:
|
|
try:
|
|
chunk = sock.recv(65536)
|
|
|
|
if not chunk:
|
|
break
|
|
|
|
chunks.append(chunk)
|
|
|
|
if sum(len(item) for item in chunks) > 1048576:
|
|
break
|
|
except socket.timeout:
|
|
break
|
|
|
|
raw_response = b"".join(chunks).decode("utf-8", errors="replace").strip()
|
|
parsed_response = None
|
|
|
|
if raw_response:
|
|
try:
|
|
parsed_response = json.loads(raw_response)
|
|
except Exception:
|
|
parsed_response = None
|
|
|
|
return {
|
|
"sent": True,
|
|
"target_ip": target_ip,
|
|
"target_port": port,
|
|
"command": command,
|
|
"duration_seconds": round(time.time() - started, 3),
|
|
"raw_response": raw_response,
|
|
"json_response": parsed_response,
|
|
}
|
|
|
|
|
|
# -----------------------------
|
|
# Step 95: WhatsMiner API3 authenticated service control
|
|
# -----------------------------
|
|
|
|
def minerops_api3_account() -> str:
|
|
import os
|
|
return str(os.environ.get("MINEROPS_WHATSMINER_API3_ACCOUNT", "super") or "super").strip()
|
|
|
|
|
|
def minerops_api3_password() -> str:
|
|
import os
|
|
return str(os.environ.get("MINEROPS_WHATSMINER_API3_PASSWORD", "super") or "super").strip()
|
|
|
|
|
|
def minerops_api3_port() -> int:
|
|
import os
|
|
|
|
raw = str(os.environ.get("MINEROPS_WHATSMINER_API3_PORT", "4433") or "4433").strip()
|
|
|
|
try:
|
|
return int(raw)
|
|
except Exception:
|
|
return 4433
|
|
|
|
|
|
def minerops_api3_token(command: str, password: str, salt: str, ts: int) -> str:
|
|
import base64
|
|
import hashlib
|
|
|
|
source = f"{command}{password}{salt}{ts}"
|
|
digest = hashlib.sha256(source.encode("utf-8")).digest()
|
|
|
|
return base64.b64encode(digest).decode("utf-8")[:8]
|
|
|
|
|
|
def minerops_api3_recv_all(sock, length: int) -> bytes:
|
|
data = b""
|
|
|
|
while len(data) < length:
|
|
chunk = sock.recv(length - len(data))
|
|
|
|
if not chunk:
|
|
break
|
|
|
|
data += chunk
|
|
|
|
return data
|
|
|
|
|
|
def minerops_api3_send_payload_sync(target_ip: str, payload: dict, timeout_seconds: float = 10.0) -> dict:
|
|
import json
|
|
import socket
|
|
import struct
|
|
import time
|
|
|
|
target_ip = minerops_normalize_target_ip(target_ip)
|
|
|
|
if not target_ip:
|
|
raise RuntimeError("No target IP available for API3 command.")
|
|
|
|
port = minerops_api3_port()
|
|
started = time.time()
|
|
|
|
request_json = json.dumps(payload, separators=(",", ":"))
|
|
request_bytes = request_json.encode("utf-8")
|
|
|
|
with socket.create_connection((target_ip, port), timeout=timeout_seconds) as sock:
|
|
sock.settimeout(timeout_seconds)
|
|
|
|
# WhatsMiner API3 uses a 4-byte little-endian JSON length prefix.
|
|
sock.sendall(struct.pack("<I", len(request_bytes)))
|
|
sock.sendall(request_bytes)
|
|
|
|
length_bytes = minerops_api3_recv_all(sock, 4)
|
|
|
|
if len(length_bytes) != 4:
|
|
raise RuntimeError("API3 response did not include a valid length header.")
|
|
|
|
response_length = struct.unpack("<I", length_bytes)[0]
|
|
|
|
if response_length <= 0 or response_length > 1048576:
|
|
raise RuntimeError(f"API3 response length looked invalid: {response_length}")
|
|
|
|
response_bytes = minerops_api3_recv_all(sock, response_length)
|
|
|
|
if len(response_bytes) != response_length:
|
|
raise RuntimeError("API3 response ended early.")
|
|
|
|
response_text = response_bytes.decode("utf-8", errors="replace")
|
|
response_json = json.loads(response_text)
|
|
|
|
return {
|
|
"sent": True,
|
|
"protocol": "whatsminer_api3",
|
|
"target_ip": target_ip,
|
|
"target_port": port,
|
|
"duration_seconds": round(time.time() - started, 3),
|
|
"request": {
|
|
"cmd": payload.get("cmd"),
|
|
"param": payload.get("param"),
|
|
"account": payload.get("account"),
|
|
"has_token": bool(payload.get("token")),
|
|
},
|
|
"response": response_json,
|
|
}
|
|
|
|
|
|
def minerops_api3_get_salt_sync(target_ip: str) -> tuple[str, dict]:
|
|
response = minerops_api3_send_payload_sync(
|
|
target_ip,
|
|
{
|
|
"cmd": "get.device.info",
|
|
"param": None,
|
|
},
|
|
)
|
|
|
|
response_json = response.get("response") or {}
|
|
|
|
if response_json.get("code") != 0:
|
|
raise RuntimeError(f"API3 get.device.info failed: {response_json}")
|
|
|
|
msg = response_json.get("msg") or {}
|
|
salt = msg.get("salt")
|
|
|
|
if not salt:
|
|
raise RuntimeError(f"API3 get.device.info did not return salt: {response_json}")
|
|
|
|
return str(salt), response
|
|
|
|
|
|
def minerops_api3_authenticated_payload(command: str, param):
|
|
import time
|
|
|
|
account = minerops_api3_account()
|
|
password = minerops_api3_password()
|
|
ts = int(time.time())
|
|
|
|
return account, password, ts
|
|
|
|
|
|
def minerops_api3_service_sync(target_ip: str, service_action: str) -> dict:
|
|
import time
|
|
|
|
if service_action not in {"start", "stop", "restart"}:
|
|
raise RuntimeError(f"Invalid API3 service action: {service_action}")
|
|
|
|
command = "set.miner.service"
|
|
account = minerops_api3_account()
|
|
password = minerops_api3_password()
|
|
|
|
salt, salt_response = minerops_api3_get_salt_sync(target_ip)
|
|
|
|
ts = int(time.time())
|
|
token = minerops_api3_token(command, password, salt, ts)
|
|
|
|
payload = {
|
|
"cmd": command,
|
|
"param": service_action,
|
|
"ts": ts,
|
|
"token": token,
|
|
"account": account,
|
|
}
|
|
|
|
result = minerops_api3_send_payload_sync(target_ip, payload)
|
|
response_json = result.get("response") or {}
|
|
|
|
result["salt_response"] = salt_response
|
|
result["api3_command"] = command
|
|
result["api3_param"] = service_action
|
|
|
|
if response_json.get("code") != 0:
|
|
raise RuntimeError(f"API3 {command} {service_action} failed: {response_json}")
|
|
|
|
return result
|
|
|
|
|
|
async def minerops_api3_service(target_ip: str, service_action: str) -> dict:
|
|
import asyncio
|
|
|
|
return await asyncio.to_thread(
|
|
minerops_api3_service_sync,
|
|
target_ip,
|
|
service_action,
|
|
)
|
|
|
|
|
|
async def minerops_send_control_command_item(target_ip: str, command_name: str) -> dict:
|
|
item = str(command_name or "").strip()
|
|
|
|
if item.startswith("api3_service:"):
|
|
service_action = item.split(":", 1)[1].strip()
|
|
return await minerops_api3_service(target_ip, service_action)
|
|
|
|
return await minerops_send_whatsminer_command(target_ip, item)
|
|
|
|
|
|
def minerops_split_command_sequence(command_name: str) -> list[str]:
|
|
parts = []
|
|
|
|
for item in str(command_name or "").split("+"):
|
|
cleaned = item.strip()
|
|
|
|
if cleaned:
|
|
parts.append(cleaned)
|
|
|
|
return parts
|
|
|
|
|
|
async def minerops_send_whatsminer_command_sequence(target_ip: str, command_name: str) -> dict:
|
|
import asyncio
|
|
|
|
commands = minerops_split_command_sequence(command_name)
|
|
|
|
if not commands:
|
|
raise RuntimeError("No command supplied.")
|
|
|
|
results = []
|
|
|
|
for item in commands:
|
|
result = await minerops_send_control_command_item(target_ip, item)
|
|
results.append(result)
|
|
|
|
# Small pause gives the miner time to apply the first command before the next one.
|
|
if len(commands) > 1:
|
|
await asyncio.sleep(1.0)
|
|
|
|
return {
|
|
"sent": True,
|
|
"target_ip": minerops_normalize_target_ip(target_ip),
|
|
"command_sequence": commands,
|
|
"results": results,
|
|
}
|
|
|
|
async def minerops_send_whatsminer_command(target_ip: str, command_name: str) -> dict:
|
|
import asyncio
|
|
|
|
port = minerops_get_whatsminer_api_port()
|
|
|
|
return await asyncio.to_thread(
|
|
minerops_send_whatsminer_command_sync,
|
|
target_ip,
|
|
command_name,
|
|
port,
|
|
10.0,
|
|
)
|
|
|
|
|
|
async def minerops_power_command(miner_id: str, request: Request, action: str):
|
|
minerops_command_require_bearer(request)
|
|
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
|
|
dry_run = bool(body.get("dry_run", False))
|
|
|
|
if action not in {"power_off", "power_on", "suspend", "resume"}:
|
|
raise HTTPException(status_code=400, detail="Invalid power action.")
|
|
|
|
if not minerops_env_enabled_default_true("MINEROPS_ENABLE_REAL_POWER_CONTROL"):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Real power control is disabled. Set MINEROPS_ENABLE_REAL_POWER_CONTROL=true or leave it unset to enable.",
|
|
)
|
|
|
|
target = await get_miner_command_target(miner_id)
|
|
|
|
if not target:
|
|
raise HTTPException(status_code=404, detail="Miner not found.")
|
|
|
|
target_ip = minerops_normalize_target_ip(target.get("target_ip"))
|
|
|
|
if not target_ip:
|
|
event_id = await insert_miner_command_event(
|
|
miner_id=miner_id,
|
|
command_type=action,
|
|
command_status="failed_no_target_ip",
|
|
dry_run=dry_run,
|
|
target_ip=None,
|
|
target_mac=target.get("target_mac"),
|
|
request_payload={
|
|
"action": action,
|
|
"dry_run": dry_run,
|
|
},
|
|
response_payload={
|
|
"message": "Miner has no current IP address.",
|
|
},
|
|
error_message="Miner has no current IP address.",
|
|
)
|
|
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail={
|
|
"message": "Miner has no current IP address.",
|
|
"event_id": event_id,
|
|
},
|
|
)
|
|
|
|
user_id, username = await minerops_command_user_from_request(request)
|
|
|
|
command_name = minerops_whatsminer_power_command_name(action)
|
|
|
|
request_payload = {
|
|
"action": action,
|
|
"dry_run": dry_run,
|
|
"whatsminer_command": command_name,
|
|
"source": "miner_detail_control_card",
|
|
"real_power_control_enabled": minerops_env_enabled_default_true("MINEROPS_ENABLE_REAL_POWER_CONTROL"),
|
|
}
|
|
|
|
if dry_run:
|
|
event_id = await insert_miner_command_event(
|
|
miner_id=miner_id,
|
|
command_type=action,
|
|
command_status="dry_run_planned",
|
|
dry_run=True,
|
|
target_ip=target_ip,
|
|
target_mac=target.get("target_mac"),
|
|
requested_by_user_id=user_id,
|
|
requested_by_username=username,
|
|
request_payload=request_payload,
|
|
response_payload={
|
|
"message": "Dry run only. No power command was sent.",
|
|
"target": target,
|
|
"whatsminer_command": command_name,
|
|
},
|
|
)
|
|
|
|
return {
|
|
"ok": True,
|
|
"event_id": event_id,
|
|
"miner_id": miner_id,
|
|
"command_type": action,
|
|
"command_status": "dry_run_planned",
|
|
"dry_run": True,
|
|
"target_ip": target_ip,
|
|
"target_mac": target.get("target_mac"),
|
|
"model": target.get("model"),
|
|
"whatsminer_command": command_name,
|
|
"message": "Dry run complete. No power command was sent.",
|
|
}
|
|
|
|
request_event_id = await insert_miner_command_event(
|
|
miner_id=miner_id,
|
|
command_type=action,
|
|
command_status=f"{action}_requested",
|
|
dry_run=False,
|
|
target_ip=target_ip,
|
|
target_mac=target.get("target_mac"),
|
|
requested_by_user_id=user_id,
|
|
requested_by_username=username,
|
|
request_payload=request_payload,
|
|
response_payload={
|
|
"message": f"{action} requested. Command send starting.",
|
|
"whatsminer_command": command_name,
|
|
},
|
|
)
|
|
|
|
try:
|
|
api3_creds = await minerops_get_api3_credentials_for_miner(miner_id)
|
|
command_result = await minerops_send_whatsminer_command_sequence(
|
|
target_ip,
|
|
command_name,
|
|
api3_creds.get("account"),
|
|
api3_creds.get("password"),
|
|
)
|
|
|
|
result_event_id = await insert_miner_command_event(
|
|
miner_id=miner_id,
|
|
command_type=action,
|
|
command_status=f"{action}_sent",
|
|
dry_run=False,
|
|
target_ip=target_ip,
|
|
target_mac=target.get("target_mac"),
|
|
requested_by_user_id=user_id,
|
|
requested_by_username=username,
|
|
request_payload=request_payload,
|
|
response_payload=command_result,
|
|
)
|
|
|
|
return {
|
|
"ok": True,
|
|
"request_event_id": request_event_id,
|
|
"event_id": result_event_id,
|
|
"miner_id": miner_id,
|
|
"command_type": action,
|
|
"command_status": f"{action}_sent",
|
|
"dry_run": False,
|
|
"target_ip": target_ip,
|
|
"target_mac": target.get("target_mac"),
|
|
"model": target.get("model"),
|
|
"whatsminer_command": command_name,
|
|
"message": f"{action} command was sent.",
|
|
"response": command_result,
|
|
}
|
|
except Exception as exc:
|
|
result_event_id = await insert_miner_command_event(
|
|
miner_id=miner_id,
|
|
command_type=action,
|
|
command_status=f"{action}_send_failed",
|
|
dry_run=False,
|
|
target_ip=target_ip,
|
|
target_mac=target.get("target_mac"),
|
|
requested_by_user_id=user_id,
|
|
requested_by_username=username,
|
|
request_payload=request_payload,
|
|
response_payload={
|
|
"message": f"{action} command failed.",
|
|
"whatsminer_command": command_name,
|
|
},
|
|
error_message=str(exc),
|
|
)
|
|
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail={
|
|
"message": f"{action} command failed.",
|
|
"event_id": result_event_id,
|
|
"error": str(exc),
|
|
},
|
|
)
|
|
|
|
|
|
@app.post("/api/miners/{miner_id}/commands/power-off")
|
|
async def real_power_off_miner_command(miner_id: str, request: Request):
|
|
return await minerops_power_command(miner_id, request, "power_off")
|
|
|
|
|
|
@app.post("/api/miners/{miner_id}/commands/power-on")
|
|
async def real_power_on_miner_command(miner_id: str, request: Request):
|
|
return await minerops_power_command(miner_id, request, "power_on")
|
|
|
|
|
|
|
|
@app.post("/api/miners/{miner_id}/commands/suspend")
|
|
async def real_suspend_miner_command(miner_id: str, request: Request):
|
|
return await minerops_power_command(miner_id, request, "suspend")
|
|
|
|
|
|
@app.post("/api/miners/{miner_id}/commands/resume")
|
|
async def real_resume_miner_command(miner_id: str, request: Request):
|
|
return await minerops_power_command(miner_id, request, "resume")
|
|
|
|
|
|
# -----------------------------
|
|
# Step 96: Miner API3 credentials and password change
|
|
# -----------------------------
|
|
|
|
async def ensure_miner_api_credentials_table():
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
await conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS miner_api_credentials (
|
|
miner_id TEXT PRIMARY KEY,
|
|
api_account TEXT NOT NULL DEFAULT 'super',
|
|
api_password TEXT NOT NULL,
|
|
password_source TEXT NOT NULL DEFAULT 'minerops',
|
|
updated_by_username TEXT,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
"""
|
|
)
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def startup_ensure_miner_api_credentials_table():
|
|
await ensure_miner_api_credentials_table()
|
|
|
|
|
|
async def minerops_get_api3_credentials_for_miner(miner_id: str) -> dict:
|
|
await ensure_miner_api_credentials_table()
|
|
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT api_account, api_password
|
|
FROM miner_api_credentials
|
|
WHERE miner_id = $1
|
|
""",
|
|
miner_id,
|
|
)
|
|
finally:
|
|
await conn.close()
|
|
|
|
if row:
|
|
return {
|
|
"account": row["api_account"],
|
|
"password": row["api_password"],
|
|
"source": "miner_override",
|
|
}
|
|
|
|
return {
|
|
"account": minerops_api3_account(),
|
|
"password": minerops_api3_password(),
|
|
"source": "environment_default",
|
|
}
|
|
|
|
|
|
async def minerops_save_api3_credentials_for_miner(
|
|
miner_id: str,
|
|
account: str,
|
|
password: str,
|
|
username: str | None = None,
|
|
):
|
|
await ensure_miner_api_credentials_table()
|
|
|
|
conn = await minerops_command_db_connect()
|
|
|
|
try:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO miner_api_credentials (
|
|
miner_id,
|
|
api_account,
|
|
api_password,
|
|
password_source,
|
|
updated_by_username,
|
|
updated_at
|
|
)
|
|
VALUES ($1, $2, $3, 'password_change', $4, now())
|
|
ON CONFLICT (miner_id)
|
|
DO UPDATE SET
|
|
api_account = EXCLUDED.api_account,
|
|
api_password = EXCLUDED.api_password,
|
|
password_source = EXCLUDED.password_source,
|
|
updated_by_username = EXCLUDED.updated_by_username,
|
|
updated_at = now()
|
|
""",
|
|
miner_id,
|
|
account,
|
|
password,
|
|
username,
|
|
)
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
def minerops_api3_encrypt_param(param: str, command: str, password: str, salt: str, ts: int) -> str:
|
|
import base64
|
|
import hashlib
|
|
|
|
from Crypto.Cipher import AES
|
|
|
|
source = f"{command}{password}{salt}{ts}"
|
|
aes_key = hashlib.sha256(source.encode("utf-8")).digest()
|
|
|
|
pad_len = 16 - (len(param) % 16)
|
|
padded_param = param + (chr(pad_len) * pad_len)
|
|
|
|
cipher = AES.new(aes_key, AES.MODE_ECB)
|
|
encrypted_bytes = cipher.encrypt(padded_param.encode("utf-8"))
|
|
|
|
return base64.b64encode(encrypted_bytes).decode("utf-8")
|
|
|
|
|
|
def minerops_api3_service_sync_with_credentials(
|
|
target_ip: str,
|
|
service_action: str,
|
|
account: str | None = None,
|
|
password: str | None = None,
|
|
) -> dict:
|
|
import time
|
|
|
|
if service_action not in {"start", "stop", "restart"}:
|
|
raise RuntimeError(f"Invalid API3 service action: {service_action}")
|
|
|
|
command = "set.miner.service"
|
|
account = str(account or minerops_api3_account()).strip()
|
|
password = str(password or minerops_api3_password()).strip()
|
|
|
|
salt, salt_response = minerops_api3_get_salt_sync(target_ip)
|
|
|
|
ts = int(time.time())
|
|
token = minerops_api3_token(command, password, salt, ts)
|
|
|
|
payload = {
|
|
"cmd": command,
|
|
"param": service_action,
|
|
"ts": ts,
|
|
"token": token,
|
|
"account": account,
|
|
}
|
|
|
|
result = minerops_api3_send_payload_sync(target_ip, payload)
|
|
response_json = result.get("response") or {}
|
|
|
|
result["salt_response"] = salt_response
|
|
result["api3_command"] = command
|
|
result["api3_param"] = service_action
|
|
result["api3_account"] = account
|
|
|
|
if response_json.get("code") != 0:
|
|
raise RuntimeError(f"API3 {command} {service_action} failed: {response_json}")
|
|
|
|
return result
|
|
|
|
|
|
async def minerops_api3_service_with_credentials(
|
|
target_ip: str,
|
|
service_action: str,
|
|
account: str | None = None,
|
|
password: str | None = None,
|
|
) -> dict:
|
|
import asyncio
|
|
|
|
return await asyncio.to_thread(
|
|
minerops_api3_service_sync_with_credentials,
|
|
target_ip,
|
|
service_action,
|
|
account,
|
|
password,
|
|
)
|
|
|
|
|
|
async def minerops_send_control_command_item(
|
|
target_ip: str,
|
|
command_name: str,
|
|
account_override: str | None = None,
|
|
password_override: str | None = None,
|
|
) -> dict:
|
|
item = str(command_name or "").strip()
|
|
|
|
if item.startswith("api3_service:"):
|
|
service_action = item.split(":", 1)[1].strip()
|
|
return await minerops_api3_service_with_credentials(
|
|
target_ip,
|
|
service_action,
|
|
account_override,
|
|
password_override,
|
|
)
|
|
|
|
return await minerops_send_whatsminer_command(target_ip, item)
|
|
|
|
|
|
async def minerops_send_whatsminer_command_sequence(
|
|
target_ip: str,
|
|
command_name: str,
|
|
account_override: str | None = None,
|
|
password_override: str | None = None,
|
|
) -> dict:
|
|
import asyncio
|
|
|
|
commands = minerops_split_command_sequence(command_name)
|
|
|
|
if not commands:
|
|
raise RuntimeError("No command supplied.")
|
|
|
|
results = []
|
|
|
|
for item in commands:
|
|
result = await minerops_send_control_command_item(
|
|
target_ip,
|
|
item,
|
|
account_override,
|
|
password_override,
|
|
)
|
|
results.append(result)
|
|
|
|
if len(commands) > 1:
|
|
await asyncio.sleep(1.0)
|
|
|
|
return {
|
|
"sent": True,
|
|
"target_ip": minerops_normalize_target_ip(target_ip),
|
|
"command_sequence": commands,
|
|
"results": results,
|
|
"credential_source": "miner_override" if password_override else "environment_default",
|
|
}
|
|
|
|
|
|
def minerops_api3_change_password_sync(
|
|
target_ip: str,
|
|
account_to_change: str,
|
|
old_password: str,
|
|
new_password: str,
|
|
) -> dict:
|
|
import json
|
|
import time
|
|
|
|
account_to_change = str(account_to_change or "super").strip()
|
|
old_password = str(old_password or "")
|
|
new_password = str(new_password or "")
|
|
|
|
if not account_to_change:
|
|
raise RuntimeError("Account is required.")
|
|
|
|
if not old_password:
|
|
raise RuntimeError("Old password is required.")
|
|
|
|
if not new_password:
|
|
raise RuntimeError("New password is required.")
|
|
|
|
command = "set.user.change_passwd"
|
|
|
|
salt, salt_response = minerops_api3_get_salt_sync(target_ip)
|
|
|
|
ts = int(time.time())
|
|
token = minerops_api3_token(command, old_password, salt, ts)
|
|
|
|
param_data = {
|
|
"account": account_to_change,
|
|
"new": new_password,
|
|
"old": old_password,
|
|
}
|
|
|
|
encrypted_param = minerops_api3_encrypt_param(
|
|
json.dumps(param_data, separators=(",", ":")),
|
|
command,
|
|
old_password,
|
|
salt,
|
|
ts,
|
|
)
|
|
|
|
payload = {
|
|
"cmd": command,
|
|
"ts": ts,
|
|
"token": token,
|
|
"account": account_to_change,
|
|
"param": encrypted_param,
|
|
}
|
|
|
|
result = minerops_api3_send_payload_sync(target_ip, payload)
|
|
response_json = result.get("response") or {}
|
|
|
|
result["salt_response"] = salt_response
|
|
result["api3_command"] = command
|
|
result["api3_account"] = account_to_change
|
|
result["passwords_logged"] = False
|
|
|
|
if response_json.get("code") != 0:
|
|
raise RuntimeError(f"API3 password change failed: {response_json}")
|
|
|
|
return result
|
|
|
|
|
|
async def minerops_api3_change_password(
|
|
target_ip: str,
|
|
account_to_change: str,
|
|
old_password: str,
|
|
new_password: str,
|
|
) -> dict:
|
|
import asyncio
|
|
|
|
return await asyncio.to_thread(
|
|
minerops_api3_change_password_sync,
|
|
target_ip,
|
|
account_to_change,
|
|
old_password,
|
|
new_password,
|
|
)
|
|
|
|
|
|
@app.post("/api/miners/{miner_id}/api3/change-password")
|
|
async def change_miner_api3_password(miner_id: str, request: Request):
|
|
minerops_command_require_bearer(request)
|
|
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
|
|
account = str(body.get("account") or "super").strip()
|
|
old_password = str(body.get("old_password") or "")
|
|
new_password = str(body.get("new_password") or "")
|
|
update_stored = bool(body.get("update_stored", True))
|
|
|
|
if not old_password or not new_password:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Old password and new password are required.",
|
|
)
|
|
|
|
target = await get_miner_command_target(miner_id)
|
|
|
|
if not target:
|
|
raise HTTPException(status_code=404, detail="Miner not found.")
|
|
|
|
target_ip = minerops_normalize_target_ip(target.get("target_ip"))
|
|
|
|
if not target_ip:
|
|
raise HTTPException(status_code=400, detail="Miner has no current IP address.")
|
|
|
|
user_id, username = await minerops_command_user_from_request(request)
|
|
|
|
request_event_id = await insert_miner_command_event(
|
|
miner_id=miner_id,
|
|
command_type="change_password",
|
|
command_status="password_change_requested",
|
|
dry_run=False,
|
|
target_ip=target_ip,
|
|
target_mac=target.get("target_mac"),
|
|
requested_by_user_id=user_id,
|
|
requested_by_username=username,
|
|
request_payload={
|
|
"account": account,
|
|
"update_stored": update_stored,
|
|
"passwords_logged": False,
|
|
},
|
|
response_payload={
|
|
"message": "Password change requested. Password values are not logged.",
|
|
},
|
|
)
|
|
|
|
try:
|
|
result = await minerops_api3_change_password(
|
|
target_ip,
|
|
account,
|
|
old_password,
|
|
new_password,
|
|
)
|
|
|
|
if update_stored:
|
|
await minerops_save_api3_credentials_for_miner(
|
|
miner_id,
|
|
account,
|
|
new_password,
|
|
username,
|
|
)
|
|
|
|
result_event_id = await insert_miner_command_event(
|
|
miner_id=miner_id,
|
|
command_type="change_password",
|
|
command_status="password_change_sent",
|
|
dry_run=False,
|
|
target_ip=target_ip,
|
|
target_mac=target.get("target_mac"),
|
|
requested_by_user_id=user_id,
|
|
requested_by_username=username,
|
|
request_payload={
|
|
"account": account,
|
|
"update_stored": update_stored,
|
|
"passwords_logged": False,
|
|
},
|
|
response_payload={
|
|
"message": "Password change command sent successfully.",
|
|
"api3_command": "set.user.change_passwd",
|
|
"account": account,
|
|
"stored_in_minerops": update_stored,
|
|
"miner_response": result.get("response"),
|
|
},
|
|
)
|
|
|
|
return {
|
|
"ok": True,
|
|
"event_id": result_event_id,
|
|
"request_event_id": request_event_id,
|
|
"miner_id": miner_id,
|
|
"target_ip": target_ip,
|
|
"target_mac": target.get("target_mac"),
|
|
"account": account,
|
|
"stored_in_minerops": update_stored,
|
|
"message": "Password changed. Password values were not logged.",
|
|
"response": result.get("response"),
|
|
}
|
|
except Exception as exc:
|
|
result_event_id = await insert_miner_command_event(
|
|
miner_id=miner_id,
|
|
command_type="change_password",
|
|
command_status="password_change_failed",
|
|
dry_run=False,
|
|
target_ip=target_ip,
|
|
target_mac=target.get("target_mac"),
|
|
requested_by_user_id=user_id,
|
|
requested_by_username=username,
|
|
request_payload={
|
|
"account": account,
|
|
"update_stored": update_stored,
|
|
"passwords_logged": False,
|
|
},
|
|
response_payload={
|
|
"message": "Password change failed. Password values are not logged.",
|
|
},
|
|
error_message=str(exc),
|
|
)
|
|
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail={
|
|
"message": "Password change failed.",
|
|
"event_id": result_event_id,
|
|
"error": str(exc),
|
|
},
|
|
)
|
|
|
|
|
|
MINEROPS_API3_CREDENTIALS_JS = r"""
|
|
(function () {
|
|
function getToken() {
|
|
const keys = [
|
|
"minerops_token",
|
|
"access_token",
|
|
"token",
|
|
"jwt",
|
|
"auth_token"
|
|
];
|
|
|
|
for (const key of keys) {
|
|
const localValue = localStorage.getItem(key);
|
|
if (localValue) return localValue;
|
|
|
|
const sessionValue = sessionStorage.getItem(key);
|
|
if (sessionValue) return sessionValue;
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function minerIdFromPage() {
|
|
const card = document.getElementById("minerControlCard");
|
|
|
|
if (card && card.dataset.minerId) {
|
|
return card.dataset.minerId;
|
|
}
|
|
|
|
const match = window.location.pathname.match(/\/miners\/([^\/]+)\/detail/);
|
|
|
|
if (match) {
|
|
return decodeURIComponent(match[1]);
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function setStatus(message, kind) {
|
|
const box = document.getElementById("minerControlStatus");
|
|
|
|
if (!box) {
|
|
alert(message);
|
|
return;
|
|
}
|
|
|
|
box.className = "status-box " + (kind || "");
|
|
box.textContent = message;
|
|
}
|
|
|
|
async function fetchJson(url, options) {
|
|
const response = await fetch(url, options || {});
|
|
let data = null;
|
|
|
|
try {
|
|
data = await response.json();
|
|
} catch (error) {
|
|
data = null;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
let message = response.status + " " + response.statusText;
|
|
|
|
if (data && data.detail) {
|
|
if (typeof data.detail === "string") {
|
|
message = data.detail;
|
|
} else if (data.detail.message) {
|
|
message = data.detail.message + (data.detail.error ? ": " + data.detail.error : "");
|
|
} else {
|
|
message = JSON.stringify(data.detail);
|
|
}
|
|
}
|
|
|
|
throw new Error(message);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
function ensureApi3Ui() {
|
|
const card = document.getElementById("minerControlCard");
|
|
|
|
if (!card || document.getElementById("api3CredentialBox")) {
|
|
return;
|
|
}
|
|
|
|
const reminder = document.createElement("div");
|
|
reminder.id = "webApiReminder";
|
|
reminder.className = "status-box warn";
|
|
reminder.style.marginTop = "12px";
|
|
reminder.innerHTML = "<strong>New miner reminder:</strong> enable WhatsMiner WebAPI/API3 before using Reboot, Suspend, Resume, or password changes. After adding or replacing a miner, enable WebAPI in WhatsMinerTool or the miner controls will not work.";
|
|
|
|
const box = document.createElement("div");
|
|
box.id = "api3CredentialBox";
|
|
box.className = "card";
|
|
box.style.marginTop = "12px";
|
|
box.innerHTML = [
|
|
"<h3>Miner API Password</h3>",
|
|
"<p class='small'>Change this miner's API password and save it in MinerOps for future Suspend/Resume commands. Password values are not written to command history.</p>",
|
|
"<div style='display:flex;flex-wrap:wrap;gap:8px;align-items:end;'>",
|
|
" <label>Account<br><input id='api3AccountInput' value='super' autocomplete='username' style='max-width:120px;'></label>",
|
|
" <label>Current password<br><input id='api3OldPasswordInput' type='password' autocomplete='current-password'></label>",
|
|
" <label>New password<br><input id='api3NewPasswordInput' type='password' autocomplete='new-password'></label>",
|
|
" <button id='changeMinerApiPasswordButton' class='danger' type='button'>Change Password</button>",
|
|
"</div>"
|
|
].join("");
|
|
|
|
card.appendChild(reminder);
|
|
card.appendChild(box);
|
|
|
|
const button = document.getElementById("changeMinerApiPasswordButton");
|
|
|
|
if (button) {
|
|
button.onclick = changeMinerApiPassword;
|
|
}
|
|
}
|
|
|
|
async function changeMinerApiPassword() {
|
|
const minerId = minerIdFromPage();
|
|
const token = getToken();
|
|
|
|
if (!minerId) {
|
|
setStatus("Could not detect miner ID.", "bad");
|
|
return;
|
|
}
|
|
|
|
if (!token) {
|
|
setStatus("Log in first.", "bad");
|
|
return;
|
|
}
|
|
|
|
const account = (document.getElementById("api3AccountInput") || {}).value || "super";
|
|
const oldPassword = (document.getElementById("api3OldPasswordInput") || {}).value || "";
|
|
const newPassword = (document.getElementById("api3NewPasswordInput") || {}).value || "";
|
|
|
|
if (!oldPassword || !newPassword) {
|
|
setStatus("Enter the current password and new password.", "bad");
|
|
return;
|
|
}
|
|
|
|
if (!confirm("Change the API password on this miner now?")) {
|
|
setStatus("Password change cancelled.", "warn");
|
|
return;
|
|
}
|
|
|
|
setStatus("Changing miner API password...", "warn");
|
|
|
|
try {
|
|
const data = await fetchJson("/api/miners/" + encodeURIComponent(minerId) + "/api3/change-password", {
|
|
method: "POST",
|
|
headers: {
|
|
"Authorization": "Bearer " + token,
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
account: account,
|
|
old_password: oldPassword,
|
|
new_password: newPassword,
|
|
update_stored: true
|
|
})
|
|
});
|
|
|
|
document.getElementById("api3OldPasswordInput").value = "";
|
|
document.getElementById("api3NewPasswordInput").value = "";
|
|
|
|
setStatus(data.message || "Password changed and saved for this miner.", "ok");
|
|
|
|
if (typeof window.loadMinerCommandHistory === "function") {
|
|
window.loadMinerCommandHistory();
|
|
}
|
|
} catch (error) {
|
|
setStatus("Password change error: " + error.message, "bad");
|
|
|
|
if (typeof window.loadMinerCommandHistory === "function") {
|
|
window.loadMinerCommandHistory();
|
|
}
|
|
}
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|
ensureApi3Ui();
|
|
setTimeout(ensureApi3Ui, 500);
|
|
setTimeout(ensureApi3Ui, 1500);
|
|
});
|
|
})();
|
|
"""
|
|
|
|
|
|
@app.get("/assets/minerops-api3-credentials.js")
|
|
async def minerops_api3_credentials_js():
|
|
return Response(
|
|
content=MINEROPS_API3_CREDENTIALS_JS,
|
|
media_type="application/javascript",
|
|
)
|
|
|