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,
COALESCE(
raw_summary_json #>> '{Msg,Power Mode}',
raw_summary_json #>> '{MSG,Power Mode}',
raw_summary_json #>> '{msg,Power Mode}',
raw_summary_json ->> 'Power Mode',
raw_summary_json ->> 'PowerMode',
raw_summary_json ->> 'power_mode'
) AS performance_mode,
raw_summary_json,
created_at
FROM miner_status_snapshots
WHERE miner_id = %s
ORDER BY observed_at DESC
LIMIT 100;
""",
(miner_id,),
)
snapshots = [dict(row) for row in cur.fetchall()]
return api_response(data=snapshots)
finally:
if conn is not None:
conn.close()
@app.get("/api/miners/{miner_id}/configured-performance-mode")
def get_miner_configured_performance_mode(miner_id: str, current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
WITH latest AS (
SELECT
COALESCE(
raw_summary_json #>> '{Msg,Power Mode}',
raw_summary_json #>> '{MSG,Power Mode}',
raw_summary_json #>> '{msg,Power Mode}',
raw_summary_json ->> 'Power Mode',
raw_summary_json ->> 'PowerMode',
raw_summary_json ->> 'power_mode'
) AS runtime_power_state,
created_at AS runtime_power_state_at
FROM miner_status_snapshots
WHERE miner_id = %s
ORDER BY observed_at DESC
LIMIT 1
)
SELECT
miners.id::text AS miner_id,
INITCAP(miners.configured_performance_mode) AS configured_performance_mode,
miners.configured_performance_mode_source,
miners.configured_performance_mode_updated_at,
latest.runtime_power_state,
latest.runtime_power_state_at
FROM miners
LEFT JOIN latest ON true
WHERE miners.id = %s;
""",
(miner_id, miner_id),
)
row = cur.fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Miner not found.")
return api_response(data=dict(row))
finally:
if conn is not None:
conn.close()
class DiscoverOneMinerRequest(BaseModel):
ip_address: str
port: int = Field(default=4028, ge=1, le=65535)
scan_range_id: str | None = None
timeout_ms: int = Field(default=5000, ge=500, le=30000)
def clean_ip_text(value: Any) -> str | None:
if value is None:
return None
return str(value).split("/")[0]
def to_bool_or_none(value: Any) -> bool | None:
if value is None:
return None
if isinstance(value, bool):
return value
text = str(value).strip().lower()
if text in ("true", "1", "yes", "y", "on"):
return True
if text in ("false", "0", "no", "n", "off"):
return False
return None
def get_msg_dict(result: dict, command_name: str) -> dict:
if not result.get("success"):
raise HTTPException(status_code=502, detail=f"{command_name} failed: {result}")
parsed_json = result.get("parsed_json")
if not isinstance(parsed_json, dict):
raise HTTPException(status_code=502, detail=f"{command_name} did not return JSON")
if parsed_json.get("STATUS") == "E":
raise HTTPException(status_code=502, detail=f"{command_name} returned error: {parsed_json}")
msg = parsed_json.get("Msg", {})
if isinstance(msg, dict):
return msg
return {}
def find_scan_range_context(cur, ip_address: str, scan_range_id: str | None) -> dict:
if scan_range_id:
cur.execute(
"""
SELECT
scan_ranges.id::text AS scan_range_id,
scan_ranges.room_id::text,
rooms.site_id::text,
rooms.name AS room_name,
scan_ranges.name AS scan_range_name
FROM scan_ranges
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
WHERE scan_ranges.id = %s;
""",
(scan_range_id,),
)
row = cur.fetchone()
if row:
return dict(row)
cur.execute(
"""
SELECT
scan_ranges.id::text AS scan_range_id,
scan_ranges.room_id::text,
rooms.site_id::text,
rooms.name AS room_name,
scan_ranges.name AS scan_range_name,
scan_ranges.range_type,
scan_ranges.cidr,
host(scan_ranges.start_ip) AS start_ip,
scan_ranges.end_ip::text
FROM scan_ranges
LEFT JOIN rooms ON rooms.id = scan_ranges.room_id
WHERE scan_ranges.enabled = true;
"""
)
ip_obj = ipaddress.ip_address(ip_address)
for row in cur.fetchall():
row_dict = dict(row)
if row_dict["range_type"] == "cidr" and row_dict["cidr"]:
network = ipaddress.ip_network(row_dict["cidr"], strict=False)
if ip_obj in network:
return row_dict
if row_dict["range_type"] == "start_end" and row_dict["start_ip"] and row_dict["end_ip"]:
start_ip = ipaddress.ip_address(clean_ip_text(row_dict["start_ip"]))
end_ip = ipaddress.ip_address(clean_ip_text(row_dict["end_ip"]))
if int(start_ip) <= int(ip_obj) <= int(end_ip):
return row_dict
return {
"scan_range_id": scan_range_id,
"room_id": None,
"site_id": None,
"room_name": None,
"scan_range_name": None,
}
def find_existing_miner(cur, miner_sn: str | None, mac_address: str | None, ip_address: str) -> dict | None:
if miner_sn:
cur.execute(
"""
SELECT id::text
FROM miners
WHERE miner_sn = %s
LIMIT 1;
""",
(miner_sn,),
)
row = cur.fetchone()
if row:
return dict(row)
if mac_address:
cur.execute(
"""
SELECT id::text
FROM miners
WHERE lower(current_mac) = lower(%s)
LIMIT 1;
""",
(mac_address,),
)
row = cur.fetchone()
if row:
return dict(row)
cur.execute(
"""
SELECT id::text
FROM miners
WHERE current_ip = %s
LIMIT 1;
""",
(ip_address,),
)
row = cur.fetchone()
if row:
return dict(row)
return None
@app.post("/api/miners/discover-one")
def discover_one_miner(request: DiscoverOneMinerRequest, current_user: dict = Depends(get_current_user)):
try:
ip_address = str(ipaddress.ip_address(request.ip_address))
except ValueError as exc:
raise HTTPException(status_code=422, detail=f"Invalid IP address: {exc}")
miner_info_result = send_whatsminer_read_command(
ip_address=ip_address,
port=request.port,
command="get_miner_info",
timeout_ms=request.timeout_ms,
params={
"info": "ip,proto,netmask,gateway,dns,hostname,mac,ledstat,minersn,powersn"
},
)
version_result = send_whatsminer_read_command(
ip_address=ip_address,
port=request.port,
command="get_version",
timeout_ms=request.timeout_ms,
)
summary_result = send_whatsminer_read_command(
ip_address=ip_address,
port=request.port,
command="summary",
timeout_ms=request.timeout_ms,
)
edevs_result = send_whatsminer_read_command(
ip_address=ip_address,
port=request.port,
command="edevs",
timeout_ms=request.timeout_ms,
)
pools_result = send_whatsminer_read_command(
ip_address=ip_address,
port=request.port,
command="pools",
timeout_ms=request.timeout_ms,
)
miner_info_json = miner_info_result.get("parsed_json") or {}
version_json = version_result.get("parsed_json") or {}
summary_json = summary_result.get("parsed_json") or {}
edevs_json = edevs_result.get("parsed_json") or {}
pools_json = pools_result.get("parsed_json") or {}
miner_info_msg = get_msg_dict(miner_info_result, "get_miner_info")
version_msg = get_msg_dict(version_result, "get_version")
try:
summary_msg = get_msg_dict(summary_result, "summary")
summary_warning = None
except HTTPException as exc:
summary_msg = {}
summary_warning = exc.detail
mac_address = miner_info_msg.get("mac")
if mac_address:
mac_address = str(mac_address).upper()
hostname = miner_info_msg.get("hostname")
miner_sn = miner_info_msg.get("minersn")
power_sn = miner_info_msg.get("powersn")
miner_type = version_msg.get("miner_type")
model = miner_type.split("_")[0] if miner_type else None
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
context = find_scan_range_context(cur, ip_address, request.scan_range_id)
existing_miner = find_existing_miner(
cur=cur,
miner_sn=miner_sn,
mac_address=mac_address,
ip_address=ip_address,
)
if existing_miner:
miner_id = existing_miner["id"]
cur.execute(
"""
UPDATE miners
SET site_id = COALESCE(%s, site_id),
room_id = COALESCE(%s, room_id),
current_mac = %s,
current_ip = %s,
hostname = %s,
miner_sn = %s,
power_sn = %s,
model = %s,
miner_type = %s,
api_version = %s,
firmware_version = %s,
platform = %s,
chip = %s,
status = 'online',
last_seen_at = now(),
updated_at = now()
WHERE id = %s
RETURNING id::text;
""",
(
context.get("site_id"),
context.get("room_id"),
mac_address,
ip_address,
hostname,
miner_sn,
power_sn,
model,
miner_type,
version_msg.get("api_ver"),
version_msg.get("fw_ver"),
version_msg.get("platform"),
version_msg.get("chip"),
miner_id,
),
)
miner_id = cur.fetchone()["id"]
action = "miner.updated"
else:
cur.execute(
"""
INSERT INTO miners (
site_id,
room_id,
current_mac,
current_ip,
hostname,
miner_sn,
power_sn,
model,
miner_type,
api_version,
firmware_version,
platform,
chip,
status
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'online')
RETURNING id::text;
""",
(
context.get("site_id"),
context.get("room_id"),
mac_address,
ip_address,
hostname,
miner_sn,
power_sn,
model,
miner_type,
version_msg.get("api_ver"),
version_msg.get("fw_ver"),
version_msg.get("platform"),
version_msg.get("chip"),
),
)
miner_id = cur.fetchone()["id"]
action = "miner.created"
if mac_address:
cur.execute(
"""
UPDATE miner_network_identities
SET is_current = false,
updated_at = now()
WHERE miner_id = %s;
""",
(miner_id,),
)
cur.execute(
"""
SELECT id::text
FROM miner_network_identities
WHERE miner_id = %s
AND lower(mac_address) = lower(%s)
LIMIT 1;
""",
(miner_id, mac_address),
)
identity = cur.fetchone()
if identity:
cur.execute(
"""
UPDATE miner_network_identities
SET ip_address = %s,
hostname = %s,
source = 'scanner',
is_current = true,
last_seen_at = now(),
updated_at = now()
WHERE id = %s;
""",
(ip_address, hostname, identity["id"]),
)
else:
cur.execute(
"""
INSERT INTO miner_network_identities (
miner_id,
mac_address,
ip_address,
hostname,
source,
is_current
)
VALUES (%s, %s, %s, %s, 'scanner', true);
""",
(miner_id, mac_address, ip_address, hostname),
)
for board in edevs_json.get("DEVS", []):
slot = board.get("Slot")
if slot is None:
continue
cur.execute(
"""
INSERT INTO miner_hashboards (
miner_id,
slot,
pcb_sn,
chip_data,
temperature_c,
chip_frequency,
mhs_av,
mhs_1m,
mhs_15m,
hs_rt,
factory_ghs,
effective_chips,
upfreq_complete,
raw_json
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT ON CONSTRAINT uq_miner_hashboards_miner_slot
DO UPDATE SET
pcb_sn = EXCLUDED.pcb_sn,
chip_data = EXCLUDED.chip_data,
temperature_c = EXCLUDED.temperature_c,
chip_frequency = EXCLUDED.chip_frequency,
mhs_av = EXCLUDED.mhs_av,
mhs_1m = EXCLUDED.mhs_1m,
mhs_15m = EXCLUDED.mhs_15m,
hs_rt = EXCLUDED.hs_rt,
factory_ghs = EXCLUDED.factory_ghs,
effective_chips = EXCLUDED.effective_chips,
upfreq_complete = EXCLUDED.upfreq_complete,
raw_json = EXCLUDED.raw_json,
last_seen_at = now(),
updated_at = now();
""",
(
miner_id,
int(slot),
board.get("PCB SN"),
board.get("Chip Data"),
board.get("Temperature"),
board.get("Chip Frequency"),
board.get("MHS av"),
board.get("MHS 1m"),
board.get("MHS 15m"),
board.get("HS RT"),
board.get("Factory GHS"),
board.get("Effective Chips"),
board.get("Upfreq Complete"),
psycopg.types.json.Jsonb(json_safe(board)),
),
)
cur.execute(
"""
INSERT INTO miner_status_snapshots (
miner_id,
observed_ip,
mhs_av,
mhs_1m,
mhs_15m,
hs_rt,
power_w,
power_rate_w_per_th,
env_temp_c,
chip_temp_min_c,
chip_temp_max_c,
chip_temp_avg_c,
fan_speed_in_rpm,
fan_speed_out_rpm,
uptime_seconds,
hash_stable,
pool_rejected_percent,
raw_summary_json,
raw_version_json,
raw_miner_info_json
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);
""",
(
miner_id,
ip_address,
summary_msg.get("MHS av"),
summary_msg.get("MHS 1m"),
summary_msg.get("MHS 15m"),
summary_msg.get("HS RT"),
summary_msg.get("Power"),
summary_msg.get("Power Rate"),
summary_msg.get("Env Temp"),
summary_msg.get("Chip Temp Min"),
summary_msg.get("Chip Temp Max"),
summary_msg.get("Chip Temp Avg"),
summary_msg.get("Fan Speed In"),
summary_msg.get("Fan Speed Out"),
summary_msg.get("Uptime"),
to_bool_or_none(summary_msg.get("Hash Stable")),
summary_msg.get("Pool Rejected%"),
psycopg.types.json.Jsonb(json_safe(summary_json)),
psycopg.types.json.Jsonb(json_safe(version_json)),
psycopg.types.json.Jsonb(json_safe(miner_info_json)),
),
)
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
VALUES (%s, %s, 'miner', %s, %s);
""",
(
current_user["id"],
action,
miner_id,
psycopg.types.json.Jsonb(
{
"ip_address": ip_address,
"mac_address": mac_address,
"miner_sn": miner_sn,
"power_sn": power_sn,
"miner_type": miner_type,
"firmware_version": version_msg.get("fw_ver"),
"room_id": context.get("room_id"),
"scan_range_id": context.get("scan_range_id"),
}
),
),
)
cur.execute(
"""
SELECT
miners.id::text,
miners.site_id::text,
sites.name AS site_name,
miners.room_id::text,
rooms.name AS room_name,
miners.current_mac,
host(miners.current_ip) AS current_ip,
miners.hostname,
miners.miner_sn,
miners.power_sn,
miners.model,
miners.miner_type,
miners.api_version,
miners.firmware_version,
miners.platform,
miners.chip,
miners.status,
miners.first_seen_at,
miners.last_seen_at,
miners.updated_at
FROM miners
LEFT JOIN sites ON sites.id = miners.site_id
LEFT JOIN rooms ON rooms.id = miners.room_id
WHERE miners.id = %s;
""",
(miner_id,),
)
miner = dict(cur.fetchone())
conn.commit()
return api_response(
data={
"action": action,
"miner": miner,
"summary": {
"ip_address": ip_address,
"mac_address": mac_address,
"miner_sn": miner_sn,
"power_sn": power_sn,
"miner_type": miner_type,
"firmware_version": version_msg.get("fw_ver"),
"hashboards_seen": len(edevs_json.get("DEVS", [])),
"pools_seen": len(pools_json.get("POOLS", [])),
"hashrate_mhs_av": summary_msg.get("MHS av"),
"power_w": summary_msg.get("Power"),
"chip_temp_max_c": summary_msg.get("Chip Temp Max"),
"summary_warning": summary_warning,
},
}
)
except HTTPException:
if conn is not None:
conn.rollback()
raise
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
@app.post("/api/scan-ranges/{scan_range_id}/discover-now")
def discover_scan_range_now(scan_range_id: str, request: ScanNowRequest, current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
id::text,
room_id::text,
name,
range_type,
cidr,
host(start_ip) AS start_ip,
host(end_ip) AS end_ip,
enabled,
timeout_ms,
retry_limit,
parallelism_limit,
stagger_ms,
api_port,
scan_interval_seconds
FROM scan_ranges
WHERE id = %s;
""",
(scan_range_id,),
)
scan_range = cur.fetchone()
if not scan_range:
raise HTTPException(status_code=404, detail="Scan range not found")
if not scan_range["enabled"]:
raise HTTPException(status_code=409, detail="Scan range is disabled")
ip_list = expand_scan_range_for_scan(dict(scan_range), request.max_ips, request.specific_ips)
cur.execute(
"""
INSERT INTO scan_runs (
scan_range_id,
triggered_by,
triggered_by_user_id,
status,
total_ips,
scanned_ips,
miners_found,
errors_count,
notes
)
VALUES (%s, 'manual', %s, 'running', %s, 0, 0, 0, %s)
RETURNING id::text;
""",
(
scan_range_id,
current_user["id"],
len(ip_list),
"TCP port 4028 scan with read-only miner discovery",
),
)
scan_run_id = cur.fetchone()["id"]
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
VALUES (%s, 'scan.discovery.started', 'scan_run', %s, %s);
""",
(
current_user["id"],
scan_run_id,
psycopg.types.json.Jsonb(
{
"scan_range_id": scan_range_id,
"scan_range_name": scan_range["name"],
"total_ips": len(ip_list),
"mode": "tcp_4028_then_whatsminer_read_only_discovery",
}
),
),
)
conn.commit()
except HTTPException:
if conn is not None:
conn.rollback()
raise
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
results = []
detected_count = 0
max_workers = min(scan_range["parallelism_limit"], len(ip_list))
if max_workers < 1:
max_workers = 1
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_map = {
executor.submit(
tcp_check_ip,
ip,
scan_range["api_port"],
scan_range["timeout_ms"],
): ip
for ip in ip_list
}
for future in as_completed(future_map):
result = future.result()
results.append(result)
if result["detected"]:
detected_count += 1
results = sorted(results, key=lambda item: ipaddress.ip_address(item["ip_address"]))
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
for result in results:
cur.execute(
"""
INSERT INTO scan_results (
scan_run_id,
ip_address,
port,
detected,
response_ms,
status,
error_message
)
VALUES (%s, %s, %s, %s, %s, %s, %s);
""",
(
scan_run_id,
result["ip_address"],
result["port"],
result["detected"],
result["response_ms"],
result["status"],
result["error_message"],
),
)
cur.execute(
"""
UPDATE scan_ranges
SET last_scan_at = now(),
next_scan_at = now() + make_interval(secs => scan_interval_seconds),
updated_at = now()
WHERE id = %s;
""",
(scan_range_id,),
)
cur.execute(
"""
UPDATE scan_runs
SET scanned_ips = %s,
miners_found = %s,
errors_count = 0
WHERE id = %s;
""",
(
len(results),
detected_count,
scan_run_id,
),
)
conn.commit()
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
discoveries = []
discovery_errors = []
for result in results:
if not result["detected"]:
continue
try:
discovery_response = discover_one_miner(
DiscoverOneMinerRequest(
ip_address=result["ip_address"],
port=result["port"],
scan_range_id=scan_range_id,
timeout_ms=scan_range["timeout_ms"],
),
current_user=current_user,
)
discoveries.append(discovery_response["data"])
except HTTPException as exc:
discovery_errors.append(
{
"ip_address": result["ip_address"],
"status_code": exc.status_code,
"detail": exc.detail,
}
)
except Exception as exc:
discovery_errors.append(
{
"ip_address": result["ip_address"],
"status_code": 500,
"detail": str(exc),
}
)
final_status = "completed"
if discovery_errors:
final_status = "completed_with_errors"
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
UPDATE scan_runs
SET finished_at = now(),
status = %s,
miners_found = %s,
errors_count = %s,
notes = %s
WHERE id = %s
RETURNING
id::text,
scan_range_id::text,
triggered_by,
started_at,
finished_at,
status,
total_ips,
scanned_ips,
miners_found,
errors_count,
notes;
""",
(
final_status,
len(discoveries),
len(discovery_errors),
"TCP port 4028 scan with read-only miner discovery",
scan_run_id,
),
)
scan_run = cur.fetchone()
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
VALUES (%s, 'scan.discovery.completed', 'scan_run', %s, %s);
""",
(
current_user["id"],
scan_run_id,
psycopg.types.json.Jsonb(
{
"scan_range_id": scan_range_id,
"total_ips": len(ip_list),
"scanned_ips": len(results),
"port_4028_open": detected_count,
"miners_discovered": len(discoveries),
"discovery_errors": len(discovery_errors),
}
),
),
)
conn.commit()
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
return api_response(
data={
"scan_run": dict(scan_run),
"summary": {
"scan_range_id": scan_range_id,
"scan_range_name": scan_range["name"],
"total_ips": len(ip_list),
"scanned_ips": len(results),
"port_4028_open": detected_count,
"miners_discovered": len(discoveries),
"discovery_errors": len(discovery_errors),
},
"discoveries": discoveries,
"discovery_errors": discovery_errors,
}
)
@app.get("/api/dashboard/summary")
def dashboard_summary(current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
COUNT(*) AS total_miners,
COUNT(*) FILTER (WHERE status = 'online') AS online_miners,
COUNT(*) FILTER (WHERE current_mac IS NULL OR current_mac = '') AS missing_mac_count,
COUNT(*) FILTER (WHERE miner_type IS NULL OR miner_type = '') AS missing_miner_type_count,
COUNT(*) FILTER (WHERE firmware_version IS NULL OR firmware_version = '') AS missing_firmware_count,
COUNT(*) FILTER (WHERE last_seen_at >= now() - interval '30 minutes') AS seen_last_30_minutes,
COUNT(*) FILTER (WHERE last_seen_at < now() - interval '30 minutes') AS not_seen_last_30_minutes
FROM miners;
"""
)
totals = dict(cur.fetchone())
cur.execute(
"""
SELECT
COALESCE(rooms.name, 'Unassigned') AS room_name,
COUNT(miners.id) AS miner_count,
COUNT(miners.id) FILTER (WHERE miners.status = 'online') AS online_count,
COUNT(miners.id) FILTER (WHERE miners.last_seen_at >= now() - interval '30 minutes') AS seen_last_30_minutes
FROM miners
LEFT JOIN rooms ON rooms.id = miners.room_id
GROUP BY rooms.name, rooms.sort_order
ORDER BY rooms.sort_order, rooms.name;
"""
)
by_room = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT
COALESCE(NULLIF(miner_type, ''), NULLIF(model, ''), 'Unknown') AS model,
COUNT(*) AS miner_count
FROM miners
GROUP BY COALESCE(NULLIF(miner_type, ''), NULLIF(model, ''), 'Unknown')
ORDER BY miner_count DESC, model;
"""
)
by_model = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT
COALESCE(NULLIF(firmware_version, ''), 'Unknown') AS firmware_version,
COUNT(*) AS miner_count
FROM miners
GROUP BY COALESCE(NULLIF(firmware_version, ''), 'Unknown')
ORDER BY miner_count DESC, firmware_version;
"""
)
by_firmware = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT
COALESCE(NULLIF(status, ''), 'Unknown') AS status,
COUNT(*) AS miner_count
FROM miners
GROUP BY COALESCE(NULLIF(status, ''), 'Unknown')
ORDER BY miner_count DESC, status;
"""
)
by_status = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
WITH latest AS (
SELECT DISTINCT ON (miner_id)
miner_id,
observed_at,
power_w,
power_rate_w_per_th,
chip_temp_max_c,
chip_temp_avg_c,
mhs_av,
hash_stable
FROM miner_status_snapshots
ORDER BY miner_id, observed_at DESC
)
SELECT
COUNT(*) AS miners_with_snapshots,
ROUND(AVG(power_w)::numeric, 2)::float AS avg_power_w,
ROUND(SUM(power_w)::numeric, 2)::float AS total_power_w,
ROUND(AVG(power_rate_w_per_th)::numeric, 2)::float AS avg_power_rate_w_per_th,
ROUND(MAX(chip_temp_max_c)::numeric, 2)::float AS hottest_chip_temp_c,
ROUND(AVG(chip_temp_avg_c)::numeric, 2)::float AS avg_chip_temp_c,
ROUND(SUM(mhs_av)::numeric / 1000000, 2)::float AS total_hashrate_ths,
COUNT(*) FILTER (WHERE chip_temp_max_c >= 90) AS miners_chip_temp_90c_or_higher,
COUNT(*) FILTER (WHERE hash_stable = false) AS miners_hash_unstable
FROM latest;
"""
)
telemetry = dict(cur.fetchone())
cur.execute(
"""
SELECT
COUNT(DISTINCT miner_id) AS miners_with_hashboards,
COUNT(*) AS total_hashboards,
COUNT(*) FILTER (WHERE temperature_c >= 90) AS hashboards_90c_or_higher,
COUNT(*) FILTER (WHERE pcb_sn IS NULL OR pcb_sn = '') AS hashboards_missing_pcb_sn
FROM miner_hashboards;
"""
)
hashboards = dict(cur.fetchone())
return api_response(
data={
"totals": totals,
"by_room": by_room,
"by_model": by_model,
"by_firmware": by_firmware,
"by_status": by_status,
"telemetry": telemetry,
"hashboards": hashboards,
}
)
finally:
if conn is not None:
conn.close()
from fastapi import File, Query, UploadFile
import csv
import io
def csv_value(row: dict, possible_names: list[str]) -> str | None:
for name in possible_names:
if name in row and row[name] is not None:
value = str(row[name]).strip()
if value:
return value
return None
def normalize_mac_for_match(mac: str | None) -> str | None:
if not mac:
return None
cleaned = "".join(ch for ch in mac.lower() if ch in "0123456789abcdef")
if len(cleaned) != 12:
return None
return cleaned
def model_from_miner_type(miner_type: str | None) -> str | None:
if not miner_type:
return None
if "_" in miner_type:
return miner_type.split("_", 1)[0]
return miner_type
@app.post("/api/imports/whatsminer-tool-scan")
async def import_whatsminer_tool_scan(
dry_run: bool = Query(default=True),
update_existing: bool = Query(default=False),
file: UploadFile = File(...),
current_user: dict = Depends(get_current_user),
):
file_bytes = await file.read()
try:
csv_text = file_bytes.decode("utf-8-sig")
except UnicodeDecodeError:
csv_text = file_bytes.decode("latin-1")
reader = csv.DictReader(io.StringIO(csv_text))
rows = list(reader)
if not rows:
raise HTTPException(status_code=422, detail="CSV file has no rows")
matched_count = 0
unmatched_count = 0
rows_with_updates = 0
updates_applied = 0
skipped_no_useful_data = 0
update_samples = []
unmatched_samples = []
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
for row_number, row in enumerate(rows, start=2):
ip_address = csv_value(row, ["IP", "IP Address", "ip", "ip_address"])
mac_address = csv_value(row, ["MAC Addr", "MAC", "Mac", "mac", "mac_address"])
miner_type = csv_value(row, ["Miner Type", "MinerType", "Model", "model", "miner_type"])
firmware_version = csv_value(row, ["Firmware", "Firmware Version", "FW Version", "fw_ver", "firmware_version"])
if ip_address:
ip_address = clean_ip_text(ip_address)
normalized_mac = normalize_mac_for_match(mac_address)
model = model_from_miner_type(miner_type)
if not ip_address and not normalized_mac:
skipped_no_useful_data += 1
continue
miner = None
if normalized_mac:
cur.execute(
"""
SELECT
id::text,
host(current_ip) AS current_ip,
current_mac,
model,
miner_type,
firmware_version
FROM miners
WHERE regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g') = %s
LIMIT 1;
""",
(normalized_mac,),
)
miner = cur.fetchone()
if not miner and ip_address:
cur.execute(
"""
SELECT
id::text,
host(current_ip) AS current_ip,
current_mac,
model,
miner_type,
firmware_version
FROM miners
WHERE host(current_ip) = %s
LIMIT 1;
""",
(ip_address,),
)
miner = cur.fetchone()
if not miner:
unmatched_count += 1
if len(unmatched_samples) < 20:
unmatched_samples.append(
{
"row_number": row_number,
"ip_address": ip_address,
"mac_address": mac_address,
"miner_type": miner_type,
"firmware_version": firmware_version,
}
)
continue
matched_count += 1
set_clauses = []
params = []
proposed_changes = {}
if miner_type and (update_existing or not miner["miner_type"]):
set_clauses.append("miner_type = %s")
params.append(miner_type)
proposed_changes["miner_type"] = {
"old": miner["miner_type"],
"new": miner_type,
}
if model and (update_existing or not miner["model"]):
set_clauses.append("model = %s")
params.append(model)
proposed_changes["model"] = {
"old": miner["model"],
"new": model,
}
if firmware_version and (update_existing or not miner["firmware_version"]):
set_clauses.append("firmware_version = %s")
params.append(firmware_version)
proposed_changes["firmware_version"] = {
"old": miner["firmware_version"],
"new": firmware_version,
}
if not set_clauses:
continue
rows_with_updates += 1
if len(update_samples) < 50:
update_samples.append(
{
"row_number": row_number,
"miner_id": miner["id"],
"ip_address": miner["current_ip"],
"current_mac": miner["current_mac"],
"changes": proposed_changes,
}
)
if not dry_run:
set_sql = ", ".join(set_clauses)
params.append(miner["id"])
cur.execute(
f"""
UPDATE miners
SET {set_sql},
updated_at = now()
WHERE id = %s;
""",
tuple(params),
)
updates_applied += 1
if not dry_run:
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
VALUES (%s, 'import.whatsminertool_csv.applied', 'import', NULL, %s);
""",
(
current_user["id"],
psycopg.types.json.Jsonb(
{
"filename": file.filename,
"rows_read": len(rows),
"matched_count": matched_count,
"unmatched_count": unmatched_count,
"rows_with_updates": rows_with_updates,
"updates_applied": updates_applied,
"update_existing": update_existing,
}
),
),
)
if dry_run:
conn.rollback()
else:
conn.commit()
return api_response(
data={
"filename": file.filename,
"dry_run": dry_run,
"update_existing": update_existing,
"rows_read": len(rows),
"matched_count": matched_count,
"unmatched_count": unmatched_count,
"skipped_no_useful_data": skipped_no_useful_data,
"rows_with_updates": rows_with_updates,
"updates_applied": updates_applied,
"update_samples": update_samples,
"unmatched_samples": unmatched_samples,
}
)
except HTTPException:
if conn is not None:
conn.rollback()
raise
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
from fastapi.responses import HTMLResponse, Response
@app.get("/tools/whatsminer-import", response_class=HTMLResponse)
def whatsminer_import_tool_page():
return """
MinerOps - WhatsMinerTool CSV Import
New miner WebAPI reminder: Before importing or adding new WhatsMiners, enable WebAPI/API3 in WhatsMinerTool. Reboot, Suspend, Resume, and password changes need WebAPI enabled.
MinerOps - WhatsMinerTool CSV Import
1. Login
Not logged in.
2. Upload CSV
Choose a CSV and preview first.
3. Preview Summary
Rows Read
-
Matched
-
Unmatched
-
Rows With Updates
-
Updates Applied
-
Raw Result
No result yet.
"""
class ScheduledBulkActionCreateRequest(BaseModel):
name: str = ""
target_type: str = "unit"
target_value: str = ""
action: str = "performance_mode"
performance_mode: str = ""
repeat_rule: str = "once"
scheduled_for: str
def minerops_ensure_scheduled_bulk_actions_table():
conn = None
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS minerops_scheduled_bulk_actions (
id TEXT PRIMARY KEY,
name TEXT,
target_type TEXT NOT NULL,
target_value TEXT,
action TEXT NOT NULL DEFAULT 'performance_mode',
performance_mode TEXT,
repeat_rule TEXT NOT NULL DEFAULT 'once',
scheduled_for TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
agent_id TEXT NOT NULL DEFAULT 'plexus-windows-agent',
requested_by_user_id TEXT,
requested_by_username TEXT,
queued_command_count INTEGER NOT NULL DEFAULT 0,
last_queued_command_count INTEGER NOT NULL DEFAULT 0,
run_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ
);
ALTER TABLE minerops_scheduled_bulk_actions
ALTER COLUMN performance_mode DROP NOT NULL;
ALTER TABLE minerops_scheduled_bulk_actions
ADD COLUMN IF NOT EXISTS repeat_rule TEXT NOT NULL DEFAULT 'once',
ADD COLUMN IF NOT EXISTS last_queued_command_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS run_count INTEGER NOT NULL DEFAULT 0;
UPDATE minerops_scheduled_bulk_actions
SET repeat_rule = 'once'
WHERE repeat_rule IS NULL OR repeat_rule = '';
CREATE INDEX IF NOT EXISTS idx_minerops_scheduled_bulk_actions_due
ON minerops_scheduled_bulk_actions(status, scheduled_for);
CREATE INDEX IF NOT EXISTS idx_minerops_scheduled_bulk_actions_created
ON minerops_scheduled_bulk_actions(created_at DESC);
"""
)
conn.commit()
finally:
if conn is not None:
conn.close()
def minerops_next_scheduled_run(current_dt, repeat_rule):
import datetime as _datetime
repeat_rule = (repeat_rule or "once").lower()
if repeat_rule == "once":
return None
candidate = current_dt + _datetime.timedelta(days=1)
if repeat_rule == "daily":
return candidate
if repeat_rule == "weekdays":
while candidate.weekday() >= 5:
candidate = candidate + _datetime.timedelta(days=1)
return candidate
if repeat_rule == "weekends":
while candidate.weekday() < 5:
candidate = candidate + _datetime.timedelta(days=1)
return candidate
return None
def minerops_scheduled_action_label(action, performance_mode):
action = (action or "").lower()
performance_mode = (performance_mode or "").lower()
if action == "performance_mode":
return f"Set {performance_mode} mode"
if action == "suspend":
return "Suspend"
if action == "resume":
return "Resume"
return action or "unknown"
def minerops_process_due_scheduled_bulk_actions():
import json as _json
import uuid as _uuid
minerops_ensure_scheduled_bulk_actions_table()
conn = None
processed = []
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT *
FROM minerops_scheduled_bulk_actions
WHERE status = 'pending'
AND scheduled_for <= now()
ORDER BY scheduled_for ASC
LIMIT 10
FOR UPDATE SKIP LOCKED;
"""
)
due_actions = [dict(row) for row in cur.fetchall()]
for action in due_actions:
action_id = action["id"]
target_type = (action.get("target_type") or "").lower()
target_value = action.get("target_value") or ""
command_action = (action.get("action") or "performance_mode").lower()
performance_mode = (action.get("performance_mode") or "").lower()
repeat_rule = (action.get("repeat_rule") or "once").lower()
try:
if command_action not in ("performance_mode", "suspend", "resume"):
raise ValueError("Invalid scheduled action.")
if command_action == "performance_mode" and performance_mode not in ("low", "normal", "high"):
raise ValueError("Invalid performance mode.")
cur.execute(
"""
UPDATE minerops_scheduled_bulk_actions
SET status = 'running',
updated_at = now(),
last_error = NULL
WHERE id = %s;
""",
(action_id,),
)
params = []
where_extra = ""
if target_type == "all":
where_extra = ""
elif target_type == "unit":
where_extra = "AND rooms.name = %s"
params.append(target_value)
elif target_type == "model":
where_extra = "AND (miners.miner_type = %s OR miners.model = %s)"
params.extend([target_value, target_value])
elif target_type == "configured_mode":
where_extra = "AND lower(COALESCE(miners.configured_performance_mode, '')) = %s"
params.append(target_value.lower())
elif target_type == "selected_miners":
selected_ids = [
item.strip()
for item in str(target_value or "").replace("\n", ",").split(",")
if item.strip()
]
if not selected_ids:
raise ValueError("No selected miners were saved with this schedule.")
placeholders = ",".join(["%s"] * len(selected_ids))
where_extra = f"AND miners.id::text IN ({placeholders})"
params.extend(selected_ids)
else:
raise ValueError("Invalid target type.")
cur.execute(
f"""
SELECT
miners.id::text AS miner_id,
host(miners.current_ip) AS ip_address,
miners.current_mac AS mac_address,
rooms.name AS room_name
FROM miners
LEFT JOIN rooms ON rooms.id = miners.room_id
WHERE miners.current_ip IS NOT NULL
{where_extra}
ORDER BY rooms.sort_order, miners.current_ip;
""",
tuple(params),
)
miners = [dict(row) for row in cur.fetchall()]
queued = 0
for miner in miners:
payload = {
"miner_id": miner["miner_id"],
"action": command_action,
"performance_mode": performance_mode if command_action == "performance_mode" else "",
"ip_address": miner["ip_address"],
"current_ip": miner["ip_address"],
"mac_address": miner["mac_address"],
"dry_run": False,
"scheduled_action_id": action_id,
"scheduled_action_name": action.get("name") or "",
}
cur.execute(
"""
INSERT INTO minerops_agent_command_queue (
id,
agent_id,
command_type,
command_status,
requested_by_user_id,
requested_by_username,
request_payload
)
VALUES (%s, %s, 'miner_control', 'pending', %s, %s, %s::jsonb);
""",
(
str(_uuid.uuid4()),
action.get("agent_id") or "plexus-windows-agent",
action.get("requested_by_user_id"),
action.get("requested_by_username") or "scheduler",
_json.dumps(payload),
),
)
queued += 1
next_run_at = minerops_next_scheduled_run(action["scheduled_for"], repeat_rule)
if next_run_at is None:
cur.execute(
"""
UPDATE minerops_scheduled_bulk_actions
SET status = 'queued',
queued_command_count = queued_command_count + %s,
last_queued_command_count = %s,
run_count = run_count + 1,
processed_at = now(),
updated_at = now(),
last_error = NULL
WHERE id = %s;
""",
(queued, queued, action_id),
)
final_status = "queued"
else:
cur.execute(
"""
UPDATE minerops_scheduled_bulk_actions
SET status = 'pending',
scheduled_for = %s,
queued_command_count = queued_command_count + %s,
last_queued_command_count = %s,
run_count = run_count + 1,
processed_at = now(),
updated_at = now(),
last_error = NULL
WHERE id = %s;
""",
(next_run_at, queued, queued, action_id),
)
final_status = "pending-next-run"
processed.append({"id": action_id, "queued": queued, "status": final_status})
except Exception as exc:
cur.execute(
"""
UPDATE minerops_scheduled_bulk_actions
SET status = 'failed',
processed_at = now(),
updated_at = now(),
last_error = %s
WHERE id = %s;
""",
(str(exc), action_id),
)
processed.append({"id": action_id, "queued": 0, "status": "failed", "error": str(exc)})
conn.commit()
return processed
finally:
if conn is not None:
conn.close()
def minerops_reconcile_scheduled_bulk_action_statuses():
minerops_ensure_scheduled_bulk_actions_table()
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
s.id,
COUNT(q.id) AS total_commands,
COUNT(q.id) FILTER (
WHERE q.command_status NOT IN ('completed', 'failed', 'cancelled')
) AS active_commands,
COUNT(q.id) FILTER (WHERE q.command_status = 'completed') AS completed_commands,
COUNT(q.id) FILTER (WHERE q.command_status = 'failed') AS failed_commands,
COUNT(q.id) FILTER (WHERE q.command_status = 'cancelled') AS cancelled_commands
FROM minerops_scheduled_bulk_actions s
LEFT JOIN minerops_agent_command_queue q
ON q.request_payload ->> 'scheduled_action_id' = s.id
WHERE s.status IN ('queued', 'running')
GROUP BY s.id;
"""
)
rows = [dict(row) for row in cur.fetchall()]
for row in rows:
total = int(row.get("total_commands") or 0)
active = int(row.get("active_commands") or 0)
completed = int(row.get("completed_commands") or 0)
failed = int(row.get("failed_commands") or 0)
cancelled = int(row.get("cancelled_commands") or 0)
if total <= 0:
continue
if active > 0:
continue
if completed == total:
new_status = "completed"
last_error = None
else:
new_status = "completed_with_errors"
last_error = f"Completed with errors: {completed} completed, {failed} failed, {cancelled} cancelled."
cur.execute(
"""
UPDATE minerops_scheduled_bulk_actions
SET status = %s,
processed_at = COALESCE(processed_at, now()),
updated_at = now(),
last_error = %s
WHERE id = %s
AND status IN ('queued', 'running');
""",
(new_status, last_error, row["id"]),
)
conn.commit()
finally:
if conn is not None:
conn.close()
@app.get("/api/dashboard/scheduled-bulk-actions")
def dashboard_scheduled_bulk_actions(current_user: dict = Depends(get_current_user)):
minerops_ensure_scheduled_bulk_actions_table()
minerops_reconcile_scheduled_bulk_action_statuses()
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
id,
name,
target_type,
target_value,
action,
performance_mode,
repeat_rule,
scheduled_for,
status,
queued_command_count,
last_queued_command_count,
run_count,
last_error,
requested_by_username,
created_at,
updated_at,
processed_at
FROM minerops_scheduled_bulk_actions
ORDER BY
CASE WHEN status = 'pending' THEN 0 ELSE 1 END,
scheduled_for ASC,
created_at DESC
LIMIT 80;
"""
)
schedules = [dict(row) for row in cur.fetchall()]
schedule_ids = [row.get("id") for row in schedules if row.get("id")]
command_counts_by_schedule = {}
if schedule_ids:
cur.execute(
"""
SELECT
request_payload ->> 'scheduled_action_id' AS scheduled_action_id,
COUNT(*) AS command_total_count,
COUNT(*) FILTER (WHERE command_status = 'pending') AS command_pending_count,
COUNT(*) FILTER (WHERE command_status = 'completed') AS command_completed_count,
COUNT(*) FILTER (WHERE command_status = 'failed') AS command_failed_count,
COUNT(*) FILTER (WHERE command_status = 'cancelled') AS command_cancelled_count,
COUNT(*) FILTER (WHERE command_status NOT IN ('completed', 'failed', 'cancelled')) AS command_active_count
FROM minerops_agent_command_queue
WHERE request_payload ->> 'scheduled_action_id' = ANY(%s)
GROUP BY request_payload ->> 'scheduled_action_id';
""",
(schedule_ids,),
)
command_counts_by_schedule = {
row["scheduled_action_id"]: dict(row)
for row in cur.fetchall()
}
for schedule in schedules:
counts = command_counts_by_schedule.get(schedule.get("id"), {})
schedule["command_total_count"] = int(counts.get("command_total_count") or 0)
schedule["command_pending_count"] = int(counts.get("command_pending_count") or 0)
schedule["command_completed_count"] = int(counts.get("command_completed_count") or 0)
schedule["command_failed_count"] = int(counts.get("command_failed_count") or 0)
schedule["command_cancelled_count"] = int(counts.get("command_cancelled_count") or 0)
schedule["command_active_count"] = int(counts.get("command_active_count") or 0)
cur.execute(
"""
SELECT name
FROM rooms
ORDER BY sort_order, name;
"""
)
units = [row["name"] for row in cur.fetchall()]
cur.execute(
"""
SELECT DISTINCT COALESCE(miner_type, model) AS model_name
FROM miners
WHERE COALESCE(miner_type, model) IS NOT NULL
AND COALESCE(miner_type, model) <> ''
ORDER BY COALESCE(miner_type, model);
"""
)
models = [row["model_name"] for row in cur.fetchall()]
return api_response(
data={
"schedules": schedules,
"units": units,
"models": models,
}
)
finally:
if conn is not None:
conn.close()
@app.post("/api/dashboard/scheduled-bulk-actions")
def create_dashboard_scheduled_bulk_action(request: ScheduledBulkActionCreateRequest, current_user: dict = Depends(get_current_user)):
import datetime as _datetime
import uuid as _uuid
minerops_ensure_scheduled_bulk_actions_table()
target_type = (request.target_type or "").strip().lower()
target_value = (request.target_value or "").strip()
command_action = (getattr(request, "action", None) or "performance_mode").strip().lower()
performance_mode = (request.performance_mode or "").strip().lower()
repeat_rule = (getattr(request, "repeat_rule", None) or "once").strip().lower()
if target_type not in ("all", "unit", "model", "configured_mode", "selected_miners"):
raise HTTPException(status_code=400, detail="Invalid target type.")
if target_type != "all" and not target_value:
raise HTTPException(status_code=400, detail="Target value is required.")
if command_action not in ("performance_mode", "suspend", "resume"):
raise HTTPException(status_code=400, detail="Invalid action.")
if command_action == "performance_mode" and performance_mode not in ("low", "normal", "high"):
raise HTTPException(status_code=400, detail="Invalid performance mode.")
if command_action in ("suspend", "resume"):
performance_mode = ""
if repeat_rule not in ("once", "daily", "weekdays", "weekends"):
raise HTTPException(status_code=400, detail="Invalid repeat rule.")
try:
raw_dt = (request.scheduled_for or "").strip().replace("Z", "+00:00")
scheduled_for = _datetime.datetime.fromisoformat(raw_dt)
if scheduled_for.tzinfo is None:
scheduled_for = scheduled_for.replace(tzinfo=_datetime.timezone.utc)
except Exception:
raise HTTPException(status_code=400, detail="Invalid scheduled date/time.")
action_id = str(_uuid.uuid4())
username = current_user.get("username") or current_user.get("email") or "unknown"
user_id = str(current_user.get("id") or "")
conn = None
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO minerops_scheduled_bulk_actions (
id,
name,
target_type,
target_value,
action,
performance_mode,
repeat_rule,
scheduled_for,
status,
requested_by_user_id,
requested_by_username
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', %s, %s);
""",
(
action_id,
request.name or "",
target_type,
target_value,
command_action,
performance_mode,
repeat_rule,
scheduled_for,
user_id,
username,
),
)
conn.commit()
return api_response(
data={"id": action_id},
meta={"message": "Scheduled bulk action created."},
)
finally:
if conn is not None:
conn.close()
@app.post("/api/dashboard/scheduled-bulk-actions/{action_id}/cancel")
def cancel_dashboard_scheduled_bulk_action(action_id: str, current_user: dict = Depends(get_current_user)):
minerops_ensure_scheduled_bulk_actions_table()
conn = None
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute(
"""
UPDATE minerops_agent_command_queue
SET
command_status = 'cancelled',
completed_at = now(),
error_message = COALESCE(error_message, '') || ' Cancelled by admin from scheduled action.'
WHERE request_payload ->> 'scheduled_action_id' = %s
AND command_status = 'pending';
""",
(action_id,),
)
cur.execute(
"""
UPDATE minerops_scheduled_bulk_actions
SET status = 'cancelled',
updated_at = now(),
last_error = CASE
WHEN COALESCE(last_error, '') = '' THEN 'Cancelled by admin.'
ELSE last_error
END
WHERE id = %s
AND status IN ('pending', 'queued', 'running', 'failed')
RETURNING id;
""",
(action_id,),
)
row = cur.fetchone()
conn.commit()
if row is None:
raise HTTPException(status_code=404, detail="Scheduled action not found or cannot be cancelled.")
return api_response(meta={"message": "Scheduled action cancelled."})
finally:
if conn is not None:
conn.close()
@app.post("/api/dashboard/scheduled-bulk-actions/{action_id}/delete")
def delete_dashboard_scheduled_bulk_action(action_id: str, current_user: dict = Depends(get_current_user)):
minerops_ensure_scheduled_bulk_actions_table()
conn = None
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute(
"""
UPDATE minerops_agent_command_queue
SET
command_status = 'cancelled',
completed_at = now(),
error_message = COALESCE(error_message, '') || ' Cancelled because scheduled action was deleted.'
WHERE request_payload ->> 'scheduled_action_id' = %s
AND command_status = 'pending';
""",
(action_id,),
)
cur.execute(
"""
DELETE FROM minerops_scheduled_bulk_actions
WHERE id = %s
RETURNING id;
""",
(action_id,),
)
row = cur.fetchone()
conn.commit()
if row is None:
raise HTTPException(status_code=404, detail="Scheduled action not found.")
return api_response(meta={"message": "Scheduled action deleted."})
finally:
if conn is not None:
conn.close()
@app.post("/api/dashboard/scheduled-bulk-actions/process-due")
def process_due_dashboard_scheduled_bulk_actions(current_user: dict = Depends(get_current_user)):
processed = minerops_process_due_scheduled_bulk_actions()
return api_response(data={"processed": processed})
@app.get("/api/dashboard/bulk-action-status")
def dashboard_bulk_action_status(current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
COALESCE(INITCAP(configured_performance_mode), 'Unknown') AS configured_mode,
COALESCE(configured_performance_mode_source, 'none') AS source,
COUNT(*) AS miner_count
FROM miners
GROUP BY configured_performance_mode, configured_performance_mode_source
ORDER BY configured_mode, source;
"""
)
mode_counts = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT
COALESCE(q.request_payload ->> 'action', 'unknown') AS action,
COALESCE(q.request_payload ->> 'performance_mode', '') AS performance_mode,
q.command_status,
COUNT(*) AS command_count
FROM minerops_agent_command_queue q
WHERE q.command_type = 'miner_control'
AND q.created_at >= now() - interval '6 hours'
GROUP BY
q.request_payload ->> 'action',
q.request_payload ->> 'performance_mode',
q.command_status
ORDER BY command_count DESC, action, performance_mode, command_status;
"""
)
recent_summary = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT
q.id,
q.command_status,
q.requested_by_username,
q.request_payload ->> 'action' AS action,
q.request_payload ->> 'performance_mode' AS performance_mode,
q.created_at,
q.claimed_at,
q.completed_at,
q.error_message,
rooms.name AS room_name,
m.current_mac,
host(m.current_ip) AS current_ip
FROM minerops_agent_command_queue q
LEFT JOIN miners m
ON m.id = CASE
WHEN q.request_payload ->> 'miner_id' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
THEN (q.request_payload ->> 'miner_id')::uuid
ELSE NULL
END
LEFT JOIN rooms ON rooms.id = m.room_id
WHERE q.command_type = 'miner_control'
ORDER BY q.created_at DESC
LIMIT 40;
"""
)
recent_commands = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT
q.id,
q.command_status,
q.requested_by_username,
q.request_payload ->> 'action' AS action,
q.request_payload ->> 'performance_mode' AS performance_mode,
q.created_at,
q.completed_at,
q.error_message,
rooms.name AS room_name,
m.current_mac,
host(m.current_ip) AS current_ip
FROM minerops_agent_command_queue q
LEFT JOIN miners m
ON m.id = CASE
WHEN q.request_payload ->> 'miner_id' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
THEN (q.request_payload ->> 'miner_id')::uuid
ELSE NULL
END
LEFT JOIN rooms ON rooms.id = m.room_id
WHERE q.command_type = 'miner_control'
AND (
q.command_status <> 'completed'
OR COALESCE(q.error_message, '') <> ''
)
ORDER BY q.created_at DESC
LIMIT 12;
"""
)
recent_failures = [dict(row) for row in cur.fetchall()]
return api_response(
data={
"configured_mode_counts": mode_counts,
"recent_summary": recent_summary,
"recent_commands": recent_commands,
"recent_failures": recent_failures,
}
)
finally:
if conn is not None:
conn.close()
@app.get("/api/dashboard/miner-table")
def dashboard_miner_table(current_user: dict = Depends(get_current_user)):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
WITH latest AS (
SELECT DISTINCT ON (miner_id)
miner_id,
observed_at,
observed_ip,
mhs_av,
power_w,
power_rate_w_per_th,
chip_temp_max_c,
chip_temp_avg_c,
fan_speed_in_rpm,
fan_speed_out_rpm,
hash_stable,
pool_rejected_percent,
COALESCE(
raw_summary_json #>> '{Msg,Power Mode}',
raw_summary_json #>> '{MSG,Power Mode}',
raw_summary_json #>> '{msg,Power Mode}',
raw_summary_json ->> 'Power Mode',
raw_summary_json ->> 'PowerMode',
raw_summary_json ->> 'power_mode'
) AS runtime_power_state
FROM miner_status_snapshots
ORDER BY miner_id, observed_at DESC
)
SELECT
miners.id::text,
COALESCE(rooms.name, 'Unassigned') AS room_name,
host(miners.current_ip) AS current_ip,
miners.current_mac,
miners.hostname,
miners.miner_sn,
miners.power_sn,
miners.model,
miners.miner_type,
miners.firmware_version,
miners.status,
miners.last_seen_at,
latest.observed_at,
ROUND((latest.mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths,
ROUND(latest.power_w::numeric, 2)::float AS power_w,
ROUND(latest.power_rate_w_per_th::numeric, 2)::float AS power_rate_w_per_th,
ROUND(latest.chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c,
ROUND(latest.chip_temp_avg_c::numeric, 2)::float AS chip_temp_avg_c,
latest.fan_speed_in_rpm,
latest.fan_speed_out_rpm,
latest.hash_stable,
INITCAP(miners.configured_performance_mode) AS performance_mode,
miners.configured_performance_mode_source,
miners.configured_performance_mode_updated_at,
latest.runtime_power_state,
ROUND(latest.pool_rejected_percent::numeric, 4)::float AS pool_rejected_percent
FROM miners
LEFT JOIN rooms ON rooms.id = miners.room_id
LEFT JOIN latest ON latest.miner_id = miners.id
ORDER BY rooms.sort_order, miners.current_ip;
"""
)
rows = [dict(row) for row in cur.fetchall()]
return api_response(data=rows)
finally:
if conn is not None:
conn.close()
@app.get("/api/dashboard/hot-miners")
def dashboard_hot_miners(
min_temp_c: float = 90,
limit: int = 100,
current_user: dict = Depends(get_current_user),
):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
WITH latest AS (
SELECT DISTINCT ON (miner_id)
miner_id,
observed_at,
chip_temp_max_c,
chip_temp_avg_c,
power_w,
mhs_av,
hash_stable
FROM miner_status_snapshots
ORDER BY miner_id, observed_at DESC
)
SELECT
miners.id::text,
COALESCE(rooms.name, 'Unassigned') AS room_name,
host(miners.current_ip) AS current_ip,
miners.current_mac,
miners.model,
miners.miner_type,
miners.firmware_version,
miners.status,
latest.observed_at,
ROUND(latest.chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c,
ROUND(latest.chip_temp_avg_c::numeric, 2)::float AS chip_temp_avg_c,
ROUND(latest.power_w::numeric, 2)::float AS power_w,
ROUND((latest.mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths,
latest.hash_stable
FROM miners
LEFT JOIN rooms ON rooms.id = miners.room_id
JOIN latest ON latest.miner_id = miners.id
WHERE latest.chip_temp_max_c >= %s
ORDER BY latest.chip_temp_max_c DESC
LIMIT %s;
""",
(min_temp_c, limit),
)
rows = [dict(row) for row in cur.fetchall()]
return api_response(data=rows)
finally:
if conn is not None:
conn.close()
@app.get("/api/dashboard/unstable-miners")
def dashboard_unstable_miners(
limit: int = 100,
current_user: dict = Depends(get_current_user),
):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
WITH latest AS (
SELECT DISTINCT ON (miner_id)
miner_id,
observed_at,
chip_temp_max_c,
power_w,
mhs_av,
hash_stable,
pool_rejected_percent
FROM miner_status_snapshots
ORDER BY miner_id, observed_at DESC
)
SELECT
miners.id::text,
COALESCE(rooms.name, 'Unassigned') AS room_name,
host(miners.current_ip) AS current_ip,
miners.current_mac,
miners.model,
miners.miner_type,
miners.firmware_version,
miners.status,
latest.observed_at,
ROUND((latest.mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths,
ROUND(latest.power_w::numeric, 2)::float AS power_w,
ROUND(latest.chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c,
latest.hash_stable,
ROUND(latest.pool_rejected_percent::numeric, 4)::float AS pool_rejected_percent
FROM miners
LEFT JOIN rooms ON rooms.id = miners.room_id
JOIN latest ON latest.miner_id = miners.id
WHERE latest.hash_stable = false
OR latest.pool_rejected_percent >= 1
ORDER BY latest.hash_stable ASC, latest.pool_rejected_percent DESC NULLS LAST, latest.chip_temp_max_c DESC NULLS LAST
LIMIT %s;
""",
(limit,),
)
rows = [dict(row) for row in cur.fetchall()]
return api_response(data=rows)
finally:
if conn is not None:
conn.close()
@app.get("/api/dashboard/stale-miners")
def dashboard_stale_miners(
minutes: int = 30,
limit: int = 200,
current_user: dict = Depends(get_current_user),
):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
miners.id::text,
COALESCE(rooms.name, 'Unassigned') AS room_name,
host(miners.current_ip) AS current_ip,
miners.current_mac,
miners.model,
miners.miner_type,
miners.firmware_version,
miners.status,
miners.last_seen_at,
ROUND(EXTRACT(EPOCH FROM (now() - miners.last_seen_at)) / 60.0, 1)::float AS minutes_since_seen
FROM miners
LEFT JOIN rooms ON rooms.id = miners.room_id
WHERE miners.last_seen_at < now() - make_interval(mins => %s)
ORDER BY miners.last_seen_at ASC
LIMIT %s;
""",
(minutes, limit),
)
rows = [dict(row) for row in cur.fetchall()]
return api_response(data=rows)
finally:
if conn is not None:
conn.close()
MINEROPS_UNIFIED_CSS = r"""
:root {
--minerops-bg: #f4f7fb;
--minerops-card: #ffffff;
--minerops-border: #d9e2ec;
--minerops-text: #102a43;
--minerops-muted: #52606d;
--minerops-blue: #0b63ce;
--minerops-blue-dark: #084b9a;
--minerops-green: #0f766e;
--minerops-red: #dc2626;
}
body {
background: var(--minerops-bg) !important;
color: var(--minerops-text) !important;
font-family: "Segoe UI", Arial, sans-serif !important;
margin: 0 !important;
padding: 20px !important;
}
/* Hide old page headers/navs that made the pages look duplicated */
body > header:not(.minerops-shell-header) {
display: none !important;
}
body > h1 {
display: none !important;
}
body > .topbar {
display: none !important;
}
.app-header {
display: none !important;
}
#dashboardLocationMapLink {
display: none !important;
}
.minerops-shell-header {
background: linear-gradient(135deg, #0b63ce, #0f766e);
color: white;
border-radius: 16px;
padding: 18px 20px;
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.18);
}
.minerops-kicker {
font-size: 13px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.86;
margin-bottom: 4px;
}
.minerops-shell-header h1 {
display: block !important;
margin: 0;
color: white;
font-size: 28px;
line-height: 1.1;
}
.minerops-subtitle {
margin-top: 6px;
font-size: 14px;
opacity: 0.9;
}
.minerops-nav {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.minerops-nav-link {
display: inline-block;
border: 1px solid rgba(255, 255, 255, 0.45);
background: rgba(255, 255, 255, 0.14);
color: white !important;
border-radius: 999px;
padding: 8px 12px;
font-weight: 800;
font-size: 13px;
text-decoration: none !important;
}
.minerops-nav-link:hover {
background: rgba(255, 255, 255, 0.24);
}
.minerops-nav-link.active {
background: white;
color: var(--minerops-blue) !important;
}
.card {
background: var(--minerops-card) !important;
border: 1px solid var(--minerops-border) !important;
border-radius: 14px !important;
padding: 16px !important;
margin-bottom: 16px !important;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06) !important;
}
.card h2 {
color: var(--minerops-text) !important;
margin-top: 0 !important;
}
.small,
.muted {
color: var(--minerops-muted) !important;
}
button,
input[type="button"],
input[type="submit"],
.issue-action-button {
background: var(--minerops-blue) !important;
color: white !important;
border: 0 !important;
border-radius: 8px !important;
padding: 8px 11px !important;
font-weight: 800 !important;
cursor: pointer !important;
text-decoration: none !important;
}
button:hover,
input[type="button"]:hover,
input[type="submit"]:hover,
.issue-action-button:hover {
background: var(--minerops-blue-dark) !important;
}
button.secondary {
background: #64748b !important;
}
button.danger,
.danger {
background: var(--minerops-red) !important;
color: white !important;
}
input,
select,
textarea {
border: 1px solid #bcccdc !important;
border-radius: 8px !important;
padding: 8px !important;
background: white !important;
color: var(--minerops-text) !important;
}
table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 10px;
overflow: hidden;
}
th {
background: #eef2ff !important;
color: #1e293b !important;
text-align: left;
font-size: 13px;
}
th,
td {
border-bottom: 1px solid #e2e8f0 !important;
padding: 8px !important;
vertical-align: top;
}
tr:hover td {
background: #f8fafc !important;
}
.status {
border-radius: 10px !important;
padding: 10px 12px !important;
margin-top: 10px !important;
border: 1px solid var(--minerops-border) !important;
background: #f8fafc !important;
}
.status.ok {
background: #ecfdf5 !important;
border-color: #6ee7b7 !important;
}
.status.warn {
background: #fff7ed !important;
border-color: #fdba74 !important;
}
.status.bad {
background: #fef2f2 !important;
border-color: #fca5a5 !important;
}
.sticky-mini-toolbar,
.card-toolbar {
background: rgba(255, 255, 255, 0.96) !important;
border: 1px solid var(--minerops-border) !important;
border-radius: 14px !important;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.10) !important;
padding: 10px !important;
}
.sticky-mini-toolbar {
top: 8px !important;
}
/* Step 80: Header auth controls */
.minerops-auth {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-left: 8px;
}
.minerops-auth input {
width: 115px !important;
min-width: 90px !important;
padding: 7px 8px !important;
border-radius: 999px !important;
border: 1px solid rgba(255, 255, 255, 0.55) !important;
background: rgba(255, 255, 255, 0.92) !important;
font-size: 12px !important;
}
.minerops-auth button {
border-radius: 999px !important;
padding: 7px 10px !important;
font-size: 12px !important;
background: rgba(255, 255, 255, 0.18) !important;
border: 1px solid rgba(255, 255, 255, 0.45) !important;
color: white !important;
}
.minerops-auth button:hover {
background: rgba(255, 255, 255, 0.28) !important;
}
.minerops-auth-user {
color: white;
font-size: 13px;
font-weight: 800;
padding: 7px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
border: 1px solid rgba(255, 255, 255, 0.30);
}
.minerops-auth-message {
color: white;
font-size: 12px;
font-weight: 700;
opacity: 0.92;
}
.minerops-hidden-login-card {
display: none !important;
}
@media (max-width: 950px) {
.minerops-auth {
width: 100%;
margin-left: 0;
}
.minerops-auth input {
width: 140px !important;
}
}
/* Step 83: Global miner search */
.minerops-global-search {
position: relative;
display: flex;
align-items: center;
gap: 6px;
margin-left: 8px;
}
.minerops-global-search input {
width: 230px !important;
min-width: 180px !important;
border-radius: 999px !important;
padding: 8px 12px !important;
border: 1px solid rgba(255, 255, 255, 0.55) !important;
background: rgba(255, 255, 255, 0.95) !important;
font-size: 13px !important;
}
.minerops-search-results {
position: absolute;
top: 40px;
right: 0;
z-index: 5000;
width: min(680px, 92vw);
max-height: 440px;
overflow: auto;
background: white;
color: var(--minerops-text);
border: 1px solid var(--minerops-border);
border-radius: 14px;
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.28);
padding: 8px;
display: none;
}
.minerops-search-results.open {
display: block;
}
.minerops-search-result {
display: grid;
grid-template-columns: 150px 110px 1fr;
gap: 8px;
padding: 9px;
border-radius: 10px;
cursor: pointer;
border-bottom: 1px solid #e2e8f0;
}
.minerops-search-result:hover,
.minerops-search-result.active {
background: #eff6ff;
}
.minerops-search-result-main {
font-weight: 900;
color: #0f172a;
}
.minerops-search-result-sub {
font-size: 12px;
color: #475569;
}
.minerops-search-empty {
padding: 10px;
color: #475569;
font-size: 13px;
}
@media (max-width: 950px) {
.minerops-global-search {
width: 100%;
margin-left: 0;
}
.minerops-global-search input {
width: 100% !important;
}
.minerops-search-results {
left: 0;
right: auto;
width: 92vw;
}
}
/* Step 84: Global scan action */
.minerops-header-scan {
display: flex;
align-items: center;
gap: 6px;
margin-left: 4px;
}
.minerops-scan-button {
background: rgba(255, 255, 255, 0.18) !important;
border: 1px solid rgba(255, 255, 255, 0.45) !important;
color: white !important;
border-radius: 999px !important;
padding: 8px 12px !important;
font-size: 13px !important;
font-weight: 800 !important;
}
.minerops-scan-button:hover {
background: rgba(255, 255, 255, 0.28) !important;
}
.minerops-scan-button.running {
background: #f59e0b !important;
color: #111827 !important;
}
.minerops-scan-status {
color: white;
font-size: 12px;
font-weight: 700;
opacity: 0.92;
max-width: 260px;
}
@media (max-width: 950px) {
.minerops-header-scan {
width: 100%;
margin-left: 0;
}
.minerops-scan-status {
max-width: 100%;
}
}
@media (max-width: 800px) {
body {
padding: 12px !important;
}
.minerops-shell-header {
align-items: flex-start;
flex-direction: column;
}
.minerops-nav {
justify-content: flex-start;
}
}
"""
@app.get("/assets/minerops-unified.css")
async def minerops_unified_css():
return HTMLResponse(content=MINEROPS_UNIFIED_CSS, media_type="text/css")
MINEROPS_SHELL_AUTH_JS = r"""
(function () {
const TOKEN_KEYS = [
"minerops_token",
"access_token",
"token",
"auth_token",
"authToken"
];
const USERNAME_KEY = "minerops_username";
function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function looksLikeToken(value) {
if (!value || typeof value !== "string") {
return false;
}
if (value.split(".").length === 3 && value.length > 30) {
return true;
}
return value.length > 40 && !value.includes(" ");
}
function findTokenRecursive(value) {
if (!value) {
return "";
}
if (typeof value === "string") {
return looksLikeToken(value) ? value : "";
}
if (Array.isArray(value)) {
for (const item of value) {
const found = findTokenRecursive(item);
if (found) {
return found;
}
}
return "";
}
if (typeof value === "object") {
const preferredKeys = [
"access_token",
"accessToken",
"token",
"jwt",
"auth_token",
"authToken"
];
for (const key of preferredKeys) {
if (looksLikeToken(value[key])) {
return value[key];
}
}
for (const key of Object.keys(value)) {
if (key.toLowerCase().includes("token")) {
const found = findTokenRecursive(value[key]);
if (found) {
return found;
}
}
}
for (const key of Object.keys(value)) {
const found = findTokenRecursive(value[key]);
if (found) {
return found;
}
}
}
return "";
}
function getToken() {
for (const key of TOKEN_KEYS) {
const value = localStorage.getItem(key);
if (value) {
return value;
}
}
return "";
}
function setToken(token) {
for (const key of TOKEN_KEYS) {
localStorage.setItem(key, token);
}
}
function clearToken() {
for (const key of TOKEN_KEYS) {
localStorage.removeItem(key);
}
localStorage.removeItem(USERNAME_KEY);
}
function authHeaders() {
const token = getToken();
if (!token) {
return {};
}
return {
"Authorization": "Bearer " + token
};
}
function findLegacyLoginContainer(heading) {
let node = heading.parentElement;
let best = null;
for (let i = 0; i < 7 && node && node !== document.body; i++) {
const hasPassword = node.querySelector && node.querySelector('input[type="password"]');
const hasClearToken = node.textContent && node.textContent.toLowerCase().includes("clear token");
const isCard = node.classList && (node.classList.contains("card") || node.classList.contains("panel"));
if (hasPassword || hasClearToken) {
best = node;
}
if (isCard) {
best = node;
break;
}
node = node.parentElement;
}
return best || heading.parentElement;
}
function hideLegacyLoginCards() {
document.querySelectorAll("h1, h2, h3").forEach(heading => {
if (heading.closest(".minerops-shell-header")) {
return;
}
const title = heading.textContent.trim().toLowerCase();
if (
title === "login" ||
title === "login / refresh" ||
title === "login/refresh"
) {
const container = findLegacyLoginContainer(heading);
if (container && !container.closest(".minerops-shell-header")) {
container.classList.add("minerops-hidden-login-card");
container.style.display = "none";
}
}
});
}
function setLegacyLoginInputs(username, password) {
const usernameInputs = Array.from(document.querySelectorAll('input[type="text"], input:not([type])'))
.filter(input => !input.closest(".minerops-shell-header"));
const passwordInputs = Array.from(document.querySelectorAll('input[type="password"]'))
.filter(input => !input.closest(".minerops-shell-header"));
for (const input of usernameInputs) {
const idName = ((input.id || "") + " " + (input.name || "") + " " + (input.placeholder || "")).toLowerCase();
if (idName.includes("user") || idName.includes("admin") || idName.includes("login")) {
input.value = username;
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
}
}
for (const input of passwordInputs) {
input.value = password;
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
}
}
async function tryLegacyPageLogin(username, password) {
setLegacyLoginInputs(username, password);
const functionNames = [
"login",
"doLogin",
"loginUser",
"dashboardLogin",
"locationLogin"
];
for (const functionName of functionNames) {
if (typeof window[functionName] === "function") {
try {
const result = window[functionName]();
await Promise.resolve(result);
await delay(800);
if (getToken()) {
return true;
}
} catch (error) {
// Keep trying other methods.
}
}
}
const buttons = Array.from(document.querySelectorAll("button"))
.filter(button => !button.closest(".minerops-shell-header"))
.filter(button => button.textContent.trim().toLowerCase() === "login");
for (const button of buttons) {
try {
button.click();
await delay(1000);
if (getToken()) {
return true;
}
} catch (error) {
// Keep trying.
}
}
return false;
}
async function parseLoginResponse(response) {
const text = await response.text();
let data = null;
try {
data = text ? JSON.parse(text) : {};
} catch (error) {
data = text;
}
if (!response.ok) {
const message = data && typeof data === "object"
? (data.detail || data.message || data.error || "Login failed.")
: (text || "Login failed.");
throw new Error(message);
}
return findTokenRecursive(data) || findTokenRecursive(text);
}
async function tryJsonLogin(username, password) {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
username: username,
password: password
})
});
return await parseLoginResponse(response);
}
async function tryFormLogin(username, password) {
const form = new URLSearchParams();
form.set("username", username);
form.set("password", password);
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: form.toString()
});
return await parseLoginResponse(response);
}
async function getCurrentUser() {
const token = getToken();
if (!token) {
return null;
}
try {
const response = await fetch("/api/auth/me", {
headers: authHeaders()
});
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
return null;
}
}
function userDisplayName(user) {
const stored = localStorage.getItem(USERNAME_KEY);
const candidates = [
stored,
user && user.username,
user && user.user_name,
user && user.name,
user && user.email
];
for (const candidate of candidates) {
if (!candidate) {
continue;
}
const value = String(candidate).trim();
if (!value) {
continue;
}
if (value.toLowerCase() === "signed in") {
continue;
}
return value;
}
return "";
}
async function loginFromHeader() {
const username = document.getElementById("mineropsHeaderUsername")?.value || "";
const password = document.getElementById("mineropsHeaderPassword")?.value || "";
const message = document.getElementById("mineropsHeaderAuthMessage");
if (!username || !password) {
if (message) {
message.textContent = "Enter username and password.";
}
return;
}
if (message) {
message.textContent = "Signing in...";
}
let token = "";
let lastError = null;
try {
token = await tryJsonLogin(username, password);
} catch (error) {
lastError = error;
}
if (!token) {
try {
token = await tryFormLogin(username, password);
} catch (error) {
lastError = error;
}
}
if (token) {
setToken(token);
localStorage.setItem(USERNAME_KEY, username);
window.location.reload();
return;
}
if (message) {
message.textContent = "Trying page login...";
}
const legacyWorked = await tryLegacyPageLogin(username, password);
if (legacyWorked || getToken()) {
localStorage.setItem(USERNAME_KEY, username);
window.location.reload();
return;
}
if (message) {
message.textContent = lastError ? lastError.message : "Login failed. No token was returned.";
}
}
function logoutFromHeader() {
clearToken();
window.location.reload();
}
async function renderHeaderAuth() {
const nav = document.querySelector(".minerops-nav");
if (!nav) {
return;
}
let authArea = document.getElementById("mineropsHeaderAuth");
if (!authArea) {
authArea = document.createElement("div");
authArea.id = "mineropsHeaderAuth";
authArea.className = "minerops-auth";
nav.appendChild(authArea);
}
const user = await getCurrentUser();
if (user) {
const displayName = userDisplayName(user);
authArea.innerHTML = displayName
? `
Signed in: ${escapeHtml(displayName)}
`
: `
Signed in
`;
document.getElementById("mineropsHeaderLogout").onclick = logoutFromHeader;
return;
}
authArea.innerHTML = `
`;
document.getElementById("mineropsHeaderLogin").onclick = loginFromHeader;
document.getElementById("mineropsHeaderPassword").addEventListener("keydown", event => {
if (event.key === "Enter") {
loginFromHeader();
}
});
}
function initHeaderAuth() {
hideLegacyLoginCards();
renderHeaderAuth();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initHeaderAuth);
} else {
initHeaderAuth();
}
setTimeout(initHeaderAuth, 300);
setTimeout(initHeaderAuth, 1000);
setTimeout(initHeaderAuth, 2000);
window.mineropsHeaderLogout = logoutFromHeader;
window.mineropsHeaderLogin = loginFromHeader;
})();
"""
@app.get("/assets/minerops-shell-auth.js")
async def minerops_shell_auth_js():
return HTMLResponse(content=MINEROPS_SHELL_AUTH_JS, media_type="text/javascript")
MINEROPS_GLOBAL_SEARCH_JS = r"""
(function () {
const TOKEN_KEYS = [
"minerops_token",
"minerops_access_token",
"access_token",
"accessToken",
"token",
"auth_token",
"authToken",
"jwt"
];
let searchCache = null;
let activeIndex = -1;
let currentResults = [];
let lastLoadSummary = "";
function storageGet(key) {
return localStorage.getItem(key) || sessionStorage.getItem(key) || "";
}
function getToken() {
for (const key of TOKEN_KEYS) {
const value = storageGet(key);
if (value) {
return value;
}
}
for (const store of [localStorage, sessionStorage]) {
for (let i = 0; i < store.length; i++) {
const key = store.key(i) || "";
const lower = key.toLowerCase();
if (lower.includes("token") || lower.includes("jwt") || lower.includes("auth")) {
const value = store.getItem(key);
if (value && value.length > 20) {
return value;
}
}
}
}
return "";
}
function authHeaders() {
const token = getToken();
if (!token) {
return {};
}
return {
"Authorization": "Bearer " + token
};
}
function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function normalize(value) {
return String(value || "")
.toLowerCase()
.replaceAll(":", "")
.replaceAll("-", "")
.replaceAll(" ", "")
.trim();
}
function firstValue(obj, keys) {
for (const key of keys) {
if (obj && obj[key] !== null && obj[key] !== undefined && obj[key] !== "") {
return obj[key];
}
}
return "";
}
function valueFromNested(obj, paths) {
for (const path of paths) {
let current = obj;
let found = true;
for (const part of path.split(".")) {
if (!current || current[part] === null || current[part] === undefined || current[part] === "") {
found = false;
break;
}
current = current[part];
}
if (found) {
return current;
}
}
return "";
}
function displayValue(value, fallback) {
if (value === null || value === undefined || value === "") {
return fallback || "";
}
return String(value);
}
async function fetchJson(url) {
const response = await fetch(url, {
headers: authHeaders()
});
if (!response.ok) {
throw new Error(url + " returned HTTP " + response.status);
}
return await response.json();
}
function addSearchRow(rows, item, sourceName) {
if (!item || typeof item !== "object") {
return;
}
const minerId = firstValue(item, [
"miner_id",
"id",
"minerId",
"miner_uuid",
"uuid"
]);
const mac = firstValue(item, [
"current_mac",
"current_mac_address",
"mac_address",
"mac",
"expected_mac",
"network_mac",
"macAddress",
"primary_mac"
]);
const ip = firstValue(item, [
"current_ip",
"current_ip_address",
"ip_address",
"ip",
"network_ip",
"ipAddress",
"host",
"hostname"
]);
const model = firstValue(item, [
"model",
"miner_model",
"miner_type",
"hardware_model",
"machine_model",
"platform"
]);
const room = firstValue(item, [
"room_name",
"room",
"unit",
"unit_name"
]);
const rack = firstValue(item, [
"rack_name",
"rack"
]);
const shelf = firstValue(item, [
"shelf_name",
"shelf"
]);
const position = firstValue(item, [
"position_index",
"position",
"slot",
"slot_index"
]);
const serial = firstValue(item, [
"serial_number",
"miner_serial",
"serial",
"sn",
"control_board_serial",
"control_board_sn",
"psu_serial",
"hashboard_serial"
]) || valueFromNested(item, [
"latest_status.serial_number",
"latest_snapshot.serial_number",
"identity.serial_number"
]);
if (!minerId && !mac && !ip && !serial && !room && !rack) {
return;
}
const locationParts = [];
if (room) {
locationParts.push(room);
}
if (rack) {
locationParts.push(rack);
}
if (shelf) {
locationParts.push(shelf);
}
if (position) {
locationParts.push("Pos " + position);
}
const location = locationParts.join(" / ");
const hashrate = firstValue(item, [
"hashrate_ths",
"hashrate",
"avg_hashrate_ths",
"current_hashrate_ths"
]);
const temp = firstValue(item, [
"chip_temp_max_c",
"max_temp_c",
"temperature_c",
"temp_c"
]);
const haystack = [
minerId,
mac,
ip,
model,
room,
rack,
shelf,
position,
serial,
location,
sourceName
].map(value => String(value || "")).join(" ");
rows.push({
miner_id: minerId,
mac: mac,
ip: ip,
model: model,
room: room,
rack: rack,
shelf: shelf,
position: position,
serial: serial,
location: location,
hashrate: hashrate,
temp: temp,
source: sourceName,
haystack: haystack,
normalized: normalize(haystack)
});
}
function addRowsFromArray(rows, arrayValue, sourceName) {
if (!Array.isArray(arrayValue)) {
return;
}
for (const item of arrayValue) {
addSearchRow(rows, item, sourceName);
}
}
function collectRowsFromPayload(rows, payload, sourceName) {
if (!payload) {
return;
}
if (Array.isArray(payload)) {
addRowsFromArray(rows, payload, sourceName);
return;
}
if (typeof payload !== "object") {
return;
}
addRowsFromArray(rows, payload.positions, sourceName + ".positions");
addRowsFromArray(rows, payload.virtual_miners, sourceName + ".virtual_miners");
addRowsFromArray(rows, payload.miners, sourceName + ".miners");
addRowsFromArray(rows, payload.items, sourceName + ".items");
addRowsFromArray(rows, payload.results, sourceName + ".results");
addRowsFromArray(rows, payload.rows, sourceName + ".rows");
addRowsFromArray(rows, payload.data, sourceName + ".data");
addSearchRow(rows, payload, sourceName);
}
function dedupeRows(rows) {
const seen = new Set();
const output = [];
for (const row of rows) {
const key = row.miner_id || normalize(row.mac) || row.ip || row.serial;
if (!key) {
continue;
}
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(row);
}
return output;
}
async function loadSearchCache() {
if (searchCache) {
return searchCache;
}
const rows = [];
const attempts = [];
const urls = [
"/api/location/map",
"/api/miners",
"/api/dashboard/miner-table",
"/api/dashboard/hot-miners",
"/api/dashboard/unstable-miners",
"/api/dashboard/stale-miners"
];
for (const url of urls) {
try {
const payload = await fetchJson(url);
const beforeCount = rows.length;
collectRowsFromPayload(rows, payload, url);
attempts.push(url + ": +" + (rows.length - beforeCount));
} catch (error) {
attempts.push(url + ": " + error.message);
}
}
searchCache = dedupeRows(rows);
lastLoadSummary = "Loaded " + searchCache.length + " searchable miners. " + attempts.join(" | ");
return searchCache;
}
function resultScore(row, rawQuery, normalizedQuery) {
const q = rawQuery.toLowerCase();
if (String(row.mac || "").toLowerCase().includes(q)) {
return 100;
}
if (normalize(row.mac).includes(normalizedQuery)) {
return 98;
}
if (String(row.ip || "").toLowerCase().includes(q)) {
return 92;
}
if (String(row.serial || "").toLowerCase().includes(q)) {
return 88;
}
if (String(row.location || "").toLowerCase().includes(q)) {
return 75;
}
if (String(row.room || "").toLowerCase().includes(q)) {
return 72;
}
if (String(row.rack || "").toLowerCase().includes(q)) {
return 70;
}
if (String(row.model || "").toLowerCase().includes(q)) {
return 62;
}
if (row.normalized.includes(normalizedQuery)) {
return 50;
}
return 0;
}
function setResultsMessage(message) {
const resultsBox = document.getElementById("mineropsGlobalSearchResults");
if (!resultsBox) {
return;
}
resultsBox.classList.add("open");
resultsBox.innerHTML = `
Move Mode is off. Normal clicks open miner detail.
Location Import Issues
Rack / Room Heatmap Summary
Quickly find the hottest racks, offline clusters, not-hashing miners, and lowest efficiency areas.
Group By
Sort By
Scope
Showing all racks/rooms.
Issue Drill-Down
Actionable list of miners matching heat, offline, hashing, and duplicate/conflict issues.
Issue Type
Scope
Issue list not loaded yet.
Physical Map
Waiting / Storage / Repair / Decommissioned
Map Management
Add rooms, racks, shelves, and mark positions as miner slots, switches, or blocked spots.
Map management controls ready.
Import Layout Spreadsheet
Upload an .xlsx file laid out like your Unit 4 / Unit 6 / Unit 12 spreadsheets.
MAC addresses can be entered without colons.
Spreadsheet File
Room Override
Add Unit / Room
New Unit / Room Name
Add Rack With Shelves
Unit / Room
Rack Name
Shelf Count
Positions Per Shelf
Switch Shelf #
Switch Position #
Add Shelf to Existing Rack
Unit / Room
Rack
Shelf Name
Shelf Index
Positions Per Shelf
Set Position Type
Unit / Room
Rack
Shelf
Position #
Type
Label
Notes
Move Miner / Change Location
Move a miner to a physical slot or to Waiting, Storage, Repair, or Decommissioned.
Occupied, switch, and blocked target positions are protected.
Miner
Destination
Target Unit / Room
Target Rack
Target Shelf
Target Position
Move Notes
Delete Rack / Room
Delete is blocked if miners are still assigned. Move miners first, then delete the rack or room.
Room / Unit
Rack
Room / Unit
Room Record
Unit / Room Order
Use these controls to choose the order rooms appear on the location map.
Room order controls ready.
"""
# -----------------------------
# Phase 3 Step 65: Legacy WhatsMiner / M30S summary fallback
# -----------------------------
class LegacySummaryRefreshRequest(BaseModel):
miner_id: str | None = None
ip_address: str | None = None
limit: int = 50
timeout_seconds: float = 1.5
only_missing: bool = True
def legacy_summary_to_float(value):
if value is None:
return None
try:
return float(str(value).replace(",", "").strip())
except Exception:
return None
def legacy_summary_to_int(value):
float_value = legacy_summary_to_float(value)
if float_value is None:
return None
return int(float_value)
def legacy_summary_to_bool(value):
if value is None:
return None
if isinstance(value, bool):
return value
text = str(value).strip().lower()
if text in ("true", "1", "yes", "y", "stable"):
return True
if text in ("false", "0", "no", "n", "unstable"):
return False
return None
def legacy_summary_get(summary: dict, *keys):
for key in keys:
if key in summary and summary.get(key) is not None:
return summary.get(key)
return None
def infer_legacy_whatsminer_model(summary: dict):
factory_ghs = legacy_summary_to_float(
legacy_summary_get(summary, "Factory GHS", "FactoryGHS", "factory_ghs")
)
target_mhs = legacy_summary_to_float(
legacy_summary_get(summary, "Target MHS", "TargetMHS", "target_mhs")
)
estimate_ghs = factory_ghs
if estimate_ghs is None and target_mhs is not None:
estimate_ghs = target_mhs / 1000.0
if estimate_ghs is None:
return "WhatsMiner Legacy"
if 70000 <= estimate_ghs <= 115000:
return "M30S+"
if 115000 < estimate_ghs <= 140000:
return "M30S++"
if 140000 < estimate_ghs <= 180000:
return "M50S++"
return "WhatsMiner Legacy"
def read_legacy_whatsminer_summary(ip_address: str, port: int = 4028, timeout_seconds: float = 1.5):
import json
import socket
payloads = [
b'{"command":"summary"}\n',
b'{"command":"summary"}',
]
last_error = None
for payload in payloads:
try:
chunks = []
with socket.create_connection((ip_address, port), timeout=timeout_seconds) as sock:
sock.settimeout(timeout_seconds)
sock.sendall(payload)
while True:
try:
chunk = sock.recv(4096)
except socket.timeout:
break
if not chunk:
break
chunks.append(chunk)
if not chunks:
continue
response_text = b"".join(chunks).decode("utf-8", errors="replace").strip()
parsed = json.loads(response_text)
summary_list = parsed.get("SUMMARY") or parsed.get("summary") or []
if not summary_list:
return {
"success": False,
"error": "No SUMMARY section returned",
"response_text": response_text,
}
return {
"success": True,
"summary": summary_list[0],
"response_text": response_text,
"parsed_json": parsed,
}
except Exception as error:
last_error = str(error)
return {
"success": False,
"error": last_error or "No response",
}
def miner_needs_legacy_summary_patch(miner_id: str):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
WITH latest AS (
SELECT DISTINCT ON (miner_id)
miner_id,
mhs_av,
chip_temp_max_c,
chip_temp_avg_c,
observed_at
FROM miner_status_snapshots
WHERE miner_id = %s::uuid
ORDER BY miner_id, observed_at DESC
)
SELECT
miners.id::text,
miners.model,
miners.miner_type,
latest.mhs_av,
latest.chip_temp_max_c,
latest.chip_temp_avg_c
FROM miners
LEFT JOIN latest ON latest.miner_id = miners.id
WHERE miners.id = %s::uuid;
""",
(miner_id, miner_id),
)
row = cur.fetchone()
if not row:
return False
model_missing = row.get("model") is None or str(row.get("model")).strip() == ""
miner_type_missing = row.get("miner_type") is None or str(row.get("miner_type")).strip() == ""
hashrate_missing = row.get("mhs_av") is None
temp_missing = row.get("chip_temp_max_c") is None and row.get("chip_temp_avg_c") is None
return model_missing or miner_type_missing or hashrate_missing or temp_missing
finally:
if conn is not None:
conn.close()
def apply_legacy_summary_to_miner(miner_id: str, ip_address: str, timeout_seconds: float = 1.5):
read_result = read_legacy_whatsminer_summary(ip_address, 4028, timeout_seconds)
if not read_result.get("success"):
return {
"success": False,
"miner_id": miner_id,
"ip_address": ip_address,
"error": read_result.get("error"),
}
summary = read_result.get("summary") or {}
mhs_av = legacy_summary_to_float(
legacy_summary_get(summary, "MHS av", "MHS Av", "mhs_av", "MHS 5s", "HS RT")
)
power_w = legacy_summary_to_float(
legacy_summary_get(summary, "Power", "power", "Power W")
)
power_rate = legacy_summary_to_float(
legacy_summary_get(summary, "Power Rate", "PowerRate", "power_rate")
)
chip_temp_max = legacy_summary_to_float(
legacy_summary_get(summary, "Chip Temp Max", "ChipTempMax", "chip_temp_max", "Temperature")
)
chip_temp_avg = legacy_summary_to_float(
legacy_summary_get(summary, "Chip Temp Avg", "ChipTempAvg", "chip_temp_avg", "Temperature")
)
fan_in = legacy_summary_to_int(
legacy_summary_get(summary, "Fan Speed In", "FanSpeedIn", "fan_speed_in")
)
fan_out = legacy_summary_to_int(
legacy_summary_get(summary, "Fan Speed Out", "FanSpeedOut", "fan_speed_out")
)
hash_stable = legacy_summary_to_bool(
legacy_summary_get(summary, "Hash Stable", "HashStable", "hash_stable")
)
pool_rejected_percent = legacy_summary_to_float(
legacy_summary_get(summary, "Pool Rejected%", "Pool Rejected", "pool_rejected_percent")
)
inferred_model = infer_legacy_whatsminer_model(summary)
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
UPDATE miners
SET
status = 'online',
last_seen_at = now(),
current_ip = COALESCE(NULLIF(%s, '')::inet, current_ip),
model = COALESCE(NULLIF(model, ''), %s),
miner_type = COALESCE(NULLIF(miner_type, ''), %s)
WHERE id = %s::uuid
RETURNING
id::text,
current_mac,
host(current_ip) AS current_ip,
model,
miner_type;
""",
(
ip_address,
inferred_model,
inferred_model,
miner_id,
),
)
miner_row = cur.fetchone()
if not miner_row:
raise HTTPException(status_code=404, detail="Miner not found")
cur.execute(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'miner_status_snapshots';
"""
)
snapshot_columns = {row["column_name"] for row in cur.fetchall()}
insert_columns = []
insert_values_sql = []
values = []
def add_value(column_name, value, sql_cast=None):
if column_name not in snapshot_columns:
return
insert_columns.append(column_name)
if sql_cast:
insert_values_sql.append(f"%s::{sql_cast}")
else:
insert_values_sql.append("%s")
values.append(value)
def add_now(column_name):
if column_name not in snapshot_columns:
return
insert_columns.append(column_name)
insert_values_sql.append("now()")
add_value("id", str(uuid4()), "uuid")
add_value("miner_id", miner_id, "uuid")
add_now("observed_at")
add_value("observed_ip", ip_address, "inet")
add_value("mhs_av", mhs_av)
add_value("power_w", power_w)
add_value("power_rate_w_per_th", power_rate)
add_value("chip_temp_max_c", chip_temp_max)
add_value("chip_temp_avg_c", chip_temp_avg)
add_value("fan_speed_in_rpm", fan_in)
add_value("fan_speed_out_rpm", fan_out)
add_value("hash_stable", hash_stable)
add_value("pool_rejected_percent", pool_rejected_percent)
add_now("created_at")
if "raw_summary_json" in snapshot_columns:
import json
add_value("raw_summary_json", json.dumps(summary))
if len(insert_columns) > 0:
sql = f"""
INSERT INTO miner_status_snapshots (
{", ".join(insert_columns)}
)
VALUES (
{", ".join(insert_values_sql)}
);
"""
cur.execute(sql, values)
conn.commit()
return {
"success": True,
"miner_id": miner_id,
"ip_address": ip_address,
"inferred_model": inferred_model,
"mhs_av": mhs_av,
"hashrate_ths": None if mhs_av is None else round(mhs_av / 1000000.0, 2),
"power_w": power_w,
"chip_temp_max_c": chip_temp_max,
"chip_temp_avg_c": chip_temp_avg,
"hash_stable": hash_stable,
"pool_rejected_percent": pool_rejected_percent,
}
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
@app.post("/api/tools/legacy-summary-refresh-one")
def legacy_summary_refresh_one(
request: LegacySummaryRefreshRequest,
current_user: dict = Depends(get_current_user),
):
if not request.miner_id and not request.ip_address:
raise HTTPException(status_code=400, detail="Provide miner_id or ip_address")
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
if request.miner_id:
cur.execute(
"""
SELECT id::text, host(current_ip) AS current_ip
FROM miners
WHERE id = %s::uuid;
""",
(request.miner_id,),
)
else:
cur.execute(
"""
SELECT id::text, host(current_ip) AS current_ip
FROM miners
WHERE current_ip = %s::inet
LIMIT 1;
""",
(request.ip_address,),
)
miner = cur.fetchone()
if not miner:
raise HTTPException(status_code=404, detail="Miner not found")
miner_id = miner["id"]
ip_address = request.ip_address or miner["current_ip"]
if not ip_address:
raise HTTPException(status_code=400, detail="Miner has no current IP address")
result = apply_legacy_summary_to_miner(
miner_id,
ip_address,
request.timeout_seconds,
)
return api_response(data=result)
finally:
if conn is not None:
conn.close()
@app.post("/api/tools/legacy-summary-refresh-missing")
def legacy_summary_refresh_missing(
request: LegacySummaryRefreshRequest,
current_user: dict = Depends(get_current_user),
):
safe_limit = max(1, min(int(request.limit or 50), 300))
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
WITH latest AS (
SELECT DISTINCT ON (miner_id)
miner_id,
mhs_av,
chip_temp_max_c,
chip_temp_avg_c,
observed_at
FROM miner_status_snapshots
ORDER BY miner_id, observed_at DESC
)
SELECT
miners.id::text,
host(miners.current_ip) AS current_ip,
miners.current_mac,
miners.model,
miners.miner_type,
latest.mhs_av,
latest.chip_temp_max_c,
latest.chip_temp_avg_c
FROM miners
LEFT JOIN latest ON latest.miner_id = miners.id
WHERE miners.current_ip IS NOT NULL
AND (
miners.model IS NULL
OR trim(miners.model) = ''
OR miners.miner_type IS NULL
OR trim(miners.miner_type) = ''
OR latest.mhs_av IS NULL
OR latest.chip_temp_max_c IS NULL
)
ORDER BY miners.current_ip
LIMIT %s;
""",
(safe_limit,),
)
miners = [dict(row) for row in cur.fetchall()]
results = []
for miner in miners:
result = apply_legacy_summary_to_miner(
miner["id"],
miner["current_ip"],
request.timeout_seconds,
)
results.append(result)
return api_response(
data=results,
meta={
"attempted": len(results),
"success": len([row for row in results if row.get("success")]),
"failed": len([row for row in results if not row.get("success")]),
},
)
finally:
if conn is not None:
conn.close()
def legacy_find_miner_id_by_ip(ip_address: str):
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT id::text
FROM miners
WHERE current_ip = %s::inet
LIMIT 1;
""",
(ip_address,),
)
row = cur.fetchone()
if not row:
return None
return row["id"]
finally:
if conn is not None:
conn.close()
try:
_minerops_original_discover_one_miner_legacy_summary = discover_one_miner
def discover_one_miner(request, current_user: dict = Depends(get_current_user)):
result = _minerops_original_discover_one_miner_legacy_summary(request, current_user)
try:
ip_address = getattr(request, "ip_address", None) or getattr(request, "ip", None)
if ip_address:
miner_id = legacy_find_miner_id_by_ip(ip_address)
if miner_id and miner_needs_legacy_summary_patch(miner_id):
apply_legacy_summary_to_miner(miner_id, ip_address, 1.5)
except Exception as legacy_error:
print(f"Legacy summary fallback failed: {legacy_error}")
return result
except NameError:
pass
# -----------------------------
# Phase 3 Step 66: Room / Unit ordering
# -----------------------------
class LocationRoomReorderRequest(BaseModel):
room_ids: list[str]
def ensure_room_order_ready():
conn = None
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute(
"""
ALTER TABLE rooms
ADD COLUMN IF NOT EXISTS sort_order integer;
"""
)
cur.execute(
"""
WITH ordered AS (
SELECT
id,
((row_number() OVER (ORDER BY sort_order NULLS LAST, name)) - 1) * 10 AS new_sort_order
FROM rooms
)
UPDATE rooms
SET sort_order = ordered.new_sort_order
FROM ordered
WHERE rooms.id = ordered.id
AND rooms.sort_order IS NULL;
"""
)
conn.commit()
finally:
if conn is not None:
conn.close()
@app.get("/api/location/rooms-order")
def get_location_rooms_order(current_user: dict = Depends(get_current_user)):
ensure_location_tables()
ensure_room_order_ready()
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
rooms.id::text,
rooms.name,
COALESCE(rooms.sort_order, 999999) AS sort_order,
COUNT(location_positions.id)::int AS position_count,
COUNT(location_positions.id) FILTER (
WHERE location_positions.position_kind = 'miner_slot'
)::int AS miner_slot_count,
COUNT(location_positions.id) FILTER (
WHERE location_positions.position_kind = 'switch'
)::int AS switch_count,
COUNT(location_positions.id) FILTER (
WHERE location_positions.position_status = 'blocked'
OR location_positions.position_kind = 'blocked'
)::int AS blocked_count,
COUNT(location_positions.id) FILTER (
WHERE location_positions.miner_id IS NOT NULL
)::int AS assigned_miner_count
FROM rooms
LEFT JOIN location_positions
ON lower(location_positions.room_name) = lower(rooms.name)
GROUP BY rooms.id, rooms.name, rooms.sort_order
ORDER BY COALESCE(rooms.sort_order, 999999), rooms.name;
"""
)
rows = [dict(row) for row in cur.fetchall()]
return api_response(data=rows)
finally:
if conn is not None:
conn.close()
@app.post("/api/location/rooms/reorder")
def reorder_location_rooms(
request: LocationRoomReorderRequest,
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
ensure_room_order_ready()
if not request.room_ids:
raise HTTPException(status_code=400, detail="No room IDs supplied")
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
for index, room_id in enumerate(request.room_ids):
cur.execute(
"""
UPDATE rooms
SET sort_order = %s
WHERE id = %s::uuid;
""",
(
index * 10,
room_id,
),
)
cur.execute(
"""
SELECT
id::text,
name,
sort_order
FROM rooms
ORDER BY sort_order, name;
"""
)
rows = [dict(row) for row in cur.fetchall()]
conn.commit()
return api_response(data=rows)
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
# -----------------------------
# Phase 3 Step 67: Location management GUI APIs
# -----------------------------
class LocationRoomCreateRequest(BaseModel):
name: str
class LocationRackCreateRequest(BaseModel):
room_name: str
rack_name: str
shelf_count: int = 5
positions_per_shelf: int = 9
switch_shelf_index: int | None = None
switch_position_index: int | None = None
class LocationShelfCreateRequest(BaseModel):
room_name: str
rack_name: str
shelf_name: str | None = None
shelf_index: int | None = None
positions_per_shelf: int = 9
class LocationPositionTypeRequest(BaseModel):
room_name: str
rack_name: str
shelf_name: str
position_index: int
position_kind: str
equipment_label: str | None = None
notes: str | None = None
def location_get_table_columns(cur, table_name: str):
cur.execute(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_name = %s;
""",
(table_name,),
)
return {row["column_name"] if isinstance(row, dict) else row[0] for row in cur.fetchall()}
def find_or_create_location_room(cur, room_name: str):
clean_name = str(room_name or "").strip()
if not clean_name:
raise HTTPException(status_code=400, detail="Room name is required")
cur.execute(
"""
SELECT id::text, name
FROM rooms
WHERE lower(name) = lower(%s)
LIMIT 1;
""",
(clean_name,),
)
existing = cur.fetchone()
if existing:
return existing["id"], existing["name"], False
room_columns = location_get_table_columns(cur, "rooms")
insert_columns = []
insert_values_sql = []
values = []
def add_value(column_name, value, sql_cast=None):
if column_name not in room_columns:
return
insert_columns.append(column_name)
if sql_cast:
insert_values_sql.append(f"%s::{sql_cast}")
else:
insert_values_sql.append("%s")
values.append(value)
def add_now(column_name):
if column_name not in room_columns:
return
insert_columns.append(column_name)
insert_values_sql.append("now()")
room_id = str(uuid4())
add_value("id", room_id, "uuid")
add_value("name", clean_name)
if "site_id" in room_columns:
cur.execute(
"""
SELECT id::text
FROM sites
ORDER BY name
LIMIT 1;
"""
)
site = cur.fetchone()
if not site:
raise HTTPException(status_code=400, detail="No site exists to attach the room to")
site_id = site["id"] if isinstance(site, dict) else site[0]
add_value("site_id", site_id, "uuid")
if "sort_order" in room_columns:
cur.execute(
"""
SELECT COALESCE(MAX(sort_order), -10) + 10
FROM rooms;
"""
)
next_sort_order = cur.fetchone()[0]
add_value("sort_order", next_sort_order)
add_value("is_enabled", True)
add_value("enabled", True)
add_now("created_at")
add_now("updated_at")
sql = f"""
INSERT INTO rooms ({", ".join(insert_columns)})
VALUES ({", ".join(insert_values_sql)})
RETURNING id::text, name;
"""
cur.execute(sql, values)
created = cur.fetchone()
return created["id"], created["name"], True
def make_default_shelf_name(shelf_index: int):
if int(shelf_index) == 1:
return "shelf1(top)"
return f"shelf{int(shelf_index)}"
def create_location_slot(
cur,
room_id,
room_name,
rack_name,
shelf_name,
shelf_index,
position_index,
position_kind="miner_slot",
position_status="available",
equipment_label=None,
notes=None,
):
cur.execute(
"""
INSERT INTO location_positions (
id,
room_id,
room_name,
rack_name,
shelf_name,
shelf_index,
position_index,
position_label,
position_kind,
position_status,
equipment_label,
notes,
source_file,
raw_value,
updated_at
)
VALUES (
%s::uuid,
%s::uuid,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
'manual_gui',
%s,
now()
)
ON CONFLICT (room_name, rack_name, shelf_name, position_index)
DO NOTHING
RETURNING id::text;
""",
(
str(uuid4()),
room_id,
room_name,
rack_name,
shelf_name,
shelf_index,
position_index,
f"Pos {position_index}",
position_kind,
position_status,
equipment_label,
notes,
equipment_label or notes,
),
)
row = cur.fetchone()
return row["id"] if row else None
@app.post("/api/location/rooms/create")
def create_location_room(
request: LocationRoomCreateRequest,
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
ensure_room_order_ready()
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
room_id, room_name, was_created = find_or_create_location_room(cur, request.name)
conn.commit()
return api_response(
data={
"id": room_id,
"name": room_name,
"created": was_created,
}
)
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
@app.post("/api/location/racks/create")
def create_location_rack(
request: LocationRackCreateRequest,
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
ensure_room_order_ready()
room_name = str(request.room_name or "").strip()
rack_name = str(request.rack_name or "").strip()
if not room_name:
raise HTTPException(status_code=400, detail="Room name is required")
if not rack_name:
raise HTTPException(status_code=400, detail="Rack name is required")
shelf_count = max(1, min(int(request.shelf_count or 1), 20))
positions_per_shelf = max(1, min(int(request.positions_per_shelf or 1), 50))
conn = None
try:
conn = get_db_connection()
created_positions = 0
skipped_positions = 0
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
room_id, room_name, room_created = find_or_create_location_room(cur, room_name)
for shelf_index in range(1, shelf_count + 1):
shelf_name = make_default_shelf_name(shelf_index)
for position_index in range(1, positions_per_shelf + 1):
position_kind = "miner_slot"
position_status = "available"
equipment_label = None
notes = None
if (
request.switch_shelf_index is not None
and request.switch_position_index is not None
and int(request.switch_shelf_index) == shelf_index
and int(request.switch_position_index) == position_index
):
position_kind = "switch"
position_status = "switch"
equipment_label = f"Switch {rack_name}"
notes = "Created from location management GUI."
position_id = create_location_slot(
cur,
room_id,
room_name,
rack_name,
shelf_name,
shelf_index,
position_index,
position_kind,
position_status,
equipment_label,
notes,
)
if position_id:
created_positions += 1
else:
skipped_positions += 1
conn.commit()
return api_response(
data={
"room_name": room_name,
"rack_name": rack_name,
"shelf_count": shelf_count,
"positions_per_shelf": positions_per_shelf,
"created_positions": created_positions,
"skipped_existing_positions": skipped_positions,
}
)
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
@app.post("/api/location/shelves/create")
def create_location_shelf(
request: LocationShelfCreateRequest,
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
ensure_room_order_ready()
room_name = str(request.room_name or "").strip()
rack_name = str(request.rack_name or "").strip()
if not room_name:
raise HTTPException(status_code=400, detail="Room name is required")
if not rack_name:
raise HTTPException(status_code=400, detail="Rack name is required")
positions_per_shelf = max(1, min(int(request.positions_per_shelf or 1), 50))
conn = None
try:
conn = get_db_connection()
created_positions = 0
skipped_positions = 0
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
room_id, room_name, room_created = find_or_create_location_room(cur, room_name)
if request.shelf_index is None:
cur.execute(
"""
SELECT COALESCE(MAX(shelf_index), 0) + 1 AS next_shelf_index
FROM location_positions
WHERE lower(room_name) = lower(%s)
AND lower(rack_name) = lower(%s);
""",
(
room_name,
rack_name,
),
)
shelf_index = int(cur.fetchone()["next_shelf_index"])
else:
shelf_index = max(1, int(request.shelf_index))
shelf_name = str(request.shelf_name or "").strip()
if not shelf_name:
shelf_name = make_default_shelf_name(shelf_index)
for position_index in range(1, positions_per_shelf + 1):
position_id = create_location_slot(
cur,
room_id,
room_name,
rack_name,
shelf_name,
shelf_index,
position_index,
"miner_slot",
"available",
None,
None,
)
if position_id:
created_positions += 1
else:
skipped_positions += 1
conn.commit()
return api_response(
data={
"room_name": room_name,
"rack_name": rack_name,
"shelf_name": shelf_name,
"shelf_index": shelf_index,
"positions_per_shelf": positions_per_shelf,
"created_positions": created_positions,
"skipped_existing_positions": skipped_positions,
}
)
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
@app.post("/api/location/positions/set-type")
def set_location_position_type(
request: LocationPositionTypeRequest,
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
room_name = str(request.room_name or "").strip()
rack_name = str(request.rack_name or "").strip()
shelf_name = str(request.shelf_name or "").strip()
position_index = int(request.position_index or 0)
position_kind = str(request.position_kind or "").strip().lower()
if position_kind not in ("miner_slot", "switch", "blocked"):
raise HTTPException(status_code=400, detail="position_kind must be miner_slot, switch, or blocked")
if not room_name or not rack_name or not shelf_name or position_index <= 0:
raise HTTPException(status_code=400, detail="Room, rack, shelf, and position are required")
if position_kind == "switch":
position_status = "switch"
equipment_label = request.equipment_label or f"Switch {rack_name}"
notes = request.notes or "Switch position."
elif position_kind == "blocked":
position_status = "blocked"
equipment_label = request.equipment_label
notes = request.notes or "Blocked/bad spot."
else:
equipment_label = request.equipment_label
notes = request.notes
conn_check = None
try:
conn_check = get_db_connection()
with conn_check.cursor(row_factory=psycopg.rows.dict_row) as check_cur:
check_cur.execute(
"""
SELECT miner_id
FROM location_positions
WHERE lower(room_name) = lower(%s)
AND lower(rack_name) = lower(%s)
AND lower(shelf_name) = lower(%s)
AND position_index = %s
LIMIT 1;
""",
(
room_name,
rack_name,
shelf_name,
position_index,
),
)
existing_position = check_cur.fetchone()
if existing_position and existing_position.get("miner_id"):
position_status = "occupied"
else:
position_status = "available"
finally:
if conn_check is not None:
conn_check.close()
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
UPDATE location_positions
SET
position_kind = %s,
position_status = %s,
equipment_label = %s,
notes = %s,
updated_at = now()
WHERE lower(room_name) = lower(%s)
AND lower(rack_name) = lower(%s)
AND lower(shelf_name) = lower(%s)
AND position_index = %s
RETURNING
id::text,
room_name,
rack_name,
shelf_name,
position_index,
position_kind,
position_status,
equipment_label,
notes;
""",
(
position_kind,
position_status,
equipment_label,
notes,
room_name,
rack_name,
shelf_name,
position_index,
),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Location position not found")
row = dict(row)
conn.commit()
return api_response(data=row)
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
# -----------------------------
# Phase 3 Step 67.1: Safe location delete APIs
# -----------------------------
class LocationRackDeleteRequest(BaseModel):
room_name: str
rack_name: str
allow_delete_occupied: bool = False
class LocationRoomDeleteRequest(BaseModel):
room_name: str
allow_delete_occupied: bool = False
delete_room_record: bool = True
@app.post("/api/location/racks/delete")
def delete_location_rack(
request: LocationRackDeleteRequest,
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
room_name = str(request.room_name or "").strip()
rack_name = str(request.rack_name or "").strip()
if not room_name or not rack_name:
raise HTTPException(status_code=400, detail="Room name and rack name are required")
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
COUNT(*)::int AS position_count,
COUNT(*) FILTER (WHERE miner_id IS NOT NULL)::int AS assigned_miner_count
FROM location_positions
WHERE lower(room_name) = lower(%s)
AND lower(rack_name) = lower(%s);
""",
(
room_name,
rack_name,
),
)
counts = dict(cur.fetchone())
if counts["position_count"] == 0:
raise HTTPException(status_code=404, detail="Rack not found")
if counts["assigned_miner_count"] > 0 and not request.allow_delete_occupied:
raise HTTPException(
status_code=400,
detail="Rack has assigned miners. Move miners first before deleting the rack.",
)
if request.allow_delete_occupied:
cur.execute(
"""
UPDATE miner_location_states
SET
asset_state = 'waiting_placement',
location_position_id = NULL,
updated_at = now()
WHERE location_position_id IN (
SELECT id
FROM location_positions
WHERE lower(room_name) = lower(%s)
AND lower(rack_name) = lower(%s)
);
""",
(
room_name,
rack_name,
),
)
cur.execute(
"""
DELETE FROM location_positions
WHERE lower(room_name) = lower(%s)
AND lower(rack_name) = lower(%s);
""",
(
room_name,
rack_name,
),
)
deleted_positions = cur.rowcount
conn.commit()
return api_response(
data={
"room_name": room_name,
"rack_name": rack_name,
"deleted_positions": deleted_positions,
"assigned_miner_count": counts["assigned_miner_count"],
}
)
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
@app.post("/api/location/rooms/delete")
def delete_location_room(
request: LocationRoomDeleteRequest,
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
room_name = str(request.room_name or "").strip()
if not room_name:
raise HTTPException(status_code=400, detail="Room name is required")
conn = None
try:
conn = get_db_connection()
room_deleted = False
room_delete_reason = None
room_id = None
miner_reference_count = 0
scan_range_reference_count = 0
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
COUNT(*)::int AS position_count,
COUNT(*) FILTER (WHERE miner_id IS NOT NULL)::int AS assigned_miner_count
FROM location_positions
WHERE lower(room_name) = lower(%s);
""",
(room_name,),
)
counts = dict(cur.fetchone())
if counts["position_count"] == 0:
cur.execute(
"""
SELECT id::text
FROM rooms
WHERE lower(name) = lower(%s)
LIMIT 1;
""",
(room_name,),
)
existing_room = cur.fetchone()
if not existing_room:
raise HTTPException(status_code=404, detail="Room not found")
if counts["assigned_miner_count"] > 0 and not request.allow_delete_occupied:
raise HTTPException(
status_code=400,
detail="Room has assigned miners. Move miners first before deleting the room map.",
)
if request.allow_delete_occupied:
cur.execute(
"""
UPDATE miner_location_states
SET
asset_state = 'waiting_placement',
location_position_id = NULL,
updated_at = now()
WHERE location_position_id IN (
SELECT id
FROM location_positions
WHERE lower(room_name) = lower(%s)
);
""",
(room_name,),
)
cur.execute(
"""
DELETE FROM location_positions
WHERE lower(room_name) = lower(%s);
""",
(room_name,),
)
deleted_positions = cur.rowcount
cur.execute(
"""
SELECT id::text
FROM rooms
WHERE lower(name) = lower(%s)
LIMIT 1;
""",
(room_name,),
)
room = cur.fetchone()
if room:
room_id = room["id"]
if request.delete_room_record and room_id:
cur.execute(
"""
SELECT COUNT(*)::int AS count
FROM miners
WHERE room_id = %s::uuid;
""",
(room_id,),
)
miner_reference_count = cur.fetchone()["count"]
cur.execute(
"""
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'scan_ranges'
AND column_name = 'room_id'
) AS has_scan_range_room_id;
"""
)
has_scan_range_room_id = cur.fetchone()["has_scan_range_room_id"]
if has_scan_range_room_id:
cur.execute(
"""
SELECT COUNT(*)::int AS count
FROM scan_ranges
WHERE room_id = %s::uuid;
""",
(room_id,),
)
scan_range_reference_count = cur.fetchone()["count"]
if miner_reference_count == 0 and scan_range_reference_count == 0:
cur.execute(
"""
DELETE FROM rooms
WHERE id = %s::uuid;
""",
(room_id,),
)
room_deleted = cur.rowcount > 0
else:
room_delete_reason = (
"Room map positions were deleted, but the room record was kept because "
f"{miner_reference_count} miners and {scan_range_reference_count} scan ranges still reference it."
)
conn.commit()
return api_response(
data={
"room_name": room_name,
"deleted_positions": deleted_positions,
"assigned_miner_count": counts["assigned_miner_count"],
"room_record_deleted": room_deleted,
"room_delete_reason": room_delete_reason,
"miner_reference_count": miner_reference_count,
"scan_range_reference_count": scan_range_reference_count,
}
)
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
# -----------------------------
# Phase 3 Step 68: Spreadsheet layout import
# -----------------------------
class LocationSpreadsheetImportRequest(BaseModel):
file_name: str
file_base64: str
room_name_override: str | None = None
def xlsx_column_name_to_number(cell_ref: str):
letters = ""
for ch in str(cell_ref):
if ch.isalpha():
letters += ch.upper()
else:
break
value = 0
for ch in letters:
value = value * 26 + (ord(ch) - ord("A") + 1)
return value
def parse_xlsx_shared_strings(zf):
import xml.etree.ElementTree as ET
try:
data = zf.read("xl/sharedStrings.xml")
except KeyError:
return []
root = ET.fromstring(data)
namespace = {"main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
strings = []
for si in root.findall("main:si", namespace):
text_parts = []
for text_node in si.findall(".//main:t", namespace):
text_parts.append(text_node.text or "")
strings.append("".join(text_parts))
return strings
def parse_xlsx_workbook_sheet_names(zf):
import xml.etree.ElementTree as ET
try:
workbook_data = zf.read("xl/workbook.xml")
rels_data = zf.read("xl/_rels/workbook.xml.rels")
except KeyError:
return [("Sheet1", "xl/worksheets/sheet1.xml")]
ns_main = {"main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
ns_rel = {"rel": "http://schemas.openxmlformats.org/package/2006/relationships"}
workbook_root = ET.fromstring(workbook_data)
rels_root = ET.fromstring(rels_data)
rel_map = {}
for rel in rels_root.findall("rel:Relationship", ns_rel):
rel_id = rel.attrib.get("Id")
target = rel.attrib.get("Target")
if rel_id and target:
if target.startswith("/"):
path = target.lstrip("/")
elif target.startswith("xl/"):
path = target
else:
path = "xl/" + target
rel_map[rel_id] = path
sheets = []
for sheet in workbook_root.findall(".//main:sheet", ns_main):
name = sheet.attrib.get("name", "Sheet")
rel_id = sheet.attrib.get("{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id")
path = rel_map.get(rel_id)
if path:
sheets.append((name, path))
if not sheets:
return [("Sheet1", "xl/worksheets/sheet1.xml")]
return sheets
def parse_xlsx_sheet_rows(zf, sheet_path, shared_strings):
import xml.etree.ElementTree as ET
try:
data = zf.read(sheet_path)
except KeyError:
return []
root = ET.fromstring(data)
namespace = {"main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
rows = []
for row in root.findall(".//main:sheetData/main:row", namespace):
row_number = int(row.attrib.get("r", "0"))
cells = {}
for cell in row.findall("main:c", namespace):
cell_ref = cell.attrib.get("r", "")
col_number = xlsx_column_name_to_number(cell_ref)
cell_type = cell.attrib.get("t")
value_node = cell.find("main:v", namespace)
value = None
if value_node is not None:
raw_value = value_node.text or ""
if cell_type == "s":
try:
value = shared_strings[int(raw_value)]
except Exception:
value = raw_value
else:
value = raw_value
inline_node = cell.find("main:is/main:t", namespace)
if inline_node is not None:
value = inline_node.text or ""
if value is not None:
cells[col_number] = str(value).strip()
if cells:
rows.append(
{
"row_number": row_number,
"cells": cells,
}
)
return rows
def parse_location_layout_xlsx(file_bytes: bytes, file_name: str, room_name_override: str | None = None):
import zipfile
import io
positions = []
parse_warnings = []
parsed_room_names = set()
parsed_rack_names = set()
with zipfile.ZipFile(io.BytesIO(file_bytes), "r") as zf:
shared_strings = parse_xlsx_shared_strings(zf)
sheets = parse_xlsx_workbook_sheet_names(zf)
for sheet_name, sheet_path in sheets:
rows = parse_xlsx_sheet_rows(zf, sheet_path, shared_strings)
current_room_name = room_name_override
current_rack_name = None
shelf_index_for_rack = 0
for row in rows:
cells = row["cells"]
first_col = min(cells.keys())
first_value = str(cells.get(first_col, "") or "").strip()
if not first_value:
continue
first_lower = first_value.lower()
if first_lower.startswith("unit ") or first_lower.startswith("room "):
if not room_name_override:
current_room_name = first_value
parsed_room_names.add(current_room_name)
continue
if first_lower.startswith("rack ") or first_lower in ("bathroom", "office"):
current_rack_name = first_value
parsed_rack_names.add(current_rack_name)
shelf_index_for_rack = 0
continue
if first_lower.startswith("shelf"):
if not current_room_name:
parse_warnings.append(f"{file_name} {sheet_name} row {row['row_number']}: shelf found before unit/room name.")
continue
if not current_rack_name:
parse_warnings.append(f"{file_name} {sheet_name} row {row['row_number']}: shelf found before rack name.")
continue
shelf_index_for_rack += 1
shelf_name = first_value
max_col = max(cells.keys()) if cells else first_col
for col_number in range(first_col + 1, max_col + 1):
raw_value = str(cells.get(col_number, "") or "").strip()
position_index = col_number - first_col
source_cell = f"R{row['row_number']}C{col_number}"
item_type = "empty"
position_status = "available"
mac_address = None
label = None
notes = None
conflict_reason = None
cleaned_mac = "".join(ch for ch in raw_value.lower() if ch in "0123456789abcdef")
if raw_value:
lower_value = raw_value.lower()
if "switch" in lower_value:
item_type = "switch"
position_status = "switch"
label = raw_value
elif "bad" in lower_value or "blocked" in lower_value or "unusable" in lower_value:
item_type = "blocked"
position_status = "blocked"
label = raw_value
notes = "Imported as permanent blocked/bad spot."
elif len(cleaned_mac) == 12:
item_type = "miner"
position_status = "occupied"
mac_address = normalize_mac_for_location(raw_value)
else:
item_type = "blocked"
position_status = "blocked"
label = raw_value
notes = "Imported as blocked because the cell was not empty, switch, or valid MAC."
positions.append(
{
"room_name": current_room_name,
"rack_name": current_rack_name,
"shelf_name": shelf_name,
"shelf_index": shelf_index_for_rack,
"position_index": position_index,
"position_label": f"Pos {position_index}",
"item_type": item_type,
"position_status": position_status,
"mac_address": mac_address,
"label": label,
"notes": notes,
"conflict_reason": conflict_reason,
"source_file": file_name,
"source_sheet": sheet_name,
"source_cell": source_cell,
"raw_value": raw_value,
}
)
return {
"positions": positions,
"parse_warnings": parse_warnings,
"parsed_room_names": sorted(list(parsed_room_names)),
"parsed_rack_names": sorted(list(parsed_rack_names)),
}
@app.post("/api/location/import-spreadsheet")
def import_location_spreadsheet(
request: LocationSpreadsheetImportRequest,
current_user: dict = Depends(get_current_user),
):
import base64
ensure_location_tables()
if not request.file_base64:
raise HTTPException(status_code=400, detail="No spreadsheet file data supplied")
file_name = request.file_name or "uploaded-layout.xlsx"
if not file_name.lower().endswith(".xlsx"):
raise HTTPException(status_code=400, detail="Only .xlsx files are supported")
try:
file_bytes = base64.b64decode(request.file_base64)
except Exception:
raise HTTPException(status_code=400, detail="Could not decode uploaded spreadsheet")
parsed = parse_location_layout_xlsx(
file_bytes,
file_name,
request.room_name_override,
)
positions = parsed["positions"]
if not positions:
raise HTTPException(status_code=400, detail="No layout positions found in spreadsheet")
seed_request = LocationSeedImportRequest(
schema_version=1,
generated_from=[file_name],
notes="Imported from Location Map spreadsheet upload GUI.",
positions=positions,
)
result = import_location_layout_seed(seed_request, current_user)
if isinstance(result, dict) and result.get("data") is not None:
result["data"]["spreadsheet_parse"] = {
"file_name": file_name,
"positions_parsed": len(positions),
"parse_warnings": parsed["parse_warnings"],
"parsed_room_names": parsed["parsed_room_names"],
"parsed_rack_names": parsed["parsed_rack_names"],
"room_name_override": request.room_name_override,
}
return result
# -----------------------------
# Phase 3 Step 69: Move miners between physical and virtual locations
# -----------------------------
class MinerLocationMoveRequest(BaseModel):
miner_id: str | None = None
mac_address: str | None = None
destination_type: str = "physical"
location_position_id: str | None = None
room_name: str | None = None
rack_name: str | None = None
shelf_name: str | None = None
position_index: int | None = None
asset_state: str | None = None
notes: str | None = None
def find_miner_for_location_move(cur, miner_id: str | None, mac_address: str | None):
if miner_id:
cur.execute(
"""
SELECT
id::text,
current_mac,
host(current_ip) AS current_ip,
room_id::text
FROM miners
WHERE id = %s::uuid
LIMIT 1;
""",
(miner_id,),
)
row = cur.fetchone()
if row:
return dict(row)
if mac_address:
mac_key = normalize_mac_key_for_location(mac_address)
if not mac_key:
raise HTTPException(status_code=400, detail="MAC address is invalid")
cur.execute(
"""
SELECT
id::text,
current_mac,
host(current_ip) AS current_ip,
room_id::text
FROM miners
WHERE regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g') = %s
LIMIT 1;
""",
(mac_key,),
)
row = cur.fetchone()
if row:
return dict(row)
return None
def describe_location_position(row):
if not row:
return "Unplaced"
return (
f"{row.get('room_name') or '-'} / "
f"{row.get('rack_name') or '-'} / "
f"{row.get('shelf_name') or '-'} / "
f"Pos {row.get('position_index') or '-'}"
)
@app.post("/api/location/miners/move")
def move_miner_location(
request: MinerLocationMoveRequest,
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
ensure_maintenance_tables()
valid_virtual_states = {
"waiting_placement",
"storage",
"repair",
"decommissioned",
}
destination_type = str(request.destination_type or "physical").strip().lower()
if destination_type not in ("physical", "virtual"):
raise HTTPException(status_code=400, detail="destination_type must be physical or virtual")
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
miner = find_miner_for_location_move(cur, request.miner_id, request.mac_address)
if not miner:
raise HTTPException(status_code=404, detail="Miner not found")
miner_id = miner["id"]
current_mac = miner.get("current_mac")
cur.execute(
"""
SELECT
id::text,
room_name,
rack_name,
shelf_name,
position_index
FROM location_positions
WHERE miner_id = %s::uuid
ORDER BY room_name, rack_name, shelf_index, position_index;
""",
(miner_id,),
)
old_positions = [dict(row) for row in cur.fetchall()]
old_location_text = " / ".join([describe_location_position(row) for row in old_positions]) if old_positions else "Unplaced"
new_location_text = None
target_position_id = None
target_room_name = None
# Clear current physical positions first. If anything fails later, rollback restores them.
cur.execute(
"""
UPDATE location_positions
SET
miner_id = NULL,
expected_mac = NULL,
position_status = CASE
WHEN position_kind = 'miner_slot' THEN 'available'
ELSE position_status
END,
conflict_reason = NULL,
updated_at = now()
WHERE miner_id = %s::uuid;
""",
(miner_id,),
)
if destination_type == "physical":
if request.location_position_id:
cur.execute(
"""
SELECT
id::text,
room_id::text,
room_name,
rack_name,
shelf_name,
shelf_index,
position_index,
position_kind,
position_status,
miner_id::text AS assigned_miner_id,
expected_mac
FROM location_positions
WHERE id = %s::uuid
FOR UPDATE;
""",
(request.location_position_id,),
)
else:
if not request.room_name or not request.rack_name or not request.shelf_name or not request.position_index:
raise HTTPException(
status_code=400,
detail="Physical moves require room, rack, shelf, and position",
)
cur.execute(
"""
SELECT
id::text,
room_id::text,
room_name,
rack_name,
shelf_name,
shelf_index,
position_index,
position_kind,
position_status,
miner_id::text AS assigned_miner_id,
expected_mac
FROM location_positions
WHERE lower(room_name) = lower(%s)
AND lower(rack_name) = lower(%s)
AND lower(shelf_name) = lower(%s)
AND position_index = %s
FOR UPDATE;
""",
(
request.room_name,
request.rack_name,
request.shelf_name,
request.position_index,
),
)
target = cur.fetchone()
if not target:
raise HTTPException(status_code=404, detail="Target physical position not found")
target = dict(target)
if target.get("position_kind") != "miner_slot":
raise HTTPException(status_code=400, detail="Target position is not a miner slot")
if target.get("position_status") in ("switch", "blocked"):
raise HTTPException(status_code=400, detail="Target position is switch/blocked")
assigned_miner_id = target.get("assigned_miner_id")
if assigned_miner_id and assigned_miner_id != miner_id:
raise HTTPException(status_code=400, detail="Target position is already occupied")
target_position_id = target["id"]
target_room_name = target["room_name"]
new_location_text = describe_location_position(target)
cur.execute(
"""
UPDATE location_positions
SET
miner_id = %s::uuid,
expected_mac = %s,
position_status = 'occupied',
conflict_reason = NULL,
updated_at = now()
WHERE id = %s::uuid;
""",
(
miner_id,
current_mac,
target_position_id,
),
)
cur.execute(
"""
SELECT id::text
FROM rooms
WHERE lower(name) = lower(%s)
LIMIT 1;
""",
(target_room_name,),
)
room = cur.fetchone()
room_id = room["id"] if room else None
if room_id:
cur.execute(
"""
UPDATE miners
SET room_id = %s::uuid
WHERE id = %s::uuid;
""",
(
room_id,
miner_id,
),
)
cur.execute(
"""
INSERT INTO miner_location_states (
miner_id,
asset_state,
location_position_id,
state_notes,
updated_by_user_id,
updated_at
)
VALUES (
%s::uuid,
'shelved',
%s::uuid,
%s,
%s::uuid,
now()
)
ON CONFLICT (miner_id)
DO UPDATE SET
asset_state = 'shelved',
location_position_id = EXCLUDED.location_position_id,
state_notes = EXCLUDED.state_notes,
updated_by_user_id = EXCLUDED.updated_by_user_id,
updated_at = now();
""",
(
miner_id,
target_position_id,
request.notes,
get_current_user_id_for_location(current_user),
),
)
new_asset_state = "shelved"
else:
asset_state = str(request.asset_state or "").strip().lower()
if asset_state not in valid_virtual_states:
raise HTTPException(
status_code=400,
detail="asset_state must be waiting_placement, storage, repair, or decommissioned",
)
cur.execute(
"""
INSERT INTO miner_location_states (
miner_id,
asset_state,
location_position_id,
state_notes,
updated_by_user_id,
updated_at
)
VALUES (
%s::uuid,
%s,
NULL,
%s,
%s::uuid,
now()
)
ON CONFLICT (miner_id)
DO UPDATE SET
asset_state = EXCLUDED.asset_state,
location_position_id = NULL,
state_notes = EXCLUDED.state_notes,
updated_by_user_id = EXCLUDED.updated_by_user_id,
updated_at = now();
""",
(
miner_id,
asset_state,
request.notes,
get_current_user_id_for_location(current_user),
),
)
new_location_text = asset_state
new_asset_state = asset_state
event_description = (
f"Miner moved from {old_location_text} to {new_location_text}."
)
if request.notes:
event_description += f" Notes: {request.notes}"
cur.execute(
"""
INSERT INTO miner_maintenance_events (
id,
miner_id,
event_type,
event_status,
event_title,
description,
event_at,
component_type,
component_slot,
created_by_user_id
)
VALUES (
%s::uuid,
%s::uuid,
'moved',
'completed',
'Miner location changed',
%s,
now(),
'location',
%s,
%s::uuid
)
RETURNING id::text;
""",
(
str(uuid4()),
miner_id,
event_description,
new_location_text,
get_current_user_id_for_location(current_user),
),
)
event = cur.fetchone()
conn.commit()
return api_response(
data={
"miner_id": miner_id,
"current_mac": current_mac,
"old_location": old_location_text,
"new_location": new_location_text,
"asset_state": new_asset_state,
"location_position_id": target_position_id,
"maintenance_event_id": event["id"] if event else None,
}
)
except Exception:
if conn is not None:
conn.rollback()
raise
finally:
if conn is not None:
conn.close()
# -----------------------------
# Step 86: Miner command control foundation
# -----------------------------
from fastapi import Request, HTTPException
async def minerops_command_db_connect():
import os
import asyncpg
database_url = os.environ.get("DATABASE_URL") or globals().get("DATABASE_URL")
if not database_url:
raise RuntimeError("DATABASE_URL is not configured.")
# asyncpg does not accept SQLAlchemy driver names like postgresql+psycopg://
database_url = database_url.replace("postgresql+psycopg://", "postgresql://", 1)
database_url = database_url.replace("postgresql+asyncpg://", "postgresql://", 1)
database_url = database_url.replace("postgres://", "postgresql://", 1)
return await asyncpg.connect(database_url)
async def ensure_miner_command_tables():
conn = await minerops_command_db_connect()
try:
await conn.execute("""
CREATE TABLE IF NOT EXISTS miner_command_events (
id TEXT PRIMARY KEY,
miner_id TEXT NOT NULL,
command_type TEXT NOT NULL,
command_status TEXT NOT NULL,
dry_run BOOLEAN NOT NULL DEFAULT TRUE,
requested_by_user_id TEXT,
requested_by_username TEXT,
target_ip TEXT,
target_mac TEXT,
request_payload JSONB,
response_payload JSONB,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ
);
""")
await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_miner_command_events_miner_id_created
ON miner_command_events (miner_id, created_at DESC);
""")
finally:
await conn.close()
async def get_miner_command_target(miner_id: str):
conn = await minerops_command_db_connect()
try:
columns = await conn.fetch("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'miners'
""")
column_names = {row["column_name"] for row in columns}
def pick_column(candidates):
for candidate in candidates:
if candidate in column_names:
return candidate
return None
ip_col = pick_column([
"current_ip",
"ip_address",
"ip",
"last_ip",
"network_ip"
])
mac_col = pick_column([
"current_mac",
"mac_address",
"mac",
"expected_mac",
"network_mac"
])
model_col = pick_column([
"model",
"miner_model",
"miner_type",
"hardware_model"
])
select_parts = ["id::text AS miner_id"]
if ip_col:
select_parts.append(f"{ip_col}::text AS target_ip")
else:
select_parts.append("NULL::text AS target_ip")
if mac_col:
select_parts.append(f"{mac_col}::text AS target_mac")
else:
select_parts.append("NULL::text AS target_mac")
if model_col:
select_parts.append(f"{model_col}::text AS model")
else:
select_parts.append("NULL::text AS model")
sql = f"""
SELECT {", ".join(select_parts)}
FROM miners
WHERE id::text = $1
LIMIT 1
"""
row = await conn.fetchrow(sql, miner_id)
if not row:
return None
return dict(row)
finally:
await conn.close()
async def insert_miner_command_event(
miner_id: str,
command_type: str,
command_status: str,
dry_run: bool,
target_ip: str | None = None,
target_mac: str | None = None,
requested_by_user_id: str | None = None,
requested_by_username: str | None = None,
request_payload: dict | None = None,
response_payload: dict | None = None,
error_message: str | None = None,
):
import json
import uuid
await ensure_miner_command_tables()
event_id = str(uuid.uuid4())
conn = await minerops_command_db_connect()
try:
await conn.execute(
"""
INSERT INTO miner_command_events (
id,
miner_id,
command_type,
command_status,
dry_run,
requested_by_user_id,
requested_by_username,
target_ip,
target_mac,
request_payload,
response_payload,
error_message,
completed_at
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
$10::jsonb,
$11::jsonb,
$12,
now()
)
""",
event_id,
miner_id,
command_type,
command_status,
dry_run,
requested_by_user_id,
requested_by_username,
target_ip,
target_mac,
json.dumps(request_payload or {}),
json.dumps(response_payload or {}),
error_message,
)
finally:
await conn.close()
return event_id
def minerops_command_require_bearer(request: Request):
auth_header = request.headers.get("authorization") or ""
if not auth_header.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="Login token required.")
async def minerops_command_user_from_request(request: Request):
username = None
user_id = None
# Best effort only. The existing auth system already owns real auth.
# This command layer requires a bearer token and records who the UI says is signed in when available.
try:
username = request.headers.get("x-minerops-username")
except Exception:
username = None
if not username:
username = "authenticated-user"
return user_id, username
@app.on_event("startup")
async def startup_ensure_miner_command_tables():
await ensure_miner_command_tables()
# -----------------------------
# Step 87: Real reboot command helpers
# -----------------------------
def minerops_env_enabled_default_true(name: str) -> bool:
import os
value = os.environ.get(name)
if value is None or str(value).strip() == "":
return True
value = str(value).strip().lower()
return value not in {"0", "false", "no", "off", "disabled"}
def minerops_env_enabled(name: str) -> bool:
import os
value = str(os.environ.get(name, "") or "").strip().lower()
return value in {"1", "true", "yes", "on", "enabled"}
def minerops_get_whatsminer_api_port() -> int:
import os
raw = str(os.environ.get("MINEROPS_WHATSMINER_API_PORT", "4028") or "4028").strip()
try:
return int(raw)
except Exception:
return 4028
def minerops_normalize_target_ip(target_ip: str | None) -> str | None:
if not target_ip:
return target_ip
cleaned = str(target_ip).strip()
# Stored IPs can sometimes be CIDR notation like 10.30.0.53/32.
# The socket connection needs only the host address.
if "/" in cleaned:
cleaned = cleaned.split("/", 1)[0].strip()
if cleaned.startswith("[") and cleaned.endswith("]"):
cleaned = cleaned[1:-1].strip()
return cleaned
def minerops_send_whatsminer_reboot_sync(target_ip: str, port: int = 4028, timeout_seconds: float = 10.0) -> dict:
import json
import socket
import time
target_ip = minerops_normalize_target_ip(target_ip)
if not target_ip:
raise RuntimeError("No target IP available for reboot command.")
command = {"cmd": "reboot"}
payload = json.dumps(command).encode("utf-8") + b"\n"
started = time.time()
chunks = []
with socket.create_connection((target_ip, port), timeout=timeout_seconds) as sock:
sock.settimeout(timeout_seconds)
sock.sendall(payload)
try:
sock.shutdown(socket.SHUT_WR)
except Exception:
pass
while True:
try:
chunk = sock.recv(65536)
if not chunk:
break
chunks.append(chunk)
if sum(len(item) for item in chunks) > 1048576:
break
except socket.timeout:
break
raw_response = b"".join(chunks).decode("utf-8", errors="replace").strip()
parsed_response = None
if raw_response:
try:
parsed_response = json.loads(raw_response)
except Exception:
parsed_response = None
return {
"sent": True,
"target_ip": target_ip,
"target_port": port,
"command": command,
"duration_seconds": round(time.time() - started, 3),
"raw_response": raw_response,
"json_response": parsed_response,
}
async def minerops_send_whatsminer_reboot(target_ip: str) -> dict:
import asyncio
port = minerops_get_whatsminer_api_port()
return await asyncio.to_thread(
minerops_send_whatsminer_reboot_sync,
target_ip,
port,
10.0,
)
@app.post("/api/miners/{miner_id}/commands/reboot")
async def dry_run_reboot_miner_command(miner_id: str, request: Request):
minerops_command_require_bearer(request)
try:
body = await request.json()
except Exception:
body = {}
dry_run = bool(body.get("dry_run", True))
confirm_text = str(body.get("confirm_text", "") or "").strip()
target = await get_miner_command_target(miner_id)
if not target:
raise HTTPException(status_code=404, detail="Miner not found.")
user_id, username = await minerops_command_user_from_request(request)
request_payload = {
"dry_run": dry_run,
"confirm_text": confirm_text,
"source": "miner_detail_control_card",
"real_reboot_enabled": minerops_env_enabled_default_true("MINEROPS_ENABLE_REAL_REBOOT"),
}
if dry_run:
event_id = await insert_miner_command_event(
miner_id=miner_id,
command_type="reboot",
command_status="dry_run_planned",
dry_run=True,
target_ip=target.get("target_ip"),
target_mac=target.get("target_mac"),
requested_by_user_id=user_id,
requested_by_username=username,
request_payload=request_payload,
response_payload={
"message": "Dry run only. No reboot command was sent.",
"target": target,
},
)
return {
"ok": True,
"event_id": event_id,
"miner_id": miner_id,
"command_type": "reboot",
"command_status": "dry_run_planned",
"dry_run": True,
"target_ip": target.get("target_ip"),
"target_mac": target.get("target_mac"),
"model": target.get("model"),
"message": "Dry run complete. No reboot command was sent.",
}
if not minerops_env_enabled_default_true("MINEROPS_ENABLE_REAL_REBOOT"):
event_id = await insert_miner_command_event(
miner_id=miner_id,
command_type="reboot",
command_status="blocked_real_reboot_disabled",
dry_run=False,
target_ip=target.get("target_ip"),
target_mac=target.get("target_mac"),
requested_by_user_id=user_id,
requested_by_username=username,
request_payload=request_payload,
response_payload={
"message": "Real reboot is disabled. Set MINEROPS_ENABLE_REAL_REBOOT=true to enable.",
},
error_message="Real reboot disabled by environment setting.",
)
raise HTTPException(
status_code=403,
detail={
"message": "Real reboot is disabled. Set MINEROPS_ENABLE_REAL_REBOOT=true to enable.",
"event_id": event_id,
},
)
if confirm_text != "REBOOT":
event_id = await insert_miner_command_event(
miner_id=miner_id,
command_type="reboot",
command_status="blocked_confirmation_missing",
dry_run=False,
target_ip=target.get("target_ip"),
target_mac=target.get("target_mac"),
requested_by_user_id=user_id,
requested_by_username=username,
request_payload=request_payload,
response_payload={
"message": "Confirmation text did not match REBOOT.",
},
error_message="Confirmation text did not match REBOOT.",
)
raise HTTPException(
status_code=400,
detail={
"message": "Type REBOOT to confirm.",
"event_id": event_id,
},
)
target_ip = minerops_normalize_target_ip(target.get("target_ip"))
if not target_ip:
event_id = await insert_miner_command_event(
miner_id=miner_id,
command_type="reboot",
command_status="failed_no_target_ip",
dry_run=False,
target_ip=None,
target_mac=target.get("target_mac"),
requested_by_user_id=user_id,
requested_by_username=username,
request_payload=request_payload,
response_payload={
"message": "Miner has no current IP address.",
},
error_message="Miner has no current IP address.",
)
raise HTTPException(
status_code=400,
detail={
"message": "Miner has no current IP address.",
"event_id": event_id,
},
)
request_event_id = await insert_miner_command_event(
miner_id=miner_id,
command_type="reboot",
command_status="real_reboot_requested",
dry_run=False,
target_ip=target_ip,
target_mac=target.get("target_mac"),
requested_by_user_id=user_id,
requested_by_username=username,
request_payload=request_payload,
response_payload={
"message": "Real reboot requested. Command send starting.",
},
)
try:
reboot_result = await minerops_send_whatsminer_reboot(target_ip)
result_event_id = await insert_miner_command_event(
miner_id=miner_id,
command_type="reboot",
command_status="real_reboot_sent",
dry_run=False,
target_ip=target_ip,
target_mac=target.get("target_mac"),
requested_by_user_id=user_id,
requested_by_username=username,
request_payload=request_payload,
response_payload=reboot_result,
)
return {
"ok": True,
"request_event_id": request_event_id,
"event_id": result_event_id,
"miner_id": miner_id,
"command_type": "reboot",
"command_status": "real_reboot_sent",
"dry_run": False,
"target_ip": target_ip,
"target_mac": target.get("target_mac"),
"model": target.get("model"),
"message": "Real reboot command was sent.",
"response": reboot_result,
}
except Exception as exc:
result_event_id = await insert_miner_command_event(
miner_id=miner_id,
command_type="reboot",
command_status="real_reboot_send_failed",
dry_run=False,
target_ip=target_ip,
target_mac=target.get("target_mac"),
requested_by_user_id=user_id,
requested_by_username=username,
request_payload=request_payload,
response_payload={
"message": "Real reboot command failed.",
},
error_message=str(exc),
)
raise HTTPException(
status_code=502,
detail={
"message": "Real reboot command failed.",
"event_id": result_event_id,
"error": str(exc),
},
)
@app.get("/api/miners/{miner_id}/commands")
async def list_miner_command_events(miner_id: str, request: Request):
minerops_command_require_bearer(request)
await ensure_miner_command_tables()
conn = await minerops_command_db_connect()
try:
rows = await conn.fetch(
"""
SELECT
id,
miner_id,
command_type,
command_status,
dry_run,
requested_by_user_id,
requested_by_username,
target_ip,
target_mac,
request_payload,
response_payload,
error_message,
created_at,
completed_at
FROM miner_command_events
WHERE miner_id = $1
ORDER BY created_at DESC
LIMIT 25
""",
miner_id,
)
return {
"miner_id": miner_id,
"commands": [dict(row) for row in rows],
}
finally:
await conn.close()
# -----------------------------
# Step 90: Power control commands
# -----------------------------
def minerops_whatsminer_power_command_name(action: str) -> str:
import os
if action == "power_off":
return str(os.environ.get("MINEROPS_WHATSMINER_POWER_OFF_CMD", "power_off") or "power_off").strip()
if action == "power_on":
return str(os.environ.get("MINEROPS_WHATSMINER_POWER_ON_CMD", "power_on") or "power_on").strip()
if action == "suspend":
return str(os.environ.get("MINEROPS_WHATSMINER_SUSPEND_CMD", "api3_service:stop") or "api3_service:stop").strip()
if action == "resume":
return str(os.environ.get("MINEROPS_WHATSMINER_RESUME_CMD", "api3_service:start") or "api3_service:start").strip()
raise RuntimeError(f"Unknown power action: {action}")
def minerops_send_whatsminer_command_sync(
target_ip: str,
command_name: str,
port: int = 4028,
timeout_seconds: float = 10.0,
) -> dict:
import json
import socket
import time
target_ip = minerops_normalize_target_ip(target_ip)
if not target_ip:
raise RuntimeError("No target IP available for command.")
command = {"cmd": command_name}
payload = json.dumps(command).encode("utf-8") + b"\n"
started = time.time()
chunks = []
with socket.create_connection((target_ip, port), timeout=timeout_seconds) as sock:
sock.settimeout(timeout_seconds)
sock.sendall(payload)
try:
sock.shutdown(socket.SHUT_WR)
except Exception:
pass
while True:
try:
chunk = sock.recv(65536)
if not chunk:
break
chunks.append(chunk)
if sum(len(item) for item in chunks) > 1048576:
break
except socket.timeout:
break
raw_response = b"".join(chunks).decode("utf-8", errors="replace").strip()
parsed_response = None
if raw_response:
try:
parsed_response = json.loads(raw_response)
except Exception:
parsed_response = None
return {
"sent": True,
"target_ip": target_ip,
"target_port": port,
"command": command,
"duration_seconds": round(time.time() - started, 3),
"raw_response": raw_response,
"json_response": parsed_response,
}
# -----------------------------
# Step 95: WhatsMiner API3 authenticated service control
# -----------------------------
def minerops_api3_account() -> str:
import os
return str(os.environ.get("MINEROPS_WHATSMINER_API3_ACCOUNT", "super") or "super").strip()
def minerops_api3_password() -> str:
import os
return str(os.environ.get("MINEROPS_WHATSMINER_API3_PASSWORD", "super") or "super").strip()
def minerops_api3_port() -> int:
import os
raw = str(os.environ.get("MINEROPS_WHATSMINER_API3_PORT", "4433") or "4433").strip()
try:
return int(raw)
except Exception:
return 4433
def minerops_api3_token(command: str, password: str, salt: str, ts: int) -> str:
import base64
import hashlib
source = f"{command}{password}{salt}{ts}"
digest = hashlib.sha256(source.encode("utf-8")).digest()
return base64.b64encode(digest).decode("utf-8")[:8]
def minerops_api3_recv_all(sock, length: int) -> bytes:
data = b""
while len(data) < length:
chunk = sock.recv(length - len(data))
if not chunk:
break
data += chunk
return data
def minerops_api3_send_payload_sync(target_ip: str, payload: dict, timeout_seconds: float = 10.0) -> dict:
import json
import socket
import struct
import time
target_ip = minerops_normalize_target_ip(target_ip)
if not target_ip:
raise RuntimeError("No target IP available for API3 command.")
port = minerops_api3_port()
started = time.time()
request_json = json.dumps(payload, separators=(",", ":"))
request_bytes = request_json.encode("utf-8")
with socket.create_connection((target_ip, port), timeout=timeout_seconds) as sock:
sock.settimeout(timeout_seconds)
# WhatsMiner API3 uses a 4-byte little-endian JSON length prefix.
sock.sendall(struct.pack(" 1048576:
raise RuntimeError(f"API3 response length looked invalid: {response_length}")
response_bytes = minerops_api3_recv_all(sock, response_length)
if len(response_bytes) != response_length:
raise RuntimeError("API3 response ended early.")
response_text = response_bytes.decode("utf-8", errors="replace")
response_json = json.loads(response_text)
return {
"sent": True,
"protocol": "whatsminer_api3",
"target_ip": target_ip,
"target_port": port,
"duration_seconds": round(time.time() - started, 3),
"request": {
"cmd": payload.get("cmd"),
"param": payload.get("param"),
"account": payload.get("account"),
"has_token": bool(payload.get("token")),
},
"response": response_json,
}
def minerops_api3_get_salt_sync(target_ip: str) -> tuple[str, dict]:
response = minerops_api3_send_payload_sync(
target_ip,
{
"cmd": "get.device.info",
"param": None,
},
)
response_json = response.get("response") or {}
if response_json.get("code") != 0:
raise RuntimeError(f"API3 get.device.info failed: {response_json}")
msg = response_json.get("msg") or {}
salt = msg.get("salt")
if not salt:
raise RuntimeError(f"API3 get.device.info did not return salt: {response_json}")
return str(salt), response
def minerops_api3_authenticated_payload(command: str, param):
import time
account = minerops_api3_account()
password = minerops_api3_password()
ts = int(time.time())
return account, password, ts
def minerops_api3_service_sync(target_ip: str, service_action: str) -> dict:
import time
if service_action not in {"start", "stop", "restart"}:
raise RuntimeError(f"Invalid API3 service action: {service_action}")
command = "set.miner.service"
account = minerops_api3_account()
password = minerops_api3_password()
salt, salt_response = minerops_api3_get_salt_sync(target_ip)
ts = int(time.time())
token = minerops_api3_token(command, password, salt, ts)
payload = {
"cmd": command,
"param": service_action,
"ts": ts,
"token": token,
"account": account,
}
result = minerops_api3_send_payload_sync(target_ip, payload)
response_json = result.get("response") or {}
result["salt_response"] = salt_response
result["api3_command"] = command
result["api3_param"] = service_action
if response_json.get("code") != 0:
raise RuntimeError(f"API3 {command} {service_action} failed: {response_json}")
return result
async def minerops_api3_service(target_ip: str, service_action: str) -> dict:
import asyncio
return await asyncio.to_thread(
minerops_api3_service_sync,
target_ip,
service_action,
)
async def minerops_send_control_command_item(target_ip: str, command_name: str) -> dict:
item = str(command_name or "").strip()
if item.startswith("api3_service:"):
service_action = item.split(":", 1)[1].strip()
return await minerops_api3_service(target_ip, service_action)
return await minerops_send_whatsminer_command(target_ip, item)
def minerops_split_command_sequence(command_name: str) -> list[str]:
parts = []
for item in str(command_name or "").split("+"):
cleaned = item.strip()
if cleaned:
parts.append(cleaned)
return parts
async def minerops_send_whatsminer_command_sequence(target_ip: str, command_name: str) -> dict:
import asyncio
commands = minerops_split_command_sequence(command_name)
if not commands:
raise RuntimeError("No command supplied.")
results = []
for item in commands:
result = await minerops_send_control_command_item(target_ip, item)
results.append(result)
# Small pause gives the miner time to apply the first command before the next one.
if len(commands) > 1:
await asyncio.sleep(1.0)
return {
"sent": True,
"target_ip": minerops_normalize_target_ip(target_ip),
"command_sequence": commands,
"results": results,
}
async def minerops_send_whatsminer_command(target_ip: str, command_name: str) -> dict:
import asyncio
port = minerops_get_whatsminer_api_port()
return await asyncio.to_thread(
minerops_send_whatsminer_command_sync,
target_ip,
command_name,
port,
10.0,
)
async def minerops_power_command(miner_id: str, request: Request, action: str):
minerops_command_require_bearer(request)
try:
body = await request.json()
except Exception:
body = {}
dry_run = bool(body.get("dry_run", False))
if action not in {"power_off", "power_on", "suspend", "resume"}:
raise HTTPException(status_code=400, detail="Invalid power action.")
if not minerops_env_enabled_default_true("MINEROPS_ENABLE_REAL_POWER_CONTROL"):
raise HTTPException(
status_code=403,
detail="Real power control is disabled. Set MINEROPS_ENABLE_REAL_POWER_CONTROL=true or leave it unset to enable.",
)
target = await get_miner_command_target(miner_id)
if not target:
raise HTTPException(status_code=404, detail="Miner not found.")
target_ip = minerops_normalize_target_ip(target.get("target_ip"))
if not target_ip:
event_id = await insert_miner_command_event(
miner_id=miner_id,
command_type=action,
command_status="failed_no_target_ip",
dry_run=dry_run,
target_ip=None,
target_mac=target.get("target_mac"),
request_payload={
"action": action,
"dry_run": dry_run,
},
response_payload={
"message": "Miner has no current IP address.",
},
error_message="Miner has no current IP address.",
)
raise HTTPException(
status_code=400,
detail={
"message": "Miner has no current IP address.",
"event_id": event_id,
},
)
user_id, username = await minerops_command_user_from_request(request)
command_name = minerops_whatsminer_power_command_name(action)
request_payload = {
"action": action,
"dry_run": dry_run,
"whatsminer_command": command_name,
"source": "miner_detail_control_card",
"real_power_control_enabled": minerops_env_enabled_default_true("MINEROPS_ENABLE_REAL_POWER_CONTROL"),
}
if dry_run:
event_id = await insert_miner_command_event(
miner_id=miner_id,
command_type=action,
command_status="dry_run_planned",
dry_run=True,
target_ip=target_ip,
target_mac=target.get("target_mac"),
requested_by_user_id=user_id,
requested_by_username=username,
request_payload=request_payload,
response_payload={
"message": "Dry run only. No power command was sent.",
"target": target,
"whatsminer_command": command_name,
},
)
return {
"ok": True,
"event_id": event_id,
"miner_id": miner_id,
"command_type": action,
"command_status": "dry_run_planned",
"dry_run": True,
"target_ip": target_ip,
"target_mac": target.get("target_mac"),
"model": target.get("model"),
"whatsminer_command": command_name,
"message": "Dry run complete. No power command was sent.",
}
request_event_id = await insert_miner_command_event(
miner_id=miner_id,
command_type=action,
command_status=f"{action}_requested",
dry_run=False,
target_ip=target_ip,
target_mac=target.get("target_mac"),
requested_by_user_id=user_id,
requested_by_username=username,
request_payload=request_payload,
response_payload={
"message": f"{action} requested. Command send starting.",
"whatsminer_command": command_name,
},
)
try:
api3_creds = await minerops_get_api3_credentials_for_miner(miner_id)
command_result = await minerops_send_whatsminer_command_sequence(
target_ip,
command_name,
api3_creds.get("account"),
api3_creds.get("password"),
)
result_event_id = await insert_miner_command_event(
miner_id=miner_id,
command_type=action,
command_status=f"{action}_sent",
dry_run=False,
target_ip=target_ip,
target_mac=target.get("target_mac"),
requested_by_user_id=user_id,
requested_by_username=username,
request_payload=request_payload,
response_payload=command_result,
)
return {
"ok": True,
"request_event_id": request_event_id,
"event_id": result_event_id,
"miner_id": miner_id,
"command_type": action,
"command_status": f"{action}_sent",
"dry_run": False,
"target_ip": target_ip,
"target_mac": target.get("target_mac"),
"model": target.get("model"),
"whatsminer_command": command_name,
"message": f"{action} command was sent.",
"response": command_result,
}
except Exception as exc:
result_event_id = await insert_miner_command_event(
miner_id=miner_id,
command_type=action,
command_status=f"{action}_send_failed",
dry_run=False,
target_ip=target_ip,
target_mac=target.get("target_mac"),
requested_by_user_id=user_id,
requested_by_username=username,
request_payload=request_payload,
response_payload={
"message": f"{action} command failed.",
"whatsminer_command": command_name,
},
error_message=str(exc),
)
raise HTTPException(
status_code=502,
detail={
"message": f"{action} command failed.",
"event_id": result_event_id,
"error": str(exc),
},
)
@app.post("/api/miners/{miner_id}/commands/power-off")
async def real_power_off_miner_command(miner_id: str, request: Request):
return await minerops_power_command(miner_id, request, "power_off")
@app.post("/api/miners/{miner_id}/commands/power-on")
async def real_power_on_miner_command(miner_id: str, request: Request):
return await minerops_power_command(miner_id, request, "power_on")
@app.post("/api/miners/{miner_id}/commands/suspend")
async def real_suspend_miner_command(miner_id: str, request: Request):
return await minerops_power_command(miner_id, request, "suspend")
@app.post("/api/miners/{miner_id}/commands/resume")
async def real_resume_miner_command(miner_id: str, request: Request):
return await minerops_power_command(miner_id, request, "resume")
# -----------------------------
# Step 96: Miner API3 credentials and password change
# -----------------------------
async def ensure_miner_api_credentials_table():
conn = await minerops_command_db_connect()
try:
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS miner_api_credentials (
miner_id TEXT PRIMARY KEY,
api_account TEXT NOT NULL DEFAULT 'super',
api_password TEXT NOT NULL,
password_source TEXT NOT NULL DEFAULT 'minerops',
updated_by_username TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
finally:
await conn.close()
@app.on_event("startup")
async def startup_ensure_miner_api_credentials_table():
await ensure_miner_api_credentials_table()
async def minerops_get_api3_credentials_for_miner(miner_id: str) -> dict:
await ensure_miner_api_credentials_table()
conn = await minerops_command_db_connect()
try:
row = await conn.fetchrow(
"""
SELECT api_account, api_password
FROM miner_api_credentials
WHERE miner_id = $1
""",
miner_id,
)
finally:
await conn.close()
if row:
return {
"account": row["api_account"],
"password": row["api_password"],
"source": "miner_override",
}
return {
"account": minerops_api3_account(),
"password": minerops_api3_password(),
"source": "environment_default",
}
async def minerops_save_api3_credentials_for_miner(
miner_id: str,
account: str,
password: str,
username: str | None = None,
):
await ensure_miner_api_credentials_table()
conn = await minerops_command_db_connect()
try:
await conn.execute(
"""
INSERT INTO miner_api_credentials (
miner_id,
api_account,
api_password,
password_source,
updated_by_username,
updated_at
)
VALUES ($1, $2, $3, 'password_change', $4, now())
ON CONFLICT (miner_id)
DO UPDATE SET
api_account = EXCLUDED.api_account,
api_password = EXCLUDED.api_password,
password_source = EXCLUDED.password_source,
updated_by_username = EXCLUDED.updated_by_username,
updated_at = now()
""",
miner_id,
account,
password,
username,
)
finally:
await conn.close()
def minerops_api3_encrypt_param(param: str, command: str, password: str, salt: str, ts: int) -> str:
import base64
import hashlib
from Crypto.Cipher import AES
source = f"{command}{password}{salt}{ts}"
aes_key = hashlib.sha256(source.encode("utf-8")).digest()
pad_len = 16 - (len(param) % 16)
padded_param = param + (chr(pad_len) * pad_len)
cipher = AES.new(aes_key, AES.MODE_ECB)
encrypted_bytes = cipher.encrypt(padded_param.encode("utf-8"))
return base64.b64encode(encrypted_bytes).decode("utf-8")
def minerops_api3_service_sync_with_credentials(
target_ip: str,
service_action: str,
account: str | None = None,
password: str | None = None,
) -> dict:
import time
if service_action not in {"start", "stop", "restart"}:
raise RuntimeError(f"Invalid API3 service action: {service_action}")
command = "set.miner.service"
account = str(account or minerops_api3_account()).strip()
password = str(password or minerops_api3_password()).strip()
salt, salt_response = minerops_api3_get_salt_sync(target_ip)
ts = int(time.time())
token = minerops_api3_token(command, password, salt, ts)
payload = {
"cmd": command,
"param": service_action,
"ts": ts,
"token": token,
"account": account,
}
result = minerops_api3_send_payload_sync(target_ip, payload)
response_json = result.get("response") or {}
result["salt_response"] = salt_response
result["api3_command"] = command
result["api3_param"] = service_action
result["api3_account"] = account
if response_json.get("code") != 0:
raise RuntimeError(f"API3 {command} {service_action} failed: {response_json}")
return result
async def minerops_api3_service_with_credentials(
target_ip: str,
service_action: str,
account: str | None = None,
password: str | None = None,
) -> dict:
import asyncio
return await asyncio.to_thread(
minerops_api3_service_sync_with_credentials,
target_ip,
service_action,
account,
password,
)
async def minerops_send_control_command_item(
target_ip: str,
command_name: str,
account_override: str | None = None,
password_override: str | None = None,
) -> dict:
item = str(command_name or "").strip()
if item.startswith("api3_service:"):
service_action = item.split(":", 1)[1].strip()
return await minerops_api3_service_with_credentials(
target_ip,
service_action,
account_override,
password_override,
)
return await minerops_send_whatsminer_command(target_ip, item)
async def minerops_send_whatsminer_command_sequence(
target_ip: str,
command_name: str,
account_override: str | None = None,
password_override: str | None = None,
) -> dict:
import asyncio
commands = minerops_split_command_sequence(command_name)
if not commands:
raise RuntimeError("No command supplied.")
results = []
for item in commands:
result = await minerops_send_control_command_item(
target_ip,
item,
account_override,
password_override,
)
results.append(result)
if len(commands) > 1:
await asyncio.sleep(1.0)
return {
"sent": True,
"target_ip": minerops_normalize_target_ip(target_ip),
"command_sequence": commands,
"results": results,
"credential_source": "miner_override" if password_override else "environment_default",
}
def minerops_api3_change_password_sync(
target_ip: str,
account_to_change: str,
old_password: str,
new_password: str,
) -> dict:
import json
import time
account_to_change = str(account_to_change or "super").strip()
old_password = str(old_password or "")
new_password = str(new_password or "")
if not account_to_change:
raise RuntimeError("Account is required.")
if not old_password:
raise RuntimeError("Old password is required.")
if not new_password:
raise RuntimeError("New password is required.")
command = "set.user.change_passwd"
salt, salt_response = minerops_api3_get_salt_sync(target_ip)
ts = int(time.time())
token = minerops_api3_token(command, old_password, salt, ts)
param_data = {
"account": account_to_change,
"new": new_password,
"old": old_password,
}
encrypted_param = minerops_api3_encrypt_param(
json.dumps(param_data, separators=(",", ":")),
command,
old_password,
salt,
ts,
)
payload = {
"cmd": command,
"ts": ts,
"token": token,
"account": account_to_change,
"param": encrypted_param,
}
result = minerops_api3_send_payload_sync(target_ip, payload)
response_json = result.get("response") or {}
result["salt_response"] = salt_response
result["api3_command"] = command
result["api3_account"] = account_to_change
result["passwords_logged"] = False
if response_json.get("code") != 0:
raise RuntimeError(f"API3 password change failed: {response_json}")
return result
async def minerops_api3_change_password(
target_ip: str,
account_to_change: str,
old_password: str,
new_password: str,
) -> dict:
import asyncio
return await asyncio.to_thread(
minerops_api3_change_password_sync,
target_ip,
account_to_change,
old_password,
new_password,
)
@app.post("/api/miners/{miner_id}/api3/change-password")
async def change_miner_api3_password(miner_id: str, request: Request):
minerops_command_require_bearer(request)
try:
body = await request.json()
except Exception:
body = {}
account = str(body.get("account") or "super").strip()
old_password = str(body.get("old_password") or "")
new_password = str(body.get("new_password") or "")
update_stored = bool(body.get("update_stored", True))
if not old_password or not new_password:
raise HTTPException(
status_code=400,
detail="Old password and new password are required.",
)
target = await get_miner_command_target(miner_id)
if not target:
raise HTTPException(status_code=404, detail="Miner not found.")
target_ip = minerops_normalize_target_ip(target.get("target_ip"))
if not target_ip:
raise HTTPException(status_code=400, detail="Miner has no current IP address.")
user_id, username = await minerops_command_user_from_request(request)
request_event_id = await insert_miner_command_event(
miner_id=miner_id,
command_type="change_password",
command_status="password_change_requested",
dry_run=False,
target_ip=target_ip,
target_mac=target.get("target_mac"),
requested_by_user_id=user_id,
requested_by_username=username,
request_payload={
"account": account,
"update_stored": update_stored,
"passwords_logged": False,
},
response_payload={
"message": "Password change requested. Password values are not logged.",
},
)
try:
result = await minerops_api3_change_password(
target_ip,
account,
old_password,
new_password,
)
if update_stored:
await minerops_save_api3_credentials_for_miner(
miner_id,
account,
new_password,
username,
)
result_event_id = await insert_miner_command_event(
miner_id=miner_id,
command_type="change_password",
command_status="password_change_sent",
dry_run=False,
target_ip=target_ip,
target_mac=target.get("target_mac"),
requested_by_user_id=user_id,
requested_by_username=username,
request_payload={
"account": account,
"update_stored": update_stored,
"passwords_logged": False,
},
response_payload={
"message": "Password change command sent successfully.",
"api3_command": "set.user.change_passwd",
"account": account,
"stored_in_minerops": update_stored,
"miner_response": result.get("response"),
},
)
return {
"ok": True,
"event_id": result_event_id,
"request_event_id": request_event_id,
"miner_id": miner_id,
"target_ip": target_ip,
"target_mac": target.get("target_mac"),
"account": account,
"stored_in_minerops": update_stored,
"message": "Password changed. Password values were not logged.",
"response": result.get("response"),
}
except Exception as exc:
result_event_id = await insert_miner_command_event(
miner_id=miner_id,
command_type="change_password",
command_status="password_change_failed",
dry_run=False,
target_ip=target_ip,
target_mac=target.get("target_mac"),
requested_by_user_id=user_id,
requested_by_username=username,
request_payload={
"account": account,
"update_stored": update_stored,
"passwords_logged": False,
},
response_payload={
"message": "Password change failed. Password values are not logged.",
},
error_message=str(exc),
)
raise HTTPException(
status_code=502,
detail={
"message": "Password change failed.",
"event_id": result_event_id,
"error": str(exc),
},
)
MINEROPS_API3_CREDENTIALS_JS = r"""
(function () {
function getToken() {
const keys = [
"minerops_token",
"access_token",
"token",
"jwt",
"auth_token"
];
for (const key of keys) {
const localValue = localStorage.getItem(key);
if (localValue) return localValue;
const sessionValue = sessionStorage.getItem(key);
if (sessionValue) return sessionValue;
}
return "";
}
function minerIdFromPage() {
const card = document.getElementById("minerControlCard");
if (card && card.dataset.minerId) {
return card.dataset.minerId;
}
const match = window.location.pathname.match(/\/miners\/([^\/]+)\/detail/);
if (match) {
return decodeURIComponent(match[1]);
}
return "";
}
function setStatus(message, kind) {
const box = document.getElementById("minerControlStatus");
if (!box) {
alert(message);
return;
}
box.className = "status-box " + (kind || "");
box.textContent = message;
}
async function fetchJson(url, options) {
const response = await fetch(url, options || {});
let data = null;
try {
data = await response.json();
} catch (error) {
data = null;
}
if (!response.ok) {
let message = response.status + " " + response.statusText;
if (data && data.detail) {
if (typeof data.detail === "string") {
message = data.detail;
} else if (data.detail.message) {
message = data.detail.message + (data.detail.error ? ": " + data.detail.error : "");
} else {
message = JSON.stringify(data.detail);
}
}
throw new Error(message);
}
return data;
}
function ensureApi3Ui() {
const card = document.getElementById("minerControlCard");
if (!card || document.getElementById("api3CredentialBox")) {
return;
}
const reminder = document.createElement("div");
reminder.id = "webApiReminder";
reminder.className = "status-box warn";
reminder.style.marginTop = "12px";
reminder.innerHTML = "New miner reminder: enable WhatsMiner WebAPI/API3 before using Reboot, Suspend, Resume, or password changes. After adding or replacing a miner, enable WebAPI in WhatsMinerTool or the miner controls will not work.";
const box = document.createElement("div");
box.id = "api3CredentialBox";
box.className = "card";
box.style.marginTop = "12px";
box.innerHTML = [
"
Miner API Password
",
"
Change this miner's API password and save it in MinerOps for future Suspend/Resume commands. Password values are not written to command history.
",
"
",
" ",
" ",
" ",
" ",
"
"
].join("");
card.appendChild(reminder);
card.appendChild(box);
const button = document.getElementById("changeMinerApiPasswordButton");
if (button) {
button.onclick = changeMinerApiPassword;
}
}
async function changeMinerApiPassword() {
const minerId = minerIdFromPage();
const token = getToken();
if (!minerId) {
setStatus("Could not detect miner ID.", "bad");
return;
}
if (!token) {
setStatus("Log in first.", "bad");
return;
}
const account = (document.getElementById("api3AccountInput") || {}).value || "super";
const oldPassword = (document.getElementById("api3OldPasswordInput") || {}).value || "";
const newPassword = (document.getElementById("api3NewPasswordInput") || {}).value || "";
if (!oldPassword || !newPassword) {
setStatus("Enter the current password and new password.", "bad");
return;
}
if (!confirm("Change the API password on this miner now?")) {
setStatus("Password change cancelled.", "warn");
return;
}
setStatus("Changing miner API password...", "warn");
try {
const data = await fetchJson("/api/miners/" + encodeURIComponent(minerId) + "/api3/change-password", {
method: "POST",
headers: {
"Authorization": "Bearer " + token,
"Content-Type": "application/json"
},
body: JSON.stringify({
account: account,
old_password: oldPassword,
new_password: newPassword,
update_stored: true
})
});
document.getElementById("api3OldPasswordInput").value = "";
document.getElementById("api3NewPasswordInput").value = "";
setStatus(data.message || "Password changed and saved for this miner.", "ok");
if (typeof window.loadMinerCommandHistory === "function") {
window.loadMinerCommandHistory();
}
} catch (error) {
setStatus("Password change error: " + error.message, "bad");
if (typeof window.loadMinerCommandHistory === "function") {
window.loadMinerCommandHistory();
}
}
}
document.addEventListener("DOMContentLoaded", function () {
ensureApi3Ui();
setTimeout(ensureApi3Ui, 500);
setTimeout(ensureApi3Ui, 1500);
});
})();
"""
@app.get("/assets/minerops-api3-credentials.js")
async def minerops_api3_credentials_js():
return Response(
content=MINEROPS_API3_CREDENTIALS_JS,
media_type="application/javascript",
)
# -----------------------------
# Phase 5F: Hub mode status and UI helper
# -----------------------------
@app.get("/api/hub/mode")
async def minerops_hub_mode_status():
import os
def env_true(name: str) -> bool:
value = str(os.environ.get(name, "") or "").strip().lower()
return value in {"1", "true", "yes", "on", "enabled"}
hub_mode = env_true("MINEROPS_HUB_MODE") or env_true("MINEROPS_DISABLE_LOCAL_SCANNER")
scanner_enabled = str(os.environ.get("SCHEDULED_SCAN_ENABLED", "true") or "true").strip().lower() not in {"0", "false", "no", "off", "disabled"}
return {
"hub_mode": hub_mode,
"local_scanner_enabled": scanner_enabled,
"local_scanner_disabled": hub_mode or not scanner_enabled,
"message": "Hub Mode: Windows site agent should poll miners and push updates to this hub." if hub_mode else "Local scanner mode."
}
MINEROPS_HUB_MODE_JS = r"""
(function () {
async function fetchHubMode() {
try {
const response = await fetch("/api/hub/mode", { cache: "no-store" });
if (!response.ok) return null;
return await response.json();
} catch (error) {
return null;
}
}
function ensureBanner(data) {
if (!data || !data.hub_mode) return;
let banner = document.getElementById("mineropsHubModeBanner");
if (!banner) {
banner = document.createElement("div");
banner.id = "mineropsHubModeBanner";
banner.style.cssText = [
"position:sticky",
"top:0",
"z-index:9998",
"margin:0 0 12px 0",
"padding:10px 14px",
"border-radius:10px",
"border:1px solid #facc15",
"background:#fef9c3",
"color:#713f12",
"font-weight:700",
"box-shadow:0 2px 8px rgba(15,23,42,.12)"
].join(";");
const target = document.querySelector("main") || document.body;
target.insertBefore(banner, target.firstChild);
}
banner.textContent = "Hub Mode: local miner scanning is disabled on this server. The Windows site agent will poll miners and push updates here.";
}
function disableScanButtons(data) {
if (!data || !data.hub_mode) return;
const scanWords = [
"run scan",
"scan now",
"known-miner scan",
"run known"
];
document.querySelectorAll("button, a").forEach(function (el) {
const text = (el.textContent || "").trim().toLowerCase();
if (!text) return;
const isScanControl = scanWords.some(function (word) {
return text.includes(word);
});
if (!isScanControl) return;
el.dataset.originalText = el.dataset.originalText || el.textContent;
el.textContent = "Hub Mode: use Windows Agent";
el.title = "Local scanner is disabled on the hub. The Windows site agent will poll miners.";
el.disabled = true;
el.style.opacity = "0.65";
el.style.cursor = "not-allowed";
el.onclick = function (event) {
event.preventDefault();
alert("Hub Mode is enabled. The Windows site agent will poll miners and push updates to this hub.");
return false;
};
});
}
function updateScannerPanel(data) {
if (!data || !data.hub_mode) return;
const state = document.getElementById("scannerState");
if (state) state.textContent = "Hub Mode";
const next = document.getElementById("scannerNextRun");
if (next) next.textContent = "Windows Agent";
const started = document.getElementById("scannerLastStarted");
if (started) started.textContent = "Local scanner disabled. Waiting for Windows site agent updates.";
const raw = document.getElementById("rawOutput");
if (raw && raw.textContent && raw.textContent.includes("Scanner is idle")) {
raw.textContent = "Hub Mode: local scanner disabled. Waiting for Windows site agent updates.";
}
}
async function applyHubModeUi() {
const data = await fetchHubMode();
if (!data || !data.hub_mode) return;
ensureBanner(data);
disableScanButtons(data);
updateScannerPanel(data);
}
document.addEventListener("DOMContentLoaded", function () {
applyHubModeUi();
setTimeout(applyHubModeUi, 500);
setTimeout(applyHubModeUi, 1500);
setInterval(applyHubModeUi, 5000);
});
})();
"""
@app.get("/assets/minerops-hub-mode.js")
async def minerops_hub_mode_js():
return Response(
content=MINEROPS_HUB_MODE_JS,
media_type="application/javascript",
)
# -----------------------------
# Phase 5G: Windows site agent hub endpoints
# -----------------------------
def minerops_agent_secret_expected() -> str:
import os
return str(os.environ.get("MINEROPS_AGENT_SHARED_SECRET", "") or "").strip()
def minerops_require_agent_secret(request: Request):
import hmac
expected = minerops_agent_secret_expected()
provided = str(request.headers.get("X-MinerOps-Agent-Secret", "") or "").strip()
if not expected:
raise HTTPException(
status_code=500,
detail="MINEROPS_AGENT_SHARED_SECRET is not configured on the hub.",
)
if not provided or not hmac.compare_digest(provided, expected):
raise HTTPException(
status_code=401,
detail="Invalid or missing agent secret.",
)
async def ensure_minerops_agent_tables():
conn = await minerops_command_db_connect()
try:
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS minerops_site_agents (
agent_id TEXT PRIMARY KEY,
site_name TEXT NOT NULL,
hostname TEXT,
version TEXT,
ip_address TEXT,
last_checkin_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_payload JSONB NOT NULL DEFAULT '{}'::jsonb
)
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS minerops_agent_snapshots (
id BIGSERIAL PRIMARY KEY,
agent_id TEXT NOT NULL,
site_name TEXT NOT NULL,
snapshot_kind TEXT NOT NULL DEFAULT 'miner_poll',
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
miner_count INTEGER NOT NULL DEFAULT 0,
payload JSONB NOT NULL
)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_minerops_agent_snapshots_received
ON minerops_agent_snapshots (received_at DESC)
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS minerops_agent_command_results (
id BIGSERIAL PRIMARY KEY,
agent_id TEXT NOT NULL,
site_name TEXT NOT NULL,
command_id TEXT,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
status TEXT NOT NULL,
payload JSONB NOT NULL
)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_minerops_agent_command_results_received
ON minerops_agent_command_results (received_at DESC)
"""
)
finally:
await conn.close()
@app.on_event("startup")
async def startup_ensure_minerops_agent_tables():
await ensure_minerops_agent_tables()
@app.post("/api/agent/checkin")
async def minerops_agent_checkin(request: Request):
import json
minerops_require_agent_secret(request)
await ensure_minerops_agent_tables()
try:
body = await request.json()
except Exception:
body = {}
agent_id = str(body.get("agent_id") or "plexus-windows-agent").strip()
site_name = str(body.get("site_name") or "Plexus").strip()
hostname = str(body.get("hostname") or "").strip()
version = str(body.get("version") or "").strip()
ip_address = str(body.get("ip_address") or "").strip()
conn = await minerops_command_db_connect()
try:
await conn.execute(
"""
INSERT INTO minerops_site_agents (
agent_id,
site_name,
hostname,
version,
ip_address,
last_checkin_at,
last_payload
)
VALUES ($1, $2, $3, $4, $5, now(), $6::jsonb)
ON CONFLICT (agent_id)
DO UPDATE SET
site_name = EXCLUDED.site_name,
hostname = EXCLUDED.hostname,
version = EXCLUDED.version,
ip_address = EXCLUDED.ip_address,
last_checkin_at = now(),
last_payload = EXCLUDED.last_payload
""",
agent_id,
site_name,
hostname,
version,
ip_address,
json.dumps(body),
)
finally:
await conn.close()
return {
"ok": True,
"hub_mode": True,
"agent_id": agent_id,
"site_name": site_name,
"message": "Agent check-in received.",
}
@app.get("/api/agent/config")
async def minerops_agent_config(request: Request):
minerops_require_agent_secret(request)
return {
"ok": True,
"site_name": "Plexus",
"poll_interval_seconds": 120,
"hub_url": "https://plexus.lussier.cc",
"ip_ranges": [
{
"name": "Unit 4",
"cidr": "10.30.0.0/22",
"enabled": True,
"api_port": 4028,
"api3_port": 4433,
},
{
"name": "Unit 6",
"cidr": "10.80.0.0/22",
"enabled": True,
"api_port": 4028,
"api3_port": 4433,
},
{
"name": "Unit 12",
"cidr": "10.90.0.0/22",
"enabled": False,
"api_port": 4028,
"api3_port": 4433,
},
],
"message": "Agent config from hub.",
}
@app.post("/api/agent/miner-snapshots")
async def minerops_agent_miner_snapshots(request: Request):
import json
minerops_require_agent_secret(request)
await ensure_minerops_agent_tables()
try:
body = await request.json()
except Exception:
body = {}
agent_id = str(body.get("agent_id") or "plexus-windows-agent").strip()
site_name = str(body.get("site_name") or "Plexus").strip()
miners = body.get("miners")
if not isinstance(miners, list):
miners = []
conn = await minerops_command_db_connect()
try:
row = await conn.fetchrow(
"""
INSERT INTO minerops_agent_snapshots (
agent_id,
site_name,
snapshot_kind,
miner_count,
payload
)
VALUES ($1, $2, 'miner_poll', $3, $4::jsonb)
RETURNING id, received_at
""",
agent_id,
site_name,
len(miners),
json.dumps(body),
)
finally:
await conn.close()
apply_result = await minerops_apply_agent_snapshot_to_live_tables(body)
return {
"ok": True,
"snapshot_id": row["id"] if row else None,
"received_at": row["received_at"].isoformat() if row and row["received_at"] else None,
"miner_count": len(miners),
"apply_result": apply_result,
"message": "Miner snapshot received, staged, and applied to live tables.",
}
@app.get("/api/agent/commands/pending-disabled-old")
async def minerops_agent_commands_pending(request: Request):
minerops_require_agent_secret(request)
# Command queue will be wired in the next step.
return {
"ok": True,
"commands": [],
"message": "No pending commands.",
}
@app.post("/api/agent/commands/result-disabled-old")
async def minerops_agent_commands_result(request: Request):
import json
minerops_require_agent_secret(request)
await ensure_minerops_agent_tables()
try:
body = await request.json()
except Exception:
body = {}
agent_id = str(body.get("agent_id") or "plexus-windows-agent").strip()
site_name = str(body.get("site_name") or "Plexus").strip()
command_id = str(body.get("command_id") or "").strip()
status = str(body.get("status") or "unknown").strip()
conn = await minerops_command_db_connect()
try:
row = await conn.fetchrow(
"""
INSERT INTO minerops_agent_command_results (
agent_id,
site_name,
command_id,
status,
payload
)
VALUES ($1, $2, $3, $4, $5::jsonb)
RETURNING id, received_at
""",
agent_id,
site_name,
command_id,
status,
json.dumps(body),
)
finally:
await conn.close()
return {
"ok": True,
"result_id": row["id"] if row else None,
"received_at": row["received_at"].isoformat() if row and row["received_at"] else None,
"message": "Command result received.",
}
# -----------------------------
# Phase 5H.1: Agent setup page
# -----------------------------
@app.get("/api/agent/setup-info")
async def minerops_agent_setup_info(current_user: dict = Depends(get_current_user)):
secret = minerops_agent_secret_expected()
return {
"ok": True,
"hub_url": "https://plexus.lussier.cc",
"agent_id": "plexus-windows-agent",
"site_name": "Plexus",
"agent_secret": secret,
"secret_length": len(secret),
"config_path": "C:\\MinerOpsAgent\\agent-config.json",
"message": "Keep this secret private. It allows the Windows Agent to push updates to the hub.",
}
@app.get("/agent/setup")
async def minerops_agent_setup_page():
html = """
MinerOps Agent Setup
MINEROPS CONSOLE
Windows Agent Setup
Plexus Mining Site
Loading...
Agent Connection Info
Keep this secret private. Anyone with the secret can submit agent data to this hub.
Windows Agent Config JSON
Paste this into C:\\MinerOpsAgent\\agent-config.json if you need to recreate the agent config.
WebAPI Reminder
For every new or replaced WhatsMiner, enable WebAPI/API3 before Reboot, Suspend, Resume, password changes, or agent polling will work.
"""
return HTMLResponse(content=html)
# -----------------------------
# Phase 5I.1: Agent known miners endpoint
# -----------------------------
@app.get("/api/agent/known-miners")
async def minerops_agent_known_miners(request: Request):
minerops_require_agent_secret(request)
conn = await minerops_command_db_connect()
try:
rows = await conn.fetch(
"""
SELECT
miners.id::text AS miner_id,
host(miners.current_ip) AS ip_address,
miners.current_mac AS mac_address,
COALESCE(rooms.name, 'Unassigned') AS room_name,
COALESCE(NULLIF(miners.miner_type, ''), NULLIF(miners.model, ''), '') AS model,
COALESCE(miners.status, '') AS status
FROM miners
LEFT JOIN rooms ON rooms.id = miners.room_id
WHERE miners.current_ip IS NOT NULL
ORDER BY rooms.sort_order NULLS LAST, rooms.name NULLS LAST, miners.current_ip
"""
)
finally:
await conn.close()
miners = []
for row in rows:
miners.append(
{
"miner_id": row["miner_id"],
"ip_address": row["ip_address"],
"mac_address": row["mac_address"],
"room_name": row["room_name"],
"model": row["model"],
"status": row["status"],
}
)
return {
"ok": True,
"count": len(miners),
"miners": miners,
"message": "Known miners for site agent polling.",
}
# -----------------------------
# Phase 5J: Apply Windows Agent snapshots to live miner tables
# -----------------------------
def minerops_agent_to_float(value):
if value is None:
return None
try:
text = str(value).strip()
if text == "":
return None
return float(text)
except Exception:
return None
def minerops_agent_to_int(value):
number = minerops_agent_to_float(value)
if number is None:
return None
try:
return int(number)
except Exception:
return None
def minerops_agent_to_bool(value):
if value is None:
return None
if isinstance(value, bool):
return value
text = str(value).strip().lower()
if text in {"true", "1", "yes", "y", "stable"}:
return True
if text in {"false", "0", "no", "n", "unstable"}:
return False
return None
def minerops_agent_parse_summary(miner_payload: dict) -> dict:
import json
responses = miner_payload.get("responses") or {}
summary = responses.get("summary") or {}
raw = summary.get("raw") or ""
parsed = summary.get("json")
if parsed is None and raw:
try:
parsed = json.loads(raw)
except Exception:
parsed = None
if not isinstance(parsed, dict):
parsed = {}
msg = parsed.get("Msg") or parsed.get("msg") or {}
if not isinstance(msg, dict):
msg = {}
return {
"raw": parsed,
"msg": msg,
}
async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> dict:
import json
import uuid
from datetime import datetime, timezone
miners = snapshot_body.get("miners")
if not isinstance(miners, list):
miners = []
now = datetime.now(timezone.utc)
applied = 0
online_count = 0
offline_count = 0
errors = []
conn = await minerops_command_db_connect()
try:
for item in miners:
if not isinstance(item, dict):
continue
miner_id = str(item.get("miner_id") or "").strip()
ip_address = str(item.get("ip_address") or "").strip()
online = bool(item.get("online"))
if not miner_id:
continue
parsed = minerops_agent_parse_summary(item)
raw_summary = parsed["raw"]
msg = parsed["msg"]
status = "online" if online else "offline"
mhs_av = minerops_agent_to_float(msg.get("MHS av"))
mhs_1m = minerops_agent_to_float(msg.get("MHS 1m"))
mhs_15m = minerops_agent_to_float(msg.get("MHS 15m"))
hs_rt = minerops_agent_to_float(msg.get("HS RT"))
power_w = minerops_agent_to_float(msg.get("Power"))
power_rate = minerops_agent_to_float(msg.get("Power Rate"))
env_temp = minerops_agent_to_float(msg.get("Env Temp"))
chip_temp_min = minerops_agent_to_float(msg.get("Chip Temp Min"))
chip_temp_max = minerops_agent_to_float(msg.get("Chip Temp Max"))
chip_temp_avg = minerops_agent_to_float(msg.get("Chip Temp Avg"))
fan_in = minerops_agent_to_int(msg.get("Fan Speed In"))
fan_out = minerops_agent_to_int(msg.get("Fan Speed Out"))
uptime = minerops_agent_to_int(msg.get("Uptime"))
hash_stable = minerops_agent_to_bool(msg.get("Hash Stable"))
pool_rejected = minerops_agent_to_float(msg.get("Pool Rejected%"))
try:
if online:
online_count += 1
await conn.execute(
"""
UPDATE miners
SET
status = $2,
current_ip = COALESCE(NULLIF($3, '')::inet, current_ip),
last_seen_at = $4,
updated_at = now()
WHERE id = $1::uuid
""",
miner_id,
status,
ip_address,
now,
)
await conn.execute(
"""
INSERT INTO miner_status_snapshots (
id,
miner_id,
observed_ip,
observed_at,
mhs_av,
mhs_1m,
mhs_15m,
hs_rt,
power_w,
power_rate_w_per_th,
env_temp_c,
chip_temp_min_c,
chip_temp_max_c,
chip_temp_avg_c,
fan_speed_in_rpm,
fan_speed_out_rpm,
uptime_seconds,
hash_stable,
pool_rejected_percent,
raw_summary_json,
created_at
)
VALUES (
$1::uuid,
$2::uuid,
NULLIF($3, '')::inet,
$4,
$5,
$6,
$7,
$8,
$9,
$10,
$11,
$12,
$13,
$14,
$15,
$16,
$17,
$18,
$19,
$20::jsonb,
now()
)
""",
str(uuid.uuid4()),
miner_id,
ip_address,
now,
mhs_av,
mhs_1m,
mhs_15m,
hs_rt,
power_w,
power_rate,
env_temp,
chip_temp_min,
chip_temp_max,
chip_temp_avg,
fan_in,
fan_out,
uptime,
hash_stable,
pool_rejected,
json.dumps(raw_summary),
)
else:
offline_count += 1
await conn.execute(
"""
UPDATE miners
SET
status = $2,
current_ip = COALESCE(NULLIF($3, '')::inet, current_ip),
updated_at = now()
WHERE id = $1::uuid
""",
miner_id,
status,
ip_address,
)
applied += 1
except Exception as exc:
if len(errors) < 20:
errors.append(
{
"miner_id": miner_id,
"ip_address": ip_address,
"error": str(exc),
}
)
finally:
await conn.close()
return {
"applied": applied,
"online": online_count,
"offline": offline_count,
"errors": errors,
}
# -----------------------------
# Phase 5K: Agent command queue
# -----------------------------
async def minerops_ensure_agent_command_queue_table():
conn = await minerops_command_db_connect()
try:
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS minerops_agent_command_queue (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
command_type TEXT NOT NULL,
command_status TEXT NOT NULL DEFAULT 'pending',
requested_by_user_id TEXT,
requested_by_username TEXT,
request_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
result_payload JSONB,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
claimed_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_minerops_agent_command_queue_agent_status
ON minerops_agent_command_queue(agent_id, command_status, created_at)
"""
)
finally:
await conn.close()
@app.on_event("startup")
async def startup_ensure_minerops_agent_command_queue_table():
await minerops_ensure_agent_command_queue_table()
@app.post("/api/agent/commands/run-poll-now")
async def minerops_queue_agent_poll_now(current_user: dict = Depends(get_current_user)):
import uuid
import json
await minerops_ensure_agent_command_queue_table()
command_id = str(uuid.uuid4())
username = str(current_user.get("username") or current_user.get("email") or "unknown")
user_id = str(current_user.get("id") or "")
conn = await minerops_command_db_connect()
try:
await conn.execute(
"""
INSERT INTO minerops_agent_command_queue (
id,
agent_id,
command_type,
command_status,
requested_by_user_id,
requested_by_username,
request_payload,
created_at
)
VALUES (
$1,
$2,
$3,
'pending',
$4,
$5,
$6::jsonb,
now()
)
""",
command_id,
"plexus-windows-agent",
"poll_known_miners_now",
user_id,
username,
json.dumps({"source": "web", "reason": "manual_poll_now"}),
)
finally:
await conn.close()
return {
"ok": True,
"command_id": command_id,
"command_type": "poll_known_miners_now",
"message": "Agent poll command queued.",
}
@app.get("/api/agent/commands/status")
async def minerops_agent_command_status(current_user: dict = Depends(get_current_user)):
await minerops_ensure_agent_command_queue_table()
conn = await minerops_command_db_connect()
try:
rows = await conn.fetch(
"""
SELECT
id,
agent_id,
command_type,
command_status,
requested_by_username,
error_message,
created_at,
claimed_at,
completed_at,
result_payload
FROM minerops_agent_command_queue
ORDER BY created_at DESC
LIMIT 20
"""
)
finally:
await conn.close()
commands = []
for row in rows:
commands.append(
{
"id": row["id"],
"agent_id": row["agent_id"],
"command_type": row["command_type"],
"command_status": row["command_status"],
"requested_by_username": row["requested_by_username"],
"error_message": row["error_message"],
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"claimed_at": row["claimed_at"].isoformat() if row["claimed_at"] else None,
"completed_at": row["completed_at"].isoformat() if row["completed_at"] else None,
"result_payload": row["result_payload"],
}
)
return {
"ok": True,
"commands": commands,
}
@app.get("/api/agent/commands/pending")
async def minerops_agent_commands_pending_v2(request: Request):
# __SCHEDULE_DUE_HOOK_V2__
try:
minerops_process_due_scheduled_bulk_actions()
except NameError:
pass
except Exception as exc:
print(f"Scheduled bulk action due processing error: {exc}")
minerops_require_agent_secret(request)
await minerops_ensure_agent_command_queue_table()
agent_id = request.query_params.get("agent_id") or "plexus-windows-agent"
conn = await minerops_command_db_connect()
try:
rows = await conn.fetch(
"""
UPDATE minerops_agent_command_queue
SET
command_status = 'claimed',
claimed_at = now()
WHERE id IN (
SELECT id
FROM minerops_agent_command_queue
WHERE agent_id = $1
AND command_status = 'pending'
ORDER BY created_at ASC
LIMIT 5
)
RETURNING
id,
command_type,
request_payload,
created_at
""",
agent_id,
)
finally:
await conn.close()
commands = []
for row in rows:
commands.append(
{
"command_id": row["id"],
"command_type": row["command_type"],
"request_payload": row["request_payload"],
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
}
)
return {
"ok": True,
"commands": commands,
}
@app.post("/api/agent/commands/result")
async def minerops_agent_commands_result_v2(request: Request):
import json
minerops_require_agent_secret(request)
body = await request.json()
command_id = str(body.get("command_id") or "").strip()
status = str(body.get("status") or "").strip().lower()
result_payload = body.get("result_payload") or {}
error_message = body.get("error_message")
if not command_id:
raise HTTPException(status_code=400, detail="Missing command_id.")
if status not in {"completed", "failed"}:
status = "completed"
await minerops_ensure_agent_command_queue_table()
conn = await minerops_command_db_connect()
try:
await conn.execute(
"""
UPDATE minerops_agent_command_queue
SET
command_status = $2,
result_payload = $3::jsonb,
error_message = $4,
completed_at = now()
WHERE id = $1
""",
command_id,
status,
json.dumps(result_payload),
error_message,
)
finally:
await conn.close()
return {
"ok": True,
"command_id": command_id,
"status": status,
"message": "Agent command result received.",
}
# -----------------------------
# Phase 5K.5: Agent control web page
# -----------------------------
@app.get("/api/agent/status-summary")
async def minerops_agent_status_summary(current_user: dict = Depends(get_current_user)):
await minerops_ensure_agent_command_queue_table()
await ensure_minerops_agent_tables()
conn = await minerops_command_db_connect()
try:
agent = await conn.fetchrow(
"""
SELECT
agent_id,
site_name,
hostname,
version,
ip_address,
last_checkin_at,
last_payload
FROM minerops_site_agents
WHERE agent_id = 'plexus-windows-agent'
ORDER BY last_checkin_at DESC
LIMIT 1
"""
)
snapshot = await conn.fetchrow(
"""
SELECT
id,
received_at,
miner_count,
payload
FROM minerops_agent_snapshots
WHERE agent_id = 'plexus-windows-agent'
ORDER BY id DESC
LIMIT 1
"""
)
latest_counts = await conn.fetchrow(
"""
SELECT
COUNT(*) FILTER (WHERE status = 'online') AS online_count,
COUNT(*) FILTER (WHERE status = 'offline') AS offline_count,
COUNT(*) AS total_count
FROM miners
"""
)
recent_commands = await conn.fetch(
"""
SELECT
id,
command_type,
command_status,
requested_by_username,
created_at,
claimed_at,
completed_at,
error_message,
result_payload
FROM minerops_agent_command_queue
ORDER BY created_at DESC
LIMIT 10
"""
)
finally:
await conn.close()
commands = []
for row in recent_commands:
commands.append(
{
"id": row["id"],
"command_type": row["command_type"],
"command_status": row["command_status"],
"requested_by_username": row["requested_by_username"],
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"claimed_at": row["claimed_at"].isoformat() if row["claimed_at"] else None,
"completed_at": row["completed_at"].isoformat() if row["completed_at"] else None,
"error_message": row["error_message"],
"result_payload": row["result_payload"],
}
)
latest_snapshot_payload = snapshot["payload"] if snapshot else {}
latest_miners = latest_snapshot_payload.get("miners") if isinstance(latest_snapshot_payload, dict) else []
latest_online = 0
latest_offline = 0
if isinstance(latest_miners, list):
for item in latest_miners:
if isinstance(item, dict) and item.get("online"):
latest_online += 1
else:
latest_offline += 1
return {
"ok": True,
"agent": {
"agent_id": agent["agent_id"] if agent else None,
"site_name": agent["site_name"] if agent else None,
"hostname": agent["hostname"] if agent else None,
"version": agent["version"] if agent else None,
"ip_address": agent["ip_address"] if agent else None,
"last_checkin_at": agent["last_checkin_at"].isoformat() if agent and agent["last_checkin_at"] else None,
},
"latest_snapshot": {
"id": snapshot["id"] if snapshot else None,
"received_at": snapshot["received_at"].isoformat() if snapshot and snapshot["received_at"] else None,
"miner_count": snapshot["miner_count"] if snapshot else 0,
"online": latest_online,
"offline": latest_offline,
},
"live_database_counts": {
"total": latest_counts["total_count"] if latest_counts else 0,
"online": latest_counts["online_count"] if latest_counts else 0,
"offline": latest_counts["offline_count"] if latest_counts else 0,
},
"recent_commands": commands,
}
@app.get("/agent/control")
async def minerops_agent_control_page():
html = """
MinerOps Agent Control
MINEROPS CONSOLE
Agent Control
Windows Agent for Plexus Mining Site
Loading...
Manual Actions
This queues a command on the hub. The Windows Agent picks it up, polls known miners, sends a snapshot back, and updates the dashboard.
DB Total
-
DB Online
-
Latest Snapshot Online
-
Latest Snapshot Offline
-
Agent Status
Agent ID
-
Hostname
-
Version
-
Last Check-in
-
Latest Snapshot
-
Discovery Summary
Loading discovery summary...
Note: An unmatched discovery does not always mean new hardware.
It may be an existing miner with a changed IP, missing MAC, or intermittent hardware.
Use Enrich / Match before Add as New.
New Miner Discoveries
Found
Room
IP
MAC
Model
Status
Matched Miner
Actions
Loading...
Recent Agent Commands
Created
Type
Status
Requested By
Claimed
Completed
Error
Loading...
"""
return HTMLResponse(content=html)
# -----------------------------
# Phase 5L: Agent new miner discovery
# -----------------------------
async def minerops_ensure_agent_discovery_table():
conn = await minerops_command_db_connect()
try:
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS minerops_agent_discoveries (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
site_name TEXT NOT NULL,
room_name TEXT,
ip_address INET,
mac_address TEXT,
model TEXT,
hostname TEXT,
miner_sn TEXT,
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
discovery_status TEXT NOT NULL DEFAULT 'new',
matched_miner_id UUID,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_minerops_agent_discoveries_mac
ON minerops_agent_discoveries(mac_address)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_minerops_agent_discoveries_ip
ON minerops_agent_discoveries(ip_address)
"""
)
finally:
await conn.close()
@app.on_event("startup")
async def startup_ensure_minerops_agent_discovery_table():
await minerops_ensure_agent_discovery_table()
@app.post("/api/agent/commands/scan-new-miners")
async def minerops_queue_agent_scan_new_miners(current_user: dict = Depends(get_current_user)):
import uuid
import json
await minerops_ensure_agent_command_queue_table()
command_id = str(uuid.uuid4())
username = str(current_user.get("username") or current_user.get("email") or "unknown")
user_id = str(current_user.get("id") or "")
conn = await minerops_command_db_connect()
try:
await conn.execute(
"""
INSERT INTO minerops_agent_command_queue (
id,
agent_id,
command_type,
command_status,
requested_by_user_id,
requested_by_username,
request_payload,
created_at
)
VALUES (
$1,
$2,
$3,
'pending',
$4,
$5,
$6::jsonb,
now()
)
""",
command_id,
"plexus-windows-agent",
"discover_new_miners",
user_id,
username,
json.dumps({"source": "web", "reason": "manual_new_miner_scan"}),
)
finally:
await conn.close()
return {
"ok": True,
"command_id": command_id,
"command_type": "discover_new_miners",
"message": "New-miner discovery command queued.",
}
@app.post("/api/agent/discoveries")
async def minerops_agent_post_discoveries(request: Request):
import json
import uuid
minerops_require_agent_secret(request)
await minerops_ensure_agent_discovery_table()
body = await request.json()
agent_id = str(body.get("agent_id") or "plexus-windows-agent").strip()
site_name = str(body.get("site_name") or "Plexus").strip()
discoveries = body.get("discoveries") or []
if not isinstance(discoveries, list):
discoveries = []
conn = await minerops_command_db_connect()
inserted = 0
matched_existing = 0
staged_new = 0
errors = []
try:
for item in discoveries:
if not isinstance(item, dict):
continue
ip_address = str(item.get("ip_address") or "").strip()
mac_address = str(item.get("mac_address") or "").strip().upper()
room_name = str(item.get("room_name") or "").strip()
model = str(item.get("model") or "").strip()
hostname = str(item.get("hostname") or "").strip()
miner_sn = str(item.get("miner_sn") or "").strip()
matched = None
if mac_address:
matched = await conn.fetchrow(
"""
SELECT id
FROM miners
WHERE upper(current_mac) = upper($1)
LIMIT 1
""",
mac_address,
)
if not matched and ip_address:
matched = await conn.fetchrow(
"""
SELECT id
FROM miners
WHERE current_ip = NULLIF($1, '')::inet
LIMIT 1
""",
ip_address,
)
status = "known" if matched else "new"
if matched:
matched_existing += 1
else:
staged_new += 1
try:
await conn.execute(
"""
INSERT INTO minerops_agent_discoveries (
id,
agent_id,
site_name,
room_name,
ip_address,
mac_address,
model,
hostname,
miner_sn,
raw_payload,
discovery_status,
matched_miner_id,
first_seen_at,
last_seen_at,
created_at,
updated_at
)
VALUES (
$1,
$2,
$3,
NULLIF($4, ''),
NULLIF($5, '')::inet,
NULLIF($6, ''),
NULLIF($7, ''),
NULLIF($8, ''),
NULLIF($9, ''),
$10::jsonb,
$11,
$12::uuid,
now(),
now(),
now(),
now()
)
ON CONFLICT (id) DO NOTHING
""",
str(uuid.uuid4()),
agent_id,
site_name,
room_name,
ip_address,
mac_address,
model,
hostname,
miner_sn,
json.dumps(item),
status,
str(matched["id"]) if matched else None,
)
inserted += 1
except Exception as exc:
if len(errors) < 20:
errors.append({"ip_address": ip_address, "mac_address": mac_address, "error": str(exc)})
finally:
await conn.close()
return {
"ok": True,
"received": len(discoveries),
"inserted": inserted,
"matched_existing": matched_existing,
"staged_new": staged_new,
"errors": errors,
"message": "Discovery results received.",
}
@app.get("/api/agent/discoveries")
async def minerops_agent_discoveries(current_user: dict = Depends(get_current_user)):
await minerops_ensure_agent_discovery_table()
conn = await minerops_command_db_connect()
try:
rows = await conn.fetch(
"""
SELECT
id,
agent_id,
site_name,
room_name,
ip_address::text AS ip_address,
mac_address,
model,
hostname,
miner_sn,
discovery_status,
matched_miner_id::text AS matched_miner_id,
first_seen_at,
last_seen_at,
created_at
FROM minerops_agent_discoveries
ORDER BY created_at DESC
LIMIT 100
"""
)
finally:
await conn.close()
items = []
for row in rows:
items.append(
{
"id": row["id"],
"agent_id": row["agent_id"],
"site_name": row["site_name"],
"room_name": row["room_name"],
"ip_address": row["ip_address"],
"mac_address": row["mac_address"],
"model": row["model"],
"hostname": row["hostname"],
"miner_sn": row["miner_sn"],
"discovery_status": row["discovery_status"],
"matched_miner_id": row["matched_miner_id"],
"first_seen_at": row["first_seen_at"].isoformat() if row["first_seen_at"] else None,
"last_seen_at": row["last_seen_at"].isoformat() if row["last_seen_at"] else None,
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
}
)
return {
"ok": True,
"discoveries": items,
}
@app.post("/api/agent/discoveries/review")
async def minerops_agent_discovery_review(request: Request, current_user: dict = Depends(get_current_user)):
import asyncpg
payload = await request.json()
ip_address = str(payload.get("ip_address") or "").strip()
action = str(payload.get("action") or "").strip().lower()
if not ip_address:
raise HTTPException(status_code=400, detail="ip_address is required.")
valid_actions = {"add_to_inventory", "ignore", "non_miner"}
if action not in valid_actions:
raise HTTPException(status_code=400, detail="Invalid discovery action.")
conn = await minerops_command_db_connect()
try:
discovery = await conn.fetchrow("""
SELECT
id,
agent_id,
site_name,
room_name,
ip_address::text AS ip_address,
mac_address,
model,
hostname,
miner_sn,
discovery_status
FROM minerops_agent_discoveries
WHERE ip_address = $1::inet
ORDER BY created_at DESC
LIMIT 1
""", ip_address)
if not discovery:
raise HTTPException(status_code=404, detail="Discovery not found.")
if action == "ignore":
await conn.execute("""
UPDATE minerops_agent_discoveries
SET
discovery_status = 'ignored',
updated_at = now()
WHERE ip_address = $1::inet
""", ip_address)
return {
"ok": True,
"action": action,
"ip_address": ip_address,
"message": "Discovery ignored."
}
if action == "non_miner":
await conn.execute("""
UPDATE minerops_agent_discoveries
SET
discovery_status = 'non_miner',
updated_at = now()
WHERE ip_address = $1::inet
""", ip_address)
return {
"ok": True,
"action": action,
"ip_address": ip_address,
"message": "Discovery marked as non-miner."
}
mac = discovery["mac_address"]
if mac is not None:
mac = str(mac).strip() or None
existing = None
if mac:
existing = await conn.fetchrow("""
SELECT id::text AS id
FROM miners
WHERE upper(current_mac) = upper($1)
LIMIT 1
""", mac)
if existing is None:
existing = await conn.fetchrow("""
SELECT id::text AS id
FROM miners
WHERE current_ip = $1::inet
LIMIT 1
""", ip_address)
if existing:
await conn.execute("""
UPDATE minerops_agent_discoveries
SET
discovery_status = 'known',
matched_miner_id = $2::uuid,
updated_at = now()
WHERE ip_address = $1::inet
""", ip_address, existing["id"])
return {
"ok": True,
"action": action,
"ip_address": ip_address,
"miner_id": existing["id"],
"message": "Discovery already matched an existing inventory miner."
}
room_id = None
site_id = None
room_name = discovery["room_name"]
if room_name:
try:
room = await conn.fetchrow("""
SELECT id::text AS id, site_id::text AS site_id
FROM rooms
WHERE lower(name) = lower($1)
LIMIT 1
""", str(room_name))
if room:
room_id = room["id"]
site_id = room["site_id"]
except Exception:
room_id = None
site_id = None
model = discovery["model"] or "WhatsMiner"
hostname = discovery["hostname"]
miner_sn = discovery["miner_sn"]
new_miner = await conn.fetchrow("""
INSERT INTO miners (
site_id,
room_id,
current_mac,
current_ip,
hostname,
miner_sn,
model,
miner_type,
status,
notes,
first_seen_at,
last_seen_at,
created_at,
updated_at
)
VALUES (
$1::uuid,
$2::uuid,
$3,
$4::inet,
$5,
$6,
$7,
$7,
'discovered',
$8,
now(),
now(),
now(),
now()
)
RETURNING id::text AS id
""",
site_id,
room_id,
mac,
ip_address,
hostname,
miner_sn,
model,
"Added from Agent Discovery. Review MAC, serial, location, and notes."
)
await conn.execute("""
UPDATE minerops_agent_discoveries
SET
discovery_status = 'added',
matched_miner_id = $2::uuid,
updated_at = now()
WHERE ip_address = $1::inet
""", ip_address, new_miner["id"])
return {
"ok": True,
"action": action,
"ip_address": ip_address,
"miner_id": new_miner["id"],
"message": "Discovery added to inventory."
}
finally:
await conn.close()
@app.post("/api/agent/miner-enrichment")
async def minerops_agent_miner_enrichment(request: Request):
import json
await minerops_require_agent_secret(request)
payload = await request.json()
ip_address = str(payload.get("ip_address") or "").strip()
miner_id = str(payload.get("miner_id") or "").strip()
def clean(value):
if value is None:
return None
value = str(value).strip()
return value or None
def normalize_mac(value):
value = clean(value)
if not value:
return None
value = value.replace("-", ":").upper()
return value
def jsonb_value(value):
if value is None:
return None
if isinstance(value, (dict, list)):
return json.dumps(value)
text_value = str(value).strip()
if not text_value:
return None
try:
return json.dumps(json.loads(text_value))
except Exception:
return json.dumps({"raw": text_value})
if not ip_address and not miner_id:
raise HTTPException(status_code=400, detail="ip_address or miner_id is required.")
conn = await minerops_command_db_connect()
try:
miner = None
if miner_id:
miner = await conn.fetchrow("""
SELECT id::text AS id
FROM miners
WHERE id = $1::uuid
LIMIT 1
""", miner_id)
if miner is None and ip_address:
miner = await conn.fetchrow("""
SELECT id::text AS id
FROM miners
WHERE current_ip = $1::inet
LIMIT 1
""", ip_address)
if miner is None:
raise HTTPException(status_code=404, detail="Miner not found.")
miner_id = miner["id"]
mac = normalize_mac(payload.get("mac_address"))
hostname = clean(payload.get("hostname"))
miner_sn = clean(payload.get("miner_sn"))
model = clean(payload.get("model"))
miner_type = clean(payload.get("miner_type")) or model
api_version = clean(payload.get("api_version"))
firmware_version = clean(payload.get("firmware_version"))
platform = clean(payload.get("platform"))
raw_version_json = jsonb_value(payload.get("raw_version"))
raw_miner_info_json = jsonb_value(payload.get("raw_miner_info"))
await conn.execute("""
UPDATE miners
SET
current_mac = COALESCE($2, current_mac),
hostname = COALESCE($3, hostname),
miner_sn = COALESCE($4, miner_sn),
model = COALESCE($5, model),
miner_type = COALESCE($6, miner_type),
api_version = COALESCE($7, api_version),
firmware_version = COALESCE($8, firmware_version),
platform = COALESCE($9, platform),
updated_at = now()
WHERE id = $1::uuid
""",
miner_id,
mac,
hostname,
miner_sn,
model,
miner_type,
api_version,
firmware_version,
platform
)
if raw_version_json is not None or raw_miner_info_json is not None:
await conn.execute("""
UPDATE miner_status_snapshots
SET
raw_version_json = COALESCE($2::jsonb, raw_version_json),
raw_miner_info_json = COALESCE($3::jsonb, raw_miner_info_json)
WHERE id = (
SELECT id
FROM miner_status_snapshots
WHERE miner_id = $1::uuid
ORDER BY created_at DESC
LIMIT 1
)
""", miner_id, raw_version_json, raw_miner_info_json)
updated = await conn.fetchrow("""
SELECT
id::text AS id,
current_mac,
host(current_ip) AS current_ip,
hostname,
miner_sn,
model,
miner_type,
api_version,
firmware_version,
platform,
status,
updated_at
FROM miners
WHERE id = $1::uuid
""", miner_id)
return {
"ok": True,
"miner": dict(updated),
"message": "Miner enrichment saved."
}
finally:
await conn.close()
@app.post("/api/agent/miner-enrichment-v2")
async def minerops_agent_miner_enrichment_v2(request: Request):
import json
await minerops_require_agent_secret(request)
payload = await request.json()
ip_address = str(payload.get("ip_address") or "").strip()
miner_id = str(payload.get("miner_id") or "").strip()
def clean(value):
if value is None:
return None
value = str(value).strip()
return value or None
def normalize_mac(value):
value = clean(value)
if not value:
return None
return value.replace("-", ":").upper()
def jsonb_text(value):
if value is None:
return None
if isinstance(value, (dict, list)):
return json.dumps(value)
value = str(value).strip()
if not value:
return None
try:
return json.dumps(json.loads(value))
except Exception:
return json.dumps({"raw": value})
def safe_dt(value):
try:
return value.isoformat()
except Exception:
return str(value) if value is not None else None
if not ip_address and not miner_id:
raise HTTPException(status_code=400, detail="ip_address or miner_id is required.")
mac = normalize_mac(payload.get("mac_address"))
hostname = clean(payload.get("hostname"))
miner_sn = clean(payload.get("miner_sn"))
model = clean(payload.get("model")) or "WhatsMiner"
miner_type = clean(payload.get("miner_type")) or model
api_version = clean(payload.get("api_version"))
firmware_version = clean(payload.get("firmware_version"))
platform = clean(payload.get("platform"))
raw_version_json = jsonb_text(payload.get("raw_version"))
raw_miner_info_json = jsonb_text(payload.get("raw_miner_info"))
conn = await minerops_command_db_connect()
try:
source = None
if miner_id:
source = await conn.fetchrow("""
SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes
FROM miners
WHERE id = $1::uuid
LIMIT 1
""", miner_id)
if source is None and ip_address:
source = await conn.fetchrow("""
SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes
FROM miners
WHERE current_ip = $1::inet
LIMIT 1
""", ip_address)
if source is None:
raise HTTPException(status_code=404, detail="Source miner not found.")
source_id = source["id"]
target_id = source_id
merged_from_miner_id = None
source_removed = False
if mac:
existing_mac_miner = await conn.fetchrow("""
SELECT id::text AS id, host(current_ip) AS current_ip, current_mac, notes
FROM miners
WHERE upper(current_mac) = upper($1)
LIMIT 1
""", mac)
if existing_mac_miner and existing_mac_miner["id"] != source_id:
target_id = existing_mac_miner["id"]
merged_from_miner_id = source_id
await conn.execute("""
UPDATE miners
SET
current_ip = NULL,
status = 'merged',
notes = COALESCE(notes, '') || E'\nMerged into miner ' || $2 || ' after enrichment found matching MAC ' || $3 || '.',
updated_at = now()
WHERE id = $1::uuid
""", source_id, target_id, mac)
await conn.execute("""
UPDATE miners
SET
current_mac = COALESCE($2, current_mac),
current_ip = COALESCE($3::inet, current_ip),
hostname = COALESCE($4, hostname),
miner_sn = COALESCE($5, miner_sn),
model = COALESCE($6, model),
miner_type = COALESCE($7, miner_type),
api_version = COALESCE($8, api_version),
firmware_version = COALESCE($9, firmware_version),
platform = COALESCE($10, platform),
status = 'online',
last_seen_at = now(),
updated_at = now()
WHERE id = $1::uuid
""",
target_id,
mac,
ip_address or None,
hostname,
miner_sn,
model,
miner_type,
api_version,
firmware_version,
platform
)
if merged_from_miner_id:
await conn.execute("""
UPDATE miner_status_snapshots
SET miner_id = $2::uuid
WHERE miner_id = $1::uuid
""", merged_from_miner_id, target_id)
try:
await conn.execute("""
DELETE FROM miners
WHERE id = $1::uuid
""", merged_from_miner_id)
source_removed = True
except Exception:
source_removed = False
if raw_version_json is not None or raw_miner_info_json is not None:
await conn.execute("""
UPDATE miner_status_snapshots
SET
raw_version_json = CASE WHEN $2 IS NULL THEN raw_version_json ELSE $2::jsonb END,
raw_miner_info_json = CASE WHEN $3 IS NULL THEN raw_miner_info_json ELSE $3::jsonb END
WHERE id = (
SELECT id
FROM miner_status_snapshots
WHERE miner_id = $1::uuid
ORDER BY created_at DESC
LIMIT 1
)
""", target_id, raw_version_json, raw_miner_info_json)
if ip_address:
try:
await conn.execute("""
UPDATE minerops_agent_discoveries
SET
discovery_status = 'known',
matched_miner_id = $2::uuid,
updated_at = now()
WHERE ip_address = $1::inet
""", ip_address, target_id)
except Exception:
pass
updated = await conn.fetchrow("""
SELECT
id::text AS id,
current_mac,
host(current_ip) AS current_ip,
hostname,
miner_sn,
model,
miner_type,
api_version,
firmware_version,
platform,
status,
updated_at
FROM miners
WHERE id = $1::uuid
""", target_id)
result = dict(updated)
result["updated_at"] = safe_dt(result.get("updated_at"))
return {
"ok": True,
"miner": result,
"target_miner_id": target_id,
"merged_from_miner_id": merged_from_miner_id,
"source_removed": source_removed,
"message": "Miner enrichment saved."
}
finally:
await conn.close()
@app.post("/api/agent/miner-enrichment-v3")
async def minerops_agent_miner_enrichment_v3(request: Request):
import json
await minerops_require_agent_secret(request)
payload = await request.json()
ip_address = str(payload.get("ip_address") or "").strip()
miner_id = str(payload.get("miner_id") or "").strip()
def clean(value):
if value is None:
return None
value = str(value).strip()
return value or None
def normalize_mac(value):
value = clean(value)
if not value:
return None
return value.replace("-", ":").upper()
def jsonb_text(value):
if value is None:
return None
if isinstance(value, (dict, list)):
return json.dumps(value)
value = str(value).strip()
if not value:
return None
try:
return json.dumps(json.loads(value))
except Exception:
return json.dumps({"raw": value})
mac = normalize_mac(payload.get("mac_address"))
hostname = clean(payload.get("hostname"))
miner_sn = clean(payload.get("miner_sn"))
model = clean(payload.get("model")) or "WhatsMiner"
miner_type = clean(payload.get("miner_type")) or model
api_version = clean(payload.get("api_version"))
firmware_version = clean(payload.get("firmware_version"))
platform = clean(payload.get("platform"))
raw_version_json = jsonb_text(payload.get("raw_version"))
raw_miner_info_json = jsonb_text(payload.get("raw_miner_info"))
if not ip_address and not miner_id:
raise HTTPException(status_code=400, detail="ip_address or miner_id is required.")
conn = await minerops_command_db_connect()
try:
target_id = None
matched_by = None
if mac:
row = await conn.fetchrow("""
SELECT id::text AS id
FROM miners
WHERE upper(current_mac) = upper($1)
LIMIT 1
""", mac)
if row:
target_id = row["id"]
matched_by = "mac"
if target_id is None and miner_id:
row = await conn.fetchrow("""
SELECT id::text AS id
FROM miners
WHERE id = $1::uuid
LIMIT 1
""", miner_id)
if row:
target_id = row["id"]
matched_by = "miner_id"
if target_id is None and ip_address:
row = await conn.fetchrow("""
SELECT id::text AS id
FROM miners
WHERE current_ip = $1::inet
LIMIT 1
""", ip_address)
if row:
target_id = row["id"]
matched_by = "ip"
if target_id:
await conn.execute("""
UPDATE miners
SET
current_mac = COALESCE($2, current_mac),
current_ip = COALESCE($3::inet, current_ip),
hostname = COALESCE($4, hostname),
miner_sn = COALESCE($5, miner_sn),
model = COALESCE($6, model),
miner_type = COALESCE($7, miner_type),
api_version = COALESCE($8, api_version),
firmware_version = COALESCE($9, firmware_version),
platform = COALESCE($10, platform),
status = 'online',
last_seen_at = now(),
updated_at = now()
WHERE id = $1::uuid
""",
target_id,
mac,
ip_address or None,
hostname,
miner_sn,
model,
miner_type,
api_version,
firmware_version,
platform
)
if raw_version_json is not None or raw_miner_info_json is not None:
await conn.execute("""
UPDATE miner_status_snapshots
SET
raw_version_json = CASE WHEN $2 IS NULL THEN raw_version_json ELSE $2::jsonb END,
raw_miner_info_json = CASE WHEN $3 IS NULL THEN raw_miner_info_json ELSE $3::jsonb END
WHERE id = (
SELECT id
FROM miner_status_snapshots
WHERE miner_id = $1::uuid
ORDER BY created_at DESC
LIMIT 1
)
""", target_id, raw_version_json, raw_miner_info_json)
if ip_address:
await conn.execute("""
UPDATE minerops_agent_discoveries
SET
mac_address = COALESCE($2, mac_address),
model = COALESCE($3, model),
hostname = COALESCE($4, hostname),
miner_sn = COALESCE($5, miner_sn),
discovery_status = 'matched',
matched_miner_id = $6::uuid,
raw_payload = COALESCE(raw_payload, '{}'::jsonb) || $7::jsonb,
updated_at = now()
WHERE ip_address = $1::inet
""",
ip_address,
mac,
model,
hostname,
miner_sn,
target_id,
json.dumps({"enrichment": payload, "matched_by": matched_by})
)
updated = await conn.fetchrow("""
SELECT
id::text AS id,
current_mac,
host(current_ip) AS current_ip,
hostname,
miner_sn,
model,
miner_type,
api_version,
firmware_version,
platform,
status,
updated_at
FROM miners
WHERE id = $1::uuid
""", target_id)
return {
"ok": True,
"matched": True,
"matched_by": matched_by,
"miner": dict(updated),
"message": "Discovery enriched and matched to existing inventory."
}
if ip_address:
await conn.execute("""
UPDATE minerops_agent_discoveries
SET
mac_address = COALESCE($2, mac_address),
model = COALESCE($3, model),
hostname = COALESCE($4, hostname),
miner_sn = COALESCE($5, miner_sn),
discovery_status = 'needs_review',
raw_payload = COALESCE(raw_payload, '{}'::jsonb) || $6::jsonb,
updated_at = now()
WHERE ip_address = $1::inet
""",
ip_address,
mac,
model,
hostname,
miner_sn,
json.dumps({"enrichment": payload, "reason": "no_inventory_match"})
)
return {
"ok": True,
"matched": False,
"ip_address": ip_address,
"mac_address": mac,
"message": "Discovery enriched but did not match existing inventory. Review before adding."
}
finally:
await conn.close()
@app.post("/api/agent/discoveries/enrich-match")
async def minerops_queue_discovery_enrich_match(request: Request, current_user: dict = Depends(get_current_user)):
payload = await request.json()
ip_address = str(payload.get("ip_address") or "").strip()
if not ip_address:
raise HTTPException(status_code=400, detail="ip_address is required.")
conn = await minerops_command_db_connect()
try:
discovery = await conn.fetchrow("""
SELECT room_name
FROM minerops_agent_discoveries
WHERE ip_address = $1::inet
ORDER BY created_at DESC
LIMIT 1
""", ip_address)
room_name = discovery["room_name"] if discovery else None
command = await conn.fetchrow("""
INSERT INTO minerops_agent_command_queue (
id,
agent_id,
command_type,
command_status,
requested_by_username,
request_payload,
created_at
)
VALUES (
gen_random_uuid()::text,
'plexus-windows-agent',
'enrich_match_discovery',
'pending',
'admin',
jsonb_build_object(
'ip_address', $1::text,
'room_name', $2::text,
'source', 'agent-control-discovery'
),
now()
)
RETURNING id
""", ip_address, room_name)
return {
"ok": True,
"command_id": command["id"],
"ip_address": ip_address,
"message": "Enrich / Match command queued."
}
finally:
await conn.close()
@app.get("/api/agent/discoveries/summary")
async def minerops_agent_discoveries_summary(request: Request, current_user: dict = Depends(get_current_user)):
conn = await minerops_command_db_connect()
try:
rows = await conn.fetch("""
SELECT
discovery_status,
COUNT(*)::int AS count
FROM minerops_agent_discoveries
GROUP BY discovery_status
ORDER BY discovery_status
""")
latest = await conn.fetchrow("""
SELECT
MAX(COALESCE(updated_at, created_at)) AS latest_at,
COUNT(*)::int AS total
FROM minerops_agent_discoveries
""")
counts = {}
for row in rows:
counts[row["discovery_status"] or "unknown"] = row["count"]
return {
"ok": True,
"total": latest["total"] if latest else 0,
"counts": counts,
"latest_at": latest["latest_at"].isoformat() if latest and latest["latest_at"] else None
}
finally:
await conn.close()
@app.post("/api/miners/{miner_id}/agent-control")
async def minerops_queue_agent_miner_control(miner_id: str, request: Request, current_user: dict = Depends(get_current_user)):
payload = await request.json()
action = str(payload.get("action") or "").strip().lower()
dry_run = bool(payload.get("dry_run", True))
performance_mode = str(payload.get("performance_mode") or payload.get("mode") or "").strip().lower()
allowed_actions = {"reboot", "suspend", "resume", "performance_mode"}
if action == "performance_mode" and performance_mode not in {"low", "normal", "high"}:
raise HTTPException(status_code=400, detail="performance_mode must be one of: low, normal, high.")
if action not in allowed_actions:
raise HTTPException(status_code=400, detail="Invalid action. Allowed: reboot, suspend, resume, performance_mode.")
conn = await minerops_command_db_connect()
try:
miner = await conn.fetchrow("""
SELECT
id::text AS id,
host(current_ip) AS current_ip,
current_mac,
model,
miner_type,
status
FROM miners
WHERE id = $1::uuid
LIMIT 1
""", miner_id)
if not miner:
raise HTTPException(status_code=404, detail="Miner not found.")
if not miner["current_ip"]:
raise HTTPException(status_code=400, detail="Miner has no current IP.")
command = await conn.fetchrow("""
INSERT INTO minerops_agent_command_queue (
id,
agent_id,
command_type,
command_status,
requested_by_username,
request_payload,
created_at
)
VALUES (
gen_random_uuid()::text,
'plexus-windows-agent',
'miner_control',
'pending',
'admin',
jsonb_build_object(
'miner_id', $1::text,
'ip_address', $2::text,
'mac_address', $3::text,
'model', $4::text,
'miner_type', $5::text,
'action', $6::text,
'dry_run', $7::boolean,
'performance_mode', $8::text,
'source', 'miner-detail-agent-control'
),
now()
)
RETURNING id
""",
miner["id"],
miner["current_ip"],
miner["current_mac"],
miner["model"],
miner["miner_type"],
action,
dry_run,
performance_mode
)
return {
"ok": True,
"command_id": command["id"],
"miner_id": miner["id"],
"ip_address": miner["current_ip"],
"action": action,
"dry_run": dry_run,
"message": "Miner control command queued for Windows Agent."
}
finally:
await conn.close()