feat: add agent zones

This commit is contained in:
lussierj committed 2026-08-26 15:48:59 +00:00
1 parent d8cd7ad058
commit 82309db79a
29 files changed
+2242 -255

No files matched your search

+154 -235
View File
@@ -11,7 +11,12 @@ from fastapi import Request, Depends, FastAPI, Header, HTTPException, Query, sta
from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError
from app.config import settings
from app.agent_commands.dependencies import require_agent_identity
from app.agent_commands.router import router as agent_commands_router
from app.agent_zones.dependencies import get_agent_zone_service
from app.agent_zones.models import MinerZoneAssociationResult
from app.agent_zones.service import AgentZoneService
from app.agent_zones.router import router as agent_zones_router
from app.miner_control.router import router as miner_control_router
from app.dashboard.router import router as dashboard_router
from app.identity import (
@@ -67,6 +72,7 @@ app.include_router(frontend_router)
app.include_router(inventory_router)
app.include_router(miner_control_router)
app.include_router(agent_commands_router)
app.include_router(agent_zones_router)
app.include_router(components_router)
app.include_router(locations_router)
app.include_router(dashboard_router)
@@ -25081,70 +25087,6 @@ 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_id = str(body.get("site_id") or "").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_id,
site_name,
hostname,
version,
ip_address,
last_checkin_at,
last_payload
)
VALUES ($1, NULLIF($2, '')::uuid, $3, $4, $5, $6, now(), $7::jsonb)
ON CONFLICT (agent_id)
DO UPDATE SET
site_id = COALESCE(EXCLUDED.site_id, minerops_site_agents.site_id),
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_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):
@@ -25183,10 +25125,13 @@ async def minerops_agent_config(request: Request):
@app.post("/api/agent/miner-snapshots")
async def minerops_agent_miner_snapshots(request: Request):
async def minerops_agent_miner_snapshots(
request: Request,
authenticated_agent_id: str = Depends(require_agent_identity),
zone_service: AgentZoneService = Depends(get_agent_zone_service),
):
import json
minerops_require_agent_secret(request)
await ensure_minerops_agent_tables()
try:
@@ -25194,30 +25139,39 @@ async def minerops_agent_miner_snapshots(request: Request):
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()
zone_service.validate_agent_identity(
authenticated_agent_id, str(body.get("agent_id") or "").strip() or None
)
site_name = str(body.get("site_name") or "Unassigned").strip()
miners = body.get("miners")
if not isinstance(miners, list):
miners = []
conn = await minerops_command_db_connect()
miner_ids = []
for item in miners:
if not isinstance(item, dict):
continue
try:
miner_ids.append(UUID(str(item.get("miner_id") or "")))
except ValueError:
continue
ownership = zone_service.associate_miners_from_agent(
authenticated_agent_id, miner_ids
)
conn = await minerops_command_db_connect()
try:
row = await conn.fetchrow(
"""
INSERT INTO minerops_agent_snapshots (
agent_id,
site_name,
snapshot_kind,
miner_count,
payload
agent_id, agent_zone_id, site_name, snapshot_kind,
miner_count, payload
)
VALUES ($1, $2, 'miner_poll', $3, $4::jsonb)
VALUES ($1, $2::uuid, $3, 'miner_poll', $4, $5::jsonb)
RETURNING id, received_at
""",
agent_id,
authenticated_agent_id,
str(ownership.agent_zone_id) if ownership.agent_zone_id else None,
site_name,
len(miners),
json.dumps(body),
@@ -25225,13 +25179,16 @@ async def minerops_agent_miner_snapshots(request: Request):
finally:
await conn.close()
apply_result = await minerops_apply_agent_snapshot_to_live_tables(body)
apply_result = await minerops_apply_agent_snapshot_to_live_tables(
body, ownership
)
return {
"ok": True,
"snapshot_id": row["id"] if row else None,
"received_at": row["received_at"].isoformat() if row and row["received_at"] else None,
"miner_count": len(miners),
"agent_zone_id": str(ownership.agent_zone_id) if ownership.agent_zone_id else None,
"routing_reason": ownership.routing_reason,
"apply_result": apply_result,
"message": "Miner snapshot received, staged, and applied to live tables.",
}
@@ -25570,23 +25527,22 @@ def minerops_agent_parse_summary(miner_payload: dict) -> dict:
}
async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> dict:
async def minerops_apply_agent_snapshot_to_live_tables(
snapshot_body: dict, ownership: MinerZoneAssociationResult
) -> dict:
import json
import uuid
from datetime import datetime, timezone
miners = snapshot_body.get("miners")
if not isinstance(miners, list):
miners = []
now = datetime.now(timezone.utc)
applied = 0
online_count = 0
offline_count = 0
errors = []
conn = await minerops_command_db_connect()
try:
@@ -25597,29 +25553,38 @@ async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> d
miner_id = str(item.get("miner_id") or "").strip()
ip_address = str(item.get("ip_address") or "").strip()
online = bool(item.get("online"))
if not miner_id:
continue
try:
miner_uuid = UUID(miner_id)
except ValueError:
errors.append({"miner_id": miner_id, "error": "invalid_miner_id"})
continue
if ownership.routing_reason:
errors.append(
{"miner_id": miner_id, "error": ownership.routing_reason}
)
continue
zone_outcome = ownership.outcomes.get(miner_uuid, "miner_not_found")
if zone_outcome not in {"assigned", "preserved"}:
errors.append({"miner_id": miner_id, "error": zone_outcome})
continue
parsed = minerops_agent_parse_summary(item)
raw_summary = parsed["raw"]
msg = parsed["msg"]
status = "online" if online else "offline"
mhs_av = minerops_agent_to_float(msg.get("MHS av"))
mhs_1m = minerops_agent_to_float(msg.get("MHS 1m"))
mhs_15m = minerops_agent_to_float(msg.get("MHS 15m"))
hs_rt = minerops_agent_to_float(msg.get("HS RT"))
power_w = minerops_agent_to_float(msg.get("Power"))
power_rate = minerops_agent_to_float(msg.get("Power Rate"))
env_temp = minerops_agent_to_float(msg.get("Env Temp"))
chip_temp_min = minerops_agent_to_float(msg.get("Chip Temp Min"))
chip_temp_max = minerops_agent_to_float(msg.get("Chip Temp Max"))
chip_temp_avg = minerops_agent_to_float(msg.get("Chip Temp Avg"))
fan_in = minerops_agent_to_int(msg.get("Fan Speed In"))
fan_out = minerops_agent_to_int(msg.get("Fan Speed Out"))
uptime = minerops_agent_to_int(msg.get("Uptime"))
@@ -25629,12 +25594,10 @@ async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> d
try:
if online:
online_count += 1
await conn.execute(
"""
UPDATE miners
SET
status = $2,
SET status = $2,
current_ip = COALESCE(NULLIF($3, '')::inet, current_ip),
last_seen_at = $4,
updated_at = now()
@@ -25645,54 +25608,20 @@ async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> d
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
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()
$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()),
@@ -25718,12 +25647,10 @@ async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> d
)
else:
offline_count += 1
await conn.execute(
"""
UPDATE miners
SET
status = $2,
SET status = $2,
current_ip = COALESCE(NULLIF($3, '')::inet, current_ip),
updated_at = now()
WHERE id = $1::uuid
@@ -25732,19 +25659,12 @@ async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> d
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),
}
{"miner_id": miner_id, "ip_address": ip_address, "error": str(exc)}
)
finally:
await conn.close()
@@ -25756,7 +25676,6 @@ async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> d
}
# -----------------------------
# Phase 5K: Agent command queue
# -----------------------------
@@ -26639,24 +26558,28 @@ async def minerops_queue_agent_scan_new_miners(current_user: dict = Depends(get_
@app.post("/api/agent/discoveries")
async def minerops_agent_post_discoveries(request: Request):
async def minerops_agent_post_discoveries(
request: Request,
authenticated_agent_id: str = Depends(require_agent_identity),
zone_service: AgentZoneService = Depends(get_agent_zone_service),
):
import json
import uuid
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()
zone_service.validate_agent_identity(
authenticated_agent_id, str(body.get("agent_id") or "").strip() or None
)
site_name = str(body.get("site_name") or "Unassigned").strip()
discoveries = body.get("discoveries") or []
if not isinstance(discoveries, list):
discoveries = []
ownership_context = zone_service.associate_miners_from_agent(
authenticated_agent_id, []
)
conn = await minerops_command_db_connect()
inserted = 0
matched_existing = 0
staged_new = 0
@@ -26666,42 +26589,38 @@ async def minerops_agent_post_discoveries(request: Request):
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
""",
"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
""",
"SELECT id FROM miners WHERE current_ip = NULLIF($1, '')::inet LIMIT 1",
ip_address,
)
status = "known" if matched else "new"
if matched:
matched_existing += 1
association = zone_service.associate_miners_from_agent(
authenticated_agent_id, [matched["id"]]
)
outcome = association.outcomes.get(matched["id"])
if outcome not in {"assigned", "preserved"}:
status = "zone_conflict" if outcome else "known"
if outcome:
errors.append(
{"miner_id": str(matched["id"]), "error": outcome}
)
else:
staged_new += 1
@@ -26709,45 +26628,23 @@ async def minerops_agent_post_discoveries(request: Request):
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()
id, agent_id, agent_zone_id, site_name, room_name,
ip_address, mac_address, model, hostname, miner_sn,
raw_payload, discovery_status, matched_miner_id,
first_seen_at, last_seen_at, created_at, updated_at
) VALUES (
$1, $2, $3::uuid, $4, NULLIF($5, ''),
NULLIF($6, '')::inet, NULLIF($7, ''), NULLIF($8, ''),
NULLIF($9, ''), NULLIF($10, ''), $11::jsonb, $12,
$13::uuid, now(), now(), now(), now()
)
ON CONFLICT (id) DO NOTHING
""",
str(uuid.uuid4()),
agent_id,
authenticated_agent_id,
str(ownership_context.agent_zone_id)
if ownership_context.agent_zone_id
else None,
site_name,
room_name,
ip_address,
@@ -26759,12 +26656,12 @@ async def minerops_agent_post_discoveries(request: Request):
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)})
errors.append(
{"ip_address": ip_address, "mac_address": mac_address, "error": str(exc)}
)
finally:
await conn.close()
@@ -26774,6 +26671,10 @@ async def minerops_agent_post_discoveries(request: Request):
"inserted": inserted,
"matched_existing": matched_existing,
"staged_new": staged_new,
"agent_zone_id": str(ownership_context.agent_zone_id)
if ownership_context.agent_zone_id
else None,
"routing_reason": ownership_context.routing_reason,
"errors": errors,
"message": "Discovery results received.",
}
@@ -26841,9 +26742,12 @@ async def minerops_agent_discoveries(current_user: dict = Depends(get_current_us
@app.post("/api/agent/discoveries/review")
async def minerops_agent_discovery_review(request: Request, current_user: dict = Depends(get_current_user)):
import asyncpg
async def minerops_agent_discovery_review(
request: Request,
current_user: dict = Depends(get_current_user),
zone_service: AgentZoneService = Depends(get_agent_zone_service),
):
del current_user
payload = await request.json()
ip_address = str(payload.get("ip_address") or "").strip()
@@ -26864,6 +26768,7 @@ async def minerops_agent_discovery_review(request: Request, current_user: dict =
SELECT
id,
agent_id,
agent_zone_id,
site_name,
room_name,
ip_address::text AS ip_address,
@@ -26936,6 +26841,12 @@ async def minerops_agent_discovery_review(request: Request, current_user: dict =
""", ip_address)
if existing:
association = zone_service.associate_miners_from_agent(
discovery["agent_id"], [UUID(existing["id"])]
)
ownership_status = association.outcomes.get(
UUID(existing["id"]), association.routing_reason or "preserved"
)
await conn.execute("""
UPDATE minerops_agent_discoveries
SET
@@ -26950,29 +26861,34 @@ async def minerops_agent_discovery_review(request: Request, current_user: dict =
"action": action,
"ip_address": ip_address,
"miner_id": existing["id"],
"ownership_status": ownership_status,
"message": "Discovery already matched an existing inventory miner."
}
room_id = None
site_id = None
routing = zone_service.routing_context(
discovery["agent_id"], discovery["agent_zone_id"]
)
site_id = routing.site_id if routing.routing_reason is None else None
agent_zone_id = (
routing.agent_zone_id if routing.routing_reason is None else None
)
room_name = discovery["room_name"]
if room_name:
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
if room_name and site_id:
room = await conn.fetchrow(
"""
SELECT id::text AS id
FROM rooms
WHERE site_id = $1::uuid AND lower(name) = lower($2)
ORDER BY id
LIMIT 1
""",
str(site_id),
str(room_name),
)
if room:
room_id = room["id"]
model = discovery["model"] or "WhatsMiner"
hostname = discovery["hostname"]
@@ -26981,6 +26897,7 @@ async def minerops_agent_discovery_review(request: Request, current_user: dict =
new_miner = await conn.fetchrow("""
INSERT INTO miners (
site_id,
agent_zone_id,
room_id,
current_mac,
current_ip,
@@ -26998,14 +26915,15 @@ async def minerops_agent_discovery_review(request: Request, current_user: dict =
VALUES (
$1::uuid,
$2::uuid,
$3,
$4::inet,
$5,
$3::uuid,
$4,
$5::inet,
$6,
$7,
$7,
'discovered',
$8,
$8,
'discovered',
$9,
now(),
now(),
now(),
@@ -27014,6 +26932,7 @@ async def minerops_agent_discovery_review(request: Request, current_user: dict =
RETURNING id::text AS id
""",
site_id,
agent_zone_id,
room_id,
mac,
ip_address,