- 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>
122 lines
3.6 KiB
Python
122 lines
3.6 KiB
Python
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}
|