feat: add admin panel at /admin for full dashboard configuration

- Backend: ConfigManager write methods (atomic YAML save), admin CRUD
  router for settings (display/odoo/refresh) and nodes, WS broadcast
  on config changes, fix nmap scan blocking event loop with to_thread
- Frontend: admin UI with tab navigation, overview dashboard, node
  CRUD table with modal form, Odoo/display/refresh settings pages,
  typed API wrappers, active views filtering, config_changed WS handler
- Infra: nginx no-cache headers for HTML, cache-forever for hashed assets
- Fixes: WebSocket reconnect loop (ref pattern), rotation index OOB
  when views shrink, mutable node list cache

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 02:34:05 +00:00
parent 15f5f87376
commit 81bcdcd696
22 changed files with 1228 additions and 689 deletions

121
backend/routers/admin.py Normal file
View File

@@ -0,0 +1,121 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from modules.config_manager import ConfigManager
from routers.ws import broadcast
router = APIRouter(prefix="/api/admin", tags=["admin"])
# Will be set from main.py
config: ConfigManager = None # type: ignore
def init(cfg: ConfigManager) -> None:
global config
config = cfg
# ── Pydantic models ─────────────────────────────────────────
class DisplaySettings(BaseModel):
resolution: str | None = None
rotation_interval_seconds: int | None = None
transition: str | None = None
theme: str | None = None
active_views: list[str] | None = None
class OdooSettings(BaseModel):
url: str | None = None
database: str | None = None
username: str | None = None
password: str | None = None
exclude_company_ids: list[int] | None = None
class RefreshSettings(BaseModel):
odoo_minutes: int | None = None
network_minutes: int | None = None
ping_seconds: int | None = None
class NodeModel(BaseModel):
name: str
ip: str
username: str | None = None
password: str | None = None
public_url: str | None = None
icon: str = "device"
type: str | None = None
parent: str | None = None
connections: list[str] = []
# ── Settings endpoints ──────────────────────────────────────
@router.get("/settings")
async def get_settings():
return config.get_settings()
@router.put("/settings/display")
async def update_display(body: DisplaySettings):
values = body.model_dump(exclude_none=True)
result = config.update_settings("display", values)
await broadcast("config_changed", {"section": "display"})
return result
@router.put("/settings/odoo")
async def update_odoo(body: OdooSettings):
values = body.model_dump(exclude_none=True)
result = config.update_settings("odoo", values)
await broadcast("config_changed", {"section": "odoo"})
return result
@router.put("/settings/refresh")
async def update_refresh(body: RefreshSettings):
values = body.model_dump(exclude_none=True)
result = config.update_settings("refresh", values)
await broadcast("config_changed", {"section": "refresh"})
return result
# ── Node endpoints ──────────────────────────────────────────
@router.get("/nodes")
async def get_nodes():
return config.get_nodes()
@router.post("/nodes", status_code=201)
async def add_node(body: NodeModel):
nodes = config.get_nodes()
for n in nodes:
if n["ip"] == body.ip:
raise HTTPException(400, f"Node with IP {body.ip} already exists")
nodes.append(body.model_dump(exclude_none=True))
config.save_nodes(nodes)
await broadcast("config_changed", {"section": "nodes"})
return body.model_dump(exclude_none=True)
@router.put("/nodes")
async def replace_nodes(body: list[NodeModel]):
nodes = [n.model_dump(exclude_none=True) for n in body]
config.save_nodes(nodes)
await broadcast("config_changed", {"section": "nodes"})
return nodes
@router.delete("/nodes/{ip}")
async def delete_node(ip: str):
nodes = config.get_nodes()
original_len = len(nodes)
nodes = [n for n in nodes if n.get("ip") != ip]
if len(nodes) == original_len:
raise HTTPException(404, f"Node with IP {ip} not found")
config.save_nodes(nodes)
await broadcast("config_changed", {"section": "nodes"})
return {"deleted": ip}

View File

@@ -1,3 +1,5 @@
import asyncio
from fastapi import APIRouter, Depends
from modules.config_manager import ConfigManager
from modules.network_scanner import NetworkScanner
@@ -19,7 +21,7 @@ async def get_topology(config: ConfigManager = Depends(get_config)):
if scan_config.get("enabled", False):
try:
scan_data = scanner.scan()
scan_data = await asyncio.to_thread(scanner.scan)
discovered = scanner.parse_scan_results(scan_data)
nodes = scanner.merge_with_config(discovered, configured_nodes)
except Exception: