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:
@@ -85,13 +85,15 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
from routers import network, tasks, calendar, services, ws
|
||||
from routers import network, tasks, calendar, services, ws, admin
|
||||
|
||||
admin.init(app_config)
|
||||
app.include_router(network.router)
|
||||
app.include_router(tasks.router)
|
||||
app.include_router(calendar.router)
|
||||
app.include_router(services.router)
|
||||
app.include_router(ws.router)
|
||||
app.include_router(admin.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -18,6 +19,12 @@ class ConfigManager:
|
||||
with open(p) as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
def _save_yaml(self, path: str, data: dict[str, Any]) -> None:
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||
os.replace(tmp, path)
|
||||
|
||||
def get_settings(self) -> dict[str, Any]:
|
||||
if not self._settings:
|
||||
self._settings = self._load_yaml(self._settings_path)
|
||||
@@ -26,7 +33,7 @@ class ConfigManager:
|
||||
def get_nodes(self) -> list[dict[str, Any]]:
|
||||
if not self._services:
|
||||
self._services = self._load_yaml(self._services_path)
|
||||
return self._services.get("nodes", [])
|
||||
return list(self._services.get("nodes", []))
|
||||
|
||||
def get_node_by_ip(self, ip: str) -> dict[str, Any] | None:
|
||||
for node in self.get_nodes():
|
||||
@@ -39,6 +46,21 @@ class ConfigManager:
|
||||
self._services = self._load_yaml(self._services_path)
|
||||
return self._services.get("network_scan", {})
|
||||
|
||||
def update_settings(self, section: str, values: dict[str, Any]) -> dict[str, Any]:
|
||||
settings = self._load_yaml(self._settings_path)
|
||||
if section not in settings:
|
||||
settings[section] = {}
|
||||
settings[section].update(values)
|
||||
self._save_yaml(self._settings_path, settings)
|
||||
self._settings = {}
|
||||
return settings[section]
|
||||
|
||||
def save_nodes(self, nodes: list[dict[str, Any]]) -> None:
|
||||
services = self._load_yaml(self._services_path)
|
||||
services["nodes"] = nodes
|
||||
self._save_yaml(self._services_path, services)
|
||||
self._services = {}
|
||||
|
||||
def reload(self) -> None:
|
||||
self._settings = {}
|
||||
self._services = {}
|
||||
|
||||
121
backend/routers/admin.py
Normal file
121
backend/routers/admin.py
Normal 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}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user