# TV Dashboard Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Build a full-screen rotating TV dashboard (4K, 3840x2160) that displays network topology with credentials, Odoo 19 project tasks (Kanban), and Odoo Calendar events. **Architecture:** Python FastAPI backend with three modules (Odoo JSON-RPC client, nmap network scanner, YAML config manager) serving a React/Vite/Tailwind frontend. WebSocket for real-time updates. Three full-screen views rotate every 30 seconds with fade transitions. **Tech Stack:** Python 3.11+, FastAPI, python-nmap, React 18, TypeScript, Vite, Tailwind CSS, D3.js, Framer Motion, Docker Compose **Design doc:** `docs/plans/2026-02-15-tv-dashboard-design.md` --- ## Task 1: Backend Project Scaffolding **Files:** - Create: `backend/main.py` - Create: `backend/requirements.txt` - Create: `backend/config/settings.yaml` - Create: `backend/config/services.yaml` - Create: `backend/modules/__init__.py` - Create: `backend/routers/__init__.py` - Create: `backend/config/__init__.py` **Step 1: Create requirements.txt** ``` fastapi==0.115.6 uvicorn[standard]==0.34.0 python-nmap==0.7.1 PyYAML==6.0.2 websockets==14.1 httpx==0.28.1 pydantic==2.10.4 pydantic-settings==2.7.1 ``` **Step 2: Create settings.yaml with defaults** ```yaml display: resolution: "3840x2160" rotation_interval_seconds: 30 transition: "fade" theme: "dark" odoo: url: "http://localhost:8069" database: "odoo" username: "admin" password: "admin" refresh: odoo_minutes: 5 network_minutes: 10 ping_seconds: 60 ``` **Step 3: Create services.yaml with example data** ```yaml nodes: - name: "Router Principal" ip: "192.168.1.1" username: "admin" password: "admin" icon: "router" connections: [] - name: "Servidor Ejemplo" ip: "192.168.1.10" username: "root" password: "password" public_url: "https://ejemplo.com" icon: "server" connections: ["Router Principal"] network_scan: enabled: true subnet: "192.168.1.0/24" interval_minutes: 10 ``` **Step 4: Create main.py with FastAPI app** ```python from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @asynccontextmanager async def lifespan(app: FastAPI): # Startup yield # Shutdown app = FastAPI(title="TV Dashboard API", lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) @app.get("/api/health") async def health(): return {"status": "ok"} ``` **Step 5: Create __init__.py files for packages** Empty files for `backend/modules/__init__.py`, `backend/routers/__init__.py`, `backend/config/__init__.py`. **Step 6: Verify the backend starts** Run: `cd /root/Dashboard/backend && pip install -r requirements.txt && python -m uvicorn main:app --host 0.0.0.0 --port 8000 &` Then: `curl http://localhost:8000/api/health` Expected: `{"status":"ok"}` Kill: `kill %1` **Step 7: Commit** ```bash git add backend/ git commit -m "feat: scaffold backend with FastAPI, config files, and health endpoint" ``` --- ## Task 2: Config Manager Module **Files:** - Create: `backend/modules/config_manager.py` - Create: `backend/tests/__init__.py` - Create: `backend/tests/test_config_manager.py` **Step 1: Write the failing test** ```python import os import tempfile import pytest import yaml from modules.config_manager import ConfigManager @pytest.fixture def sample_settings(tmp_path): settings = { "display": { "resolution": "3840x2160", "rotation_interval_seconds": 30, "transition": "fade", "theme": "dark", }, "odoo": { "url": "http://localhost:8069", "database": "test_db", "username": "admin", "password": "admin", }, "refresh": { "odoo_minutes": 5, "network_minutes": 10, "ping_seconds": 60, }, } path = tmp_path / "settings.yaml" path.write_text(yaml.dump(settings)) return str(path) @pytest.fixture def sample_services(tmp_path): services = { "nodes": [ { "name": "Router", "ip": "192.168.1.1", "username": "admin", "password": "pass", "icon": "router", "connections": [], }, { "name": "Server", "ip": "192.168.1.10", "username": "root", "password": "secret", "public_url": "https://example.com", "icon": "server", "connections": ["Router"], }, ], "network_scan": { "enabled": True, "subnet": "192.168.1.0/24", "interval_minutes": 10, }, } path = tmp_path / "services.yaml" path.write_text(yaml.dump(services)) return str(path) def test_load_settings(sample_settings): cm = ConfigManager(settings_path=sample_settings, services_path="dummy") settings = cm.get_settings() assert settings["odoo"]["database"] == "test_db" assert settings["display"]["rotation_interval_seconds"] == 30 def test_load_services(sample_services): cm = ConfigManager(settings_path="dummy", services_path=sample_services) nodes = cm.get_nodes() assert len(nodes) == 2 assert nodes[0]["name"] == "Router" assert nodes[1]["public_url"] == "https://example.com" def test_get_node_by_ip(sample_services): cm = ConfigManager(settings_path="dummy", services_path=sample_services) node = cm.get_node_by_ip("192.168.1.10") assert node["name"] == "Server" assert cm.get_node_by_ip("10.0.0.1") is None def test_get_network_scan_config(sample_services): cm = ConfigManager(settings_path="dummy", services_path=sample_services) scan = cm.get_network_scan_config() assert scan["enabled"] is True assert scan["subnet"] == "192.168.1.0/24" ``` **Step 2: Run test to verify it fails** Run: `cd /root/Dashboard/backend && python -m pytest tests/test_config_manager.py -v` Expected: FAIL — `ModuleNotFoundError: No module named 'modules.config_manager'` **Step 3: Write implementation** ```python from pathlib import Path from typing import Any import yaml class ConfigManager: def __init__(self, settings_path: str, services_path: str): self._settings_path = settings_path self._services_path = services_path self._settings: dict[str, Any] = {} self._services: dict[str, Any] = {} def _load_yaml(self, path: str) -> dict[str, Any]: p = Path(path) if not p.exists(): return {} with open(p) as f: return yaml.safe_load(f) or {} def get_settings(self) -> dict[str, Any]: if not self._settings: self._settings = self._load_yaml(self._settings_path) return self._settings 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", []) def get_node_by_ip(self, ip: str) -> dict[str, Any] | None: for node in self.get_nodes(): if node.get("ip") == ip: return node return None def get_network_scan_config(self) -> dict[str, Any]: if not self._services: self._services = self._load_yaml(self._services_path) return self._services.get("network_scan", {}) def reload(self) -> None: self._settings = {} self._services = {} ``` **Step 4: Run test to verify it passes** Run: `cd /root/Dashboard/backend && python -m pytest tests/test_config_manager.py -v` Expected: 4 passed **Step 5: Commit** ```bash git add backend/modules/config_manager.py backend/tests/ git commit -m "feat: add config manager module with YAML loading for settings and services" ``` --- ## Task 3: Odoo JSON-RPC Client Module **Files:** - Create: `backend/modules/odoo_client.py` - Create: `backend/tests/test_odoo_client.py` **Step 1: Write the failing test** The Odoo client uses JSON-RPC over HTTP. We mock the HTTP calls for testing. ```python import json import pytest from unittest.mock import AsyncMock, patch, MagicMock from modules.odoo_client import OdooClient @pytest.fixture def odoo(): return OdooClient( url="http://localhost:8069", database="test_db", username="admin", password="admin", ) @pytest.mark.asyncio async def test_authenticate(odoo): mock_response = MagicMock() mock_response.json.return_value = {"jsonrpc": "2.0", "result": 2} mock_response.raise_for_status = MagicMock() with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response): uid = await odoo.authenticate() assert uid == 2 assert odoo.uid == 2 @pytest.mark.asyncio async def test_search_read(odoo): odoo.uid = 2 mock_response = MagicMock() mock_response.json.return_value = { "jsonrpc": "2.0", "result": [ {"id": 1, "name": "Project A"}, {"id": 2, "name": "Project B"}, ], } mock_response.raise_for_status = MagicMock() with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response): results = await odoo.search_read( model="project.project", domain=[("active", "=", True)], fields=["name"], ) assert len(results) == 2 assert results[0]["name"] == "Project A" @pytest.mark.asyncio async def test_get_projects(odoo): odoo.uid = 2 mock_response = MagicMock() mock_response.json.return_value = { "jsonrpc": "2.0", "result": [ {"id": 1, "name": "Web App", "task_count": 5}, ], } mock_response.raise_for_status = MagicMock() with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response): projects = await odoo.get_projects() assert len(projects) == 1 assert projects[0]["name"] == "Web App" @pytest.mark.asyncio async def test_get_tasks(odoo): odoo.uid = 2 mock_response = MagicMock() mock_response.json.return_value = { "jsonrpc": "2.0", "result": [ { "id": 1, "name": "Implement login", "project_id": [1, "Web App"], "stage_id": [1, "In Progress"], "user_ids": [2], "priority": "1", "date_deadline": "2026-02-20", }, ], } mock_response.raise_for_status = MagicMock() with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response): tasks = await odoo.get_tasks() assert len(tasks) == 1 assert tasks[0]["name"] == "Implement login" @pytest.mark.asyncio async def test_get_calendar_events(odoo): odoo.uid = 2 mock_response = MagicMock() mock_response.json.return_value = { "jsonrpc": "2.0", "result": [ { "id": 1, "name": "Standup", "start": "2026-02-15 09:00:00", "stop": "2026-02-15 09:30:00", "location": "Sala de juntas", }, ], } mock_response.raise_for_status = MagicMock() with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response): events = await odoo.get_calendar_events( date_from="2026-02-15", date_to="2026-02-22", ) assert len(events) == 1 assert events[0]["name"] == "Standup" ``` **Step 2: Run test to verify it fails** Run: `cd /root/Dashboard/backend && pip install pytest-asyncio && python -m pytest tests/test_odoo_client.py -v` Expected: FAIL — `ModuleNotFoundError: No module named 'modules.odoo_client'` **Step 3: Write implementation** ```python from typing import Any import httpx class OdooClient: def __init__(self, url: str, database: str, username: str, password: str): self.url = url.rstrip("/") self.database = database self.username = username self.password = password self.uid: int | None = None async def _jsonrpc(self, endpoint: str, params: dict[str, Any]) -> Any: payload = { "jsonrpc": "2.0", "method": "call", "params": params, "id": 1, } async with httpx.AsyncClient() as client: response = await client.post( f"{self.url}{endpoint}", json=payload, timeout=30.0, ) response.raise_for_status() result = response.json() if "error" in result: raise Exception(f"Odoo error: {result['error']}") return result.get("result") async def authenticate(self) -> int: self.uid = await self._jsonrpc("/web/session/authenticate", { "db": self.database, "login": self.username, "password": self.password, }) return self.uid async def search_read( self, model: str, domain: list | None = None, fields: list[str] | None = None, limit: int = 0, order: str = "", ) -> list[dict[str, Any]]: return await self._jsonrpc("/web/dataset/call_kw", { "model": model, "method": "search_read", "args": [domain or []], "kwargs": { "fields": fields or [], "limit": limit, "order": order, }, }) async def get_projects(self) -> list[dict[str, Any]]: return await self.search_read( model="project.project", domain=[("active", "=", True)], fields=["name", "task_count", "color"], ) async def get_tasks(self, project_id: int | None = None) -> list[dict[str, Any]]: domain: list = [("active", "=", True)] if project_id: domain.append(("project_id", "=", project_id)) return await self.search_read( model="project.task", domain=domain, fields=[ "name", "project_id", "stage_id", "user_ids", "priority", "date_deadline", "kanban_state", ], ) async def get_calendar_events( self, date_from: str, date_to: str ) -> list[dict[str, Any]]: return await self.search_read( model="calendar.event", domain=[ ("start", ">=", f"{date_from} 00:00:00"), ("start", "<=", f"{date_to} 23:59:59"), ], fields=["name", "start", "stop", "location", "description", "partner_ids"], order="start asc", ) ``` **Step 4: Run test to verify it passes** Run: `cd /root/Dashboard/backend && python -m pytest tests/test_odoo_client.py -v` Expected: 5 passed **Step 5: Commit** ```bash git add backend/modules/odoo_client.py backend/tests/test_odoo_client.py git commit -m "feat: add Odoo JSON-RPC client with projects, tasks, and calendar methods" ``` --- ## Task 4: Network Scanner Module **Files:** - Create: `backend/modules/network_scanner.py` - Create: `backend/tests/test_network_scanner.py` **Step 1: Write the failing test** ```python import pytest from unittest.mock import MagicMock, patch from modules.network_scanner import NetworkScanner @pytest.fixture def scanner(): return NetworkScanner(subnet="192.168.1.0/24") def test_parse_scan_results(scanner): mock_scan_result = { "scan": { "192.168.1.1": { "status": {"state": "up"}, "hostnames": [{"name": "router.local", "type": "PTR"}], "vendor": {"AA:BB:CC:DD:EE:FF": "Cisco"}, }, "192.168.1.10": { "status": {"state": "up"}, "hostnames": [{"name": "", "type": ""}], "vendor": {}, }, } } nodes = scanner.parse_scan_results(mock_scan_result) assert len(nodes) == 2 assert nodes[0]["ip"] == "192.168.1.1" assert nodes[0]["hostname"] == "router.local" assert nodes[0]["status"] == "up" assert nodes[1]["ip"] == "192.168.1.10" def test_merge_with_config(scanner): discovered = [ {"ip": "192.168.1.1", "hostname": "router.local", "status": "up", "vendor": "Cisco"}, {"ip": "192.168.1.50", "hostname": "", "status": "up", "vendor": ""}, ] configured = [ { "name": "Router Principal", "ip": "192.168.1.1", "username": "admin", "password": "pass", "icon": "router", "connections": [], }, ] merged = scanner.merge_with_config(discovered, configured) # Router should have config data merged in router = next(n for n in merged if n["ip"] == "192.168.1.1") assert router["name"] == "Router Principal" assert router["username"] == "admin" assert router["status"] == "up" # Unknown device keeps discovered data unknown = next(n for n in merged if n["ip"] == "192.168.1.50") assert unknown["name"] == "192.168.1.50" assert unknown.get("username") is None @pytest.mark.asyncio async def test_ping_host(scanner): with patch("asyncio.create_subprocess_exec") as mock_exec: mock_proc = MagicMock() mock_proc.wait = MagicMock(return_value=0) mock_exec.return_value = mock_proc result = await scanner.ping_host("192.168.1.1") assert result is True ``` **Step 2: Run test to verify it fails** Run: `cd /root/Dashboard/backend && python -m pytest tests/test_network_scanner.py -v` Expected: FAIL **Step 3: Write implementation** ```python import asyncio import subprocess from typing import Any class NetworkScanner: def __init__(self, subnet: str): self.subnet = subnet def scan(self) -> dict[str, Any]: import nmap nm = nmap.PortScanner() nm.scan(hosts=self.subnet, arguments="-sn") return {"scan": dict(nm._scan_result.get("scan", {}))} def parse_scan_results(self, scan_data: dict[str, Any]) -> list[dict[str, Any]]: nodes = [] for ip, data in scan_data.get("scan", {}).items(): hostnames = data.get("hostnames", [{}]) hostname = hostnames[0].get("name", "") if hostnames else "" vendors = data.get("vendor", {}) vendor = next(iter(vendors.values()), "") if vendors else "" nodes.append({ "ip": ip, "hostname": hostname, "status": data.get("status", {}).get("state", "unknown"), "vendor": vendor, }) nodes.sort(key=lambda n: tuple(int(p) for p in n["ip"].split("."))) return nodes def merge_with_config( self, discovered: list[dict[str, Any]], configured: list[dict[str, Any]], ) -> list[dict[str, Any]]: config_by_ip = {n["ip"]: n for n in configured} merged = [] seen_ips = set() for node in discovered: ip = node["ip"] seen_ips.add(ip) if ip in config_by_ip: entry = {**config_by_ip[ip], **{"status": node["status"], "auto_discovered": True}} else: entry = { "name": node["hostname"] or ip, "ip": ip, "status": node["status"], "vendor": node.get("vendor", ""), "icon": "device", "auto_discovered": True, "connections": [], } merged.append(entry) for node in configured: if node["ip"] not in seen_ips: merged.append({**node, "status": "unknown", "auto_discovered": False}) return merged async def ping_host(self, ip: str) -> bool: try: proc = await asyncio.create_subprocess_exec( "ping", "-c", "1", "-W", "1", ip, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) code = await proc.wait() return code == 0 except Exception: return False async def ping_all(self, ips: list[str]) -> dict[str, bool]: results = await asyncio.gather(*[self.ping_host(ip) for ip in ips]) return dict(zip(ips, results)) ``` **Step 4: Run test to verify it passes** Run: `cd /root/Dashboard/backend && python -m pytest tests/test_network_scanner.py -v` Expected: 3 passed **Step 5: Commit** ```bash git add backend/modules/network_scanner.py backend/tests/test_network_scanner.py git commit -m "feat: add network scanner module with nmap discovery, config merge, and ping" ``` --- ## Task 5: Backend API Routers **Files:** - Create: `backend/routers/network.py` - Create: `backend/routers/tasks.py` - Create: `backend/routers/calendar.py` - Create: `backend/routers/services.py` - Modify: `backend/main.py` **Step 1: Create network router** ```python from fastapi import APIRouter, Depends from modules.config_manager import ConfigManager from modules.network_scanner import NetworkScanner router = APIRouter(prefix="/api/network", tags=["network"]) def get_config() -> ConfigManager: from main import app_config return app_config @router.get("/topology") async def get_topology(config: ConfigManager = Depends(get_config)): scan_config = config.get_network_scan_config() configured_nodes = config.get_nodes() scanner = NetworkScanner(subnet=scan_config.get("subnet", "192.168.1.0/24")) if scan_config.get("enabled", False): try: scan_data = scanner.scan() discovered = scanner.parse_scan_results(scan_data) nodes = scanner.merge_with_config(discovered, configured_nodes) except Exception: nodes = [{**n, "status": "unknown"} for n in configured_nodes] else: nodes = [{**n, "status": "unknown"} for n in configured_nodes] # Ping all nodes for current status ips = [n["ip"] for n in nodes] statuses = await scanner.ping_all(ips) for node in nodes: node["status"] = "up" if statuses.get(node["ip"], False) else "down" return {"nodes": nodes, "scan_enabled": scan_config.get("enabled", False)} ``` **Step 2: Create tasks router** ```python from fastapi import APIRouter router = APIRouter(prefix="/api/tasks", tags=["tasks"]) @router.get("/by-project") async def get_tasks_by_project(): from main import odoo_client projects = await odoo_client.get_projects() tasks = await odoo_client.get_tasks() # Group tasks by project result = [] for project in projects: project_tasks = [t for t in tasks if t.get("project_id") and t["project_id"][0] == project["id"]] # Group by stage stages: dict[str, list] = {} for task in project_tasks: stage_name = task["stage_id"][1] if task.get("stage_id") else "Sin etapa" stages.setdefault(stage_name, []).append({ "id": task["id"], "name": task["name"], "assigned": task.get("user_ids", []), "priority": task.get("priority", "0"), "deadline": task.get("date_deadline"), "kanban_state": task.get("kanban_state", "normal"), }) result.append({ "id": project["id"], "name": project["name"], "color": project.get("color", 0), "stages": stages, }) return {"projects": result} ``` **Step 3: Create calendar router** ```python from datetime import datetime, timedelta from fastapi import APIRouter, Query router = APIRouter(prefix="/api/calendar", tags=["calendar"]) @router.get("/events") async def get_events( days: int = Query(default=7, ge=1, le=30), ): from main import odoo_client today = datetime.now().strftime("%Y-%m-%d") end = (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d") events = await odoo_client.get_calendar_events(date_from=today, date_to=end) return {"events": events, "date_from": today, "date_to": end} ``` **Step 4: Create services router (config/settings endpoint)** ```python from fastapi import APIRouter router = APIRouter(prefix="/api/services", tags=["services"]) @router.get("/config") async def get_config(): from main import app_config settings = app_config.get_settings() return { "display": settings.get("display", {}), "refresh": settings.get("refresh", {}), } ``` **Step 5: Update main.py to wire everything together** ```python from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from modules.config_manager import ConfigManager from modules.odoo_client import OdooClient CONFIG_DIR = Path(__file__).parent / "config" app_config = ConfigManager( settings_path=str(CONFIG_DIR / "settings.yaml"), services_path=str(CONFIG_DIR / "services.yaml"), ) settings = app_config.get_settings() odoo_settings = settings.get("odoo", {}) odoo_client = OdooClient( url=odoo_settings.get("url", "http://localhost:8069"), database=odoo_settings.get("database", "odoo"), username=odoo_settings.get("username", "admin"), password=odoo_settings.get("password", "admin"), ) @asynccontextmanager async def lifespan(app: FastAPI): # Authenticate with Odoo on startup try: await odoo_client.authenticate() except Exception as e: print(f"Warning: Could not connect to Odoo: {e}") yield app = FastAPI(title="TV Dashboard API", lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) from routers import network, tasks, calendar, services app.include_router(network.router) app.include_router(tasks.router) app.include_router(calendar.router) app.include_router(services.router) @app.get("/api/health") async def health(): return {"status": "ok", "odoo_connected": odoo_client.uid is not None} ``` **Step 6: Commit** ```bash git add backend/routers/ backend/main.py git commit -m "feat: add API routers for network, tasks, calendar, and services" ``` --- ## Task 6: WebSocket for Real-Time Updates **Files:** - Create: `backend/routers/ws.py` - Modify: `backend/main.py` (add ws router + background tasks) **Step 1: Create WebSocket router** ```python import asyncio import json from fastapi import APIRouter, WebSocket, WebSocketDisconnect router = APIRouter() connected_clients: list[WebSocket] = [] @router.websocket("/ws") async def websocket_endpoint(ws: WebSocket): await ws.accept() connected_clients.append(ws) try: while True: await ws.receive_text() # Keep connection alive except WebSocketDisconnect: connected_clients.remove(ws) async def broadcast(event_type: str, data: dict): message = json.dumps({"type": event_type, "data": data}) disconnected = [] for ws in connected_clients: try: await ws.send_text(message) except Exception: disconnected.append(ws) for ws in disconnected: connected_clients.remove(ws) ``` **Step 2: Add background refresh tasks to main.py lifespan** Add this to `main.py` inside the `lifespan` function, after Odoo authentication: ```python import asyncio from routers.ws import broadcast async def refresh_loop(): refresh = app_config.get_settings().get("refresh", {}) odoo_interval = refresh.get("odoo_minutes", 5) * 60 network_interval = refresh.get("network_minutes", 10) * 60 ping_interval = refresh.get("ping_seconds", 60) last_odoo = 0 last_network = 0 last_ping = 0 while True: now = asyncio.get_event_loop().time() if now - last_ping >= ping_interval: try: from modules.network_scanner import NetworkScanner scan_config = app_config.get_network_scan_config() scanner = NetworkScanner(scan_config.get("subnet", "192.168.1.0/24")) nodes = app_config.get_nodes() ips = [n["ip"] for n in nodes] statuses = await scanner.ping_all(ips) await broadcast("ping_update", statuses) except Exception: pass last_ping = now if now - last_odoo >= odoo_interval: try: await broadcast("odoo_refresh", {"trigger": "scheduled"}) except Exception: pass last_odoo = now await asyncio.sleep(10) ``` Add to lifespan startup: `task = asyncio.create_task(refresh_loop())` Add to lifespan shutdown: `task.cancel()` **Step 3: Register ws router in main.py** ```python from routers import network, tasks, calendar, services, ws app.include_router(ws.router) ``` **Step 4: Commit** ```bash git add backend/routers/ws.py backend/main.py git commit -m "feat: add WebSocket for real-time updates with background refresh loop" ``` --- ## Task 7: Frontend Project Scaffolding **Files:** - Create: `frontend/` (via Vite scaffold) - Modify: `frontend/vite.config.ts` - Modify: `frontend/tailwind.config.js` - Create: `frontend/src/types/index.ts` **Step 1: Scaffold React + TypeScript project with Vite** Run: ```bash cd /root/Dashboard npm create vite@latest frontend -- --template react-ts cd frontend npm install npm install -D tailwindcss @tailwindcss/vite npm install d3 @types/d3 framer-motion ``` **Step 2: Configure Vite with Tailwind and API proxy** Update `frontend/vite.config.ts`: ```typescript import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ plugins: [react(), tailwindcss()], server: { host: "0.0.0.0", port: 5173, proxy: { "/api": "http://localhost:8000", "/ws": { target: "ws://localhost:8000", ws: true, }, }, }, }); ``` **Step 3: Configure Tailwind for 4K** Replace `frontend/src/index.css`: ```css @import "tailwindcss"; @theme { --color-bg-primary: #0a0a0f; --color-bg-secondary: #12121a; --color-bg-card: #1a1a2e; --color-border: #2a2a3e; --color-text-primary: #e4e4e7; --color-text-secondary: #a1a1aa; --color-accent: #3b82f6; --color-success: #22c55e; --color-danger: #ef4444; --color-warning: #f59e0b; } html { font-size: 24px; /* Base for 4K — all rem values scale from here */ } body { @apply bg-bg-primary text-text-primary; font-family: "Inter", system-ui, sans-serif; margin: 0; overflow: hidden; width: 3840px; height: 2160px; } ``` **Step 4: Create TypeScript types** ```typescript // frontend/src/types/index.ts export interface NetworkNode { name: string; ip: string; username?: string; password?: string; public_url?: string; icon: string; status: "up" | "down" | "unknown"; connections: string[]; auto_discovered?: boolean; vendor?: string; } export interface TopologyData { nodes: NetworkNode[]; scan_enabled: boolean; } export interface OdooTask { id: number; name: string; assigned: number[]; priority: string; deadline: string | null; kanban_state: string; } export interface ProjectStages { [stageName: string]: OdooTask[]; } export interface Project { id: number; name: string; color: number; stages: ProjectStages; } export interface TasksData { projects: Project[]; } export interface CalendarEvent { id: number; name: string; start: string; stop: string; location?: string; description?: string; } export interface CalendarData { events: CalendarEvent[]; date_from: string; date_to: string; } export interface DisplayConfig { resolution: string; rotation_interval_seconds: number; transition: string; theme: string; } export interface WSMessage { type: string; data: Record; } ``` **Step 5: Verify frontend builds** Run: `cd /root/Dashboard/frontend && npm run build` Expected: Build succeeds **Step 6: Commit** ```bash git add frontend/ git commit -m "feat: scaffold frontend with Vite, React, Tailwind 4K config, and TypeScript types" ``` --- ## Task 8: Frontend Hooks (Data Fetching + WebSocket + Rotation) **Files:** - Create: `frontend/src/hooks/useOdooData.ts` - Create: `frontend/src/hooks/useWebSocket.ts` - Create: `frontend/src/hooks/useRotation.ts` **Step 1: Create useOdooData hook** ```typescript // frontend/src/hooks/useOdooData.ts import { useState, useEffect, useCallback } from "react"; import type { TopologyData, TasksData, CalendarData, DisplayConfig } from "../types"; export function useTopology(refreshMs: number = 600_000) { const [data, setData] = useState(null); const [error, setError] = useState(null); const fetch_ = useCallback(async () => { try { const res = await fetch("/api/network/topology"); if (!res.ok) throw new Error(`HTTP ${res.status}`); setData(await res.json()); setError(null); } catch (e) { setError(e instanceof Error ? e.message : "Unknown error"); } }, []); useEffect(() => { fetch_(); const id = setInterval(fetch_, refreshMs); return () => clearInterval(id); }, [fetch_, refreshMs]); return { data, error, refetch: fetch_ }; } export function useTasks(refreshMs: number = 300_000) { const [data, setData] = useState(null); const [error, setError] = useState(null); const fetch_ = useCallback(async () => { try { const res = await fetch("/api/tasks/by-project"); if (!res.ok) throw new Error(`HTTP ${res.status}`); setData(await res.json()); setError(null); } catch (e) { setError(e instanceof Error ? e.message : "Unknown error"); } }, []); useEffect(() => { fetch_(); const id = setInterval(fetch_, refreshMs); return () => clearInterval(id); }, [fetch_, refreshMs]); return { data, error, refetch: fetch_ }; } export function useCalendar(refreshMs: number = 300_000) { const [data, setData] = useState(null); const [error, setError] = useState(null); const fetch_ = useCallback(async () => { try { const res = await fetch("/api/calendar/events"); if (!res.ok) throw new Error(`HTTP ${res.status}`); setData(await res.json()); setError(null); } catch (e) { setError(e instanceof Error ? e.message : "Unknown error"); } }, []); useEffect(() => { fetch_(); const id = setInterval(fetch_, refreshMs); return () => clearInterval(id); }, [fetch_, refreshMs]); return { data, error, refetch: fetch_ }; } export function useDisplayConfig() { const [config, setConfig] = useState(null); useEffect(() => { fetch("/api/services/config") .then((r) => r.json()) .then((d) => setConfig(d.display)) .catch(() => {}); }, []); return config; } ``` **Step 2: Create useWebSocket hook** ```typescript // frontend/src/hooks/useWebSocket.ts import { useEffect, useRef, useCallback } from "react"; import type { WSMessage } from "../types"; export function useWebSocket(onMessage: (msg: WSMessage) => void) { const wsRef = useRef(null); const reconnectTimeout = useRef>(); const connect = useCallback(() => { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const ws = new WebSocket(`${protocol}//${window.location.host}/ws`); ws.onmessage = (event) => { try { const msg: WSMessage = JSON.parse(event.data); onMessage(msg); } catch {} }; ws.onclose = () => { reconnectTimeout.current = setTimeout(connect, 3000); }; ws.onerror = () => ws.close(); wsRef.current = ws; }, [onMessage]); useEffect(() => { connect(); return () => { clearTimeout(reconnectTimeout.current); wsRef.current?.close(); }; }, [connect]); } ``` **Step 3: Create useRotation hook** ```typescript // frontend/src/hooks/useRotation.ts import { useState, useEffect } from "react"; export function useRotation(totalViews: number, intervalMs: number = 30_000) { const [activeView, setActiveView] = useState(0); useEffect(() => { const id = setInterval(() => { setActiveView((prev) => (prev + 1) % totalViews); }, intervalMs); return () => clearInterval(id); }, [totalViews, intervalMs]); return activeView; } ``` **Step 4: Commit** ```bash git add frontend/src/hooks/ git commit -m "feat: add React hooks for data fetching, WebSocket, and view rotation" ``` --- ## Task 9: Layout Components (Header + ViewRotator) **Files:** - Create: `frontend/src/components/Layout/Header.tsx` - Create: `frontend/src/components/Layout/ViewRotator.tsx` **Step 1: Create Header component** ```typescript // frontend/src/components/Layout/Header.tsx import { useState, useEffect } from "react"; interface HeaderProps { viewName: string; connected: boolean; } export function Header({ viewName, connected }: HeaderProps) { const [time, setTime] = useState(new Date()); useEffect(() => { const id = setInterval(() => setTime(new Date()), 1000); return () => clearInterval(id); }, []); const dateStr = time.toLocaleDateString("es-MX", { weekday: "long", year: "numeric", month: "long", day: "numeric", }); const timeStr = time.toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit", second: "2-digit", }); return (

Dashboard

{viewName}
{dateStr} {timeStr}
); } ``` **Step 2: Create ViewRotator component** ```typescript // frontend/src/components/Layout/ViewRotator.tsx import { type ReactNode } from "react"; import { AnimatePresence, motion } from "framer-motion"; interface ViewRotatorProps { activeView: number; children: ReactNode[]; } export function ViewRotator({ activeView, children }: ViewRotatorProps) { return (
{children[activeView]}
); } ``` **Step 3: Commit** ```bash git add frontend/src/components/Layout/ git commit -m "feat: add Header with clock and ViewRotator with fade transitions" ``` --- ## Task 10: Network Topology View (D3.js Graph) **Files:** - Create: `frontend/src/components/Topology/NodeCard.tsx` - Create: `frontend/src/components/Topology/NetworkGraph.tsx` **Step 1: Create NodeCard component** This is the tooltip/card that shows node details when hovering. For the TV, nodes show info directly. ```typescript // frontend/src/components/Topology/NodeCard.tsx import { useState } from "react"; import type { NetworkNode } from "../../types"; const ICON_MAP: Record = { router: "🌐", server: "🖥️", switch: "🔀", ap: "📡", pc: "💻", nas: "💾", device: "📱", }; interface NodeCardProps { node: NetworkNode; x: number; y: number; } export function NodeCard({ node, x, y }: NodeCardProps) { const [showPassword, setShowPassword] = useState(false); const statusColor = node.status === "up" ? "bg-success" : node.status === "down" ? "bg-danger" : "bg-warning"; return (
setShowPassword((prev) => !prev)} > {ICON_MAP[node.icon] || "📦"} {node.name} {node.ip} {node.username && ( {node.username} / {showPassword ? node.password : "••••"} )} {node.public_url && ( {node.public_url} )}
{node.status === "up" ? "Online" : node.status === "down" ? "Offline" : "Unknown"}
); } ``` **Step 2: Create NetworkGraph component** ```typescript // frontend/src/components/Topology/NetworkGraph.tsx import { useEffect, useRef } from "react"; import * as d3 from "d3"; import { NodeCard } from "./NodeCard"; import type { NetworkNode } from "../../types"; interface NetworkGraphProps { nodes: NetworkNode[]; } interface SimNode extends d3.SimulationNodeDatum { id: string; data: NetworkNode; } interface SimLink extends d3.SimulationLinkDatum { source: SimNode; target: SimNode; } export function NetworkGraph({ nodes }: NetworkGraphProps) { const svgRef = useRef(null); const simRef = useRef>(); const nodesRef = useRef([]); const linksRef = useRef([]); useEffect(() => { if (!svgRef.current || nodes.length === 0) return; const width = 3840; const height = 1900; // minus header/footer const simNodes: SimNode[] = nodes.map((n) => ({ id: n.ip, data: n, x: width / 2 + (Math.random() - 0.5) * 800, y: height / 2 + (Math.random() - 0.5) * 600, })); const nodeMap = new Map(simNodes.map((n) => [n.data.name, n])); const simLinks: SimLink[] = []; for (const node of nodes) { for (const connName of node.connections || []) { const target = nodeMap.get(connName); const source = nodeMap.get(node.name); if (source && target) { simLinks.push({ source, target }); } } } nodesRef.current = simNodes; linksRef.current = simLinks; const sim = d3 .forceSimulation(simNodes) .force("link", d3.forceLink(simLinks).id((d: any) => d.id).distance(350)) .force("charge", d3.forceManyBody().strength(-2000)) .force("center", d3.forceCenter(width / 2, height / 2)) .force("collision", d3.forceCollide(150)) .on("tick", () => { // Force re-render by updating refs const svg = d3.select(svgRef.current); svg .selectAll("line.link") .data(simLinks) .join("line") .attr("class", "link") .attr("x1", (d) => d.source.x!) .attr("y1", (d) => d.source.y!) .attr("x2", (d) => d.target.x!) .attr("y2", (d) => d.target.y!) .attr("stroke", "#2a2a3e") .attr("stroke-width", 3); }); simRef.current = sim; return () => { sim.stop(); }; }, [nodes]); const onlineCount = nodes.filter((n) => n.status === "up").length; const offlineCount = nodes.filter((n) => n.status === "down").length; return (
{nodesRef.current.map((simNode) => ( ))}
{onlineCount} online {offlineCount} offline
); } ``` **Step 3: Commit** ```bash git add frontend/src/components/Topology/ git commit -m "feat: add network topology view with D3.js force graph and node cards" ``` --- ## Task 11: Kanban Board View (Odoo Tasks) **Files:** - Create: `frontend/src/components/Tasks/TaskCard.tsx` - Create: `frontend/src/components/Tasks/KanbanBoard.tsx` **Step 1: Create TaskCard component** ```typescript // frontend/src/components/Tasks/TaskCard.tsx import type { OdooTask } from "../../types"; const PRIORITY_COLORS: Record = { "0": "border-border", "1": "border-warning", "2": "border-danger", }; interface TaskCardProps { task: OdooTask; } export function TaskCard({ task }: TaskCardProps) { const borderColor = PRIORITY_COLORS[task.priority] || "border-border"; return (

{task.name}

{task.deadline && ( {task.deadline} )}
); } ``` **Step 2: Create KanbanBoard component** ```typescript // frontend/src/components/Tasks/KanbanBoard.tsx import { TaskCard } from "./TaskCard"; import type { Project } from "../../types"; interface KanbanBoardProps { projects: Project[]; } export function KanbanBoard({ projects }: KanbanBoardProps) { // Collect all unique stage names across projects const allStages = new Set(); for (const project of projects) { for (const stage of Object.keys(project.stages)) { allStages.add(stage); } } const stageList = Array.from(allStages); return (
{/* Column headers */}
Proyecto
{stageList.map((stage) => (
{stage}
))}
{/* Project rows */}
{projects.map((project) => (
{project.name}
{stageList.map((stage) => (
{(project.stages[stage] || []).map((task) => ( ))}
))}
))}
); } ``` **Step 3: Commit** ```bash git add frontend/src/components/Tasks/ git commit -m "feat: add Kanban board view with project rows and task cards" ``` --- ## Task 12: Calendar View **Files:** - Create: `frontend/src/components/Calendar/EventCard.tsx` - Create: `frontend/src/components/Calendar/CalendarView.tsx` **Step 1: Create EventCard component** ```typescript // frontend/src/components/Calendar/EventCard.tsx import type { CalendarEvent } from "../../types"; interface EventCardProps { event: CalendarEvent; } export function EventCard({ event }: EventCardProps) { const startTime = new Date(event.start).toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit", }); const endTime = new Date(event.stop).toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit", }); return (
{startTime} - {endTime}

{event.name}

{event.location && (

{event.location}

)}
); } ``` **Step 2: Create CalendarView component** ```typescript // frontend/src/components/Calendar/CalendarView.tsx import { EventCard } from "./EventCard"; import type { CalendarEvent } from "../../types"; interface CalendarViewProps { events: CalendarEvent[]; } export function CalendarView({ events }: CalendarViewProps) { const now = new Date(); const today = now.toISOString().split("T")[0]; const tomorrow = new Date(now.getTime() + 86400000).toISOString().split("T")[0]; const todayEvents = events.filter((e) => e.start.startsWith(today)); const tomorrowEvents = events.filter((e) => e.start.startsWith(tomorrow)); const laterEvents = events.filter( (e) => !e.start.startsWith(today) && !e.start.startsWith(tomorrow) ); const formatDate = (dateStr: string) => { return new Date(dateStr).toLocaleDateString("es-MX", { weekday: "long", month: "long", day: "numeric", }); }; return (
{/* Today */}

Hoy — {formatDate(today)}

{todayEvents.length === 0 ? (

Sin eventos programados

) : (
{todayEvents.map((e) => ( ))}
)}
{/* Tomorrow */}

Manana — {formatDate(tomorrow)}

{tomorrowEvents.length === 0 ? (

Sin eventos programados

) : (
{tomorrowEvents.map((e) => ( ))}
)}
{/* This week */} {laterEvents.length > 0 && (

Esta semana

{laterEvents.map((e) => (
{formatDate(e.start)} {e.name}
))}
)}
); } ``` **Step 3: Commit** ```bash git add frontend/src/components/Calendar/ git commit -m "feat: add calendar view with today, tomorrow, and weekly sections" ``` --- ## Task 13: Wire Up App.tsx (Main Application) **Files:** - Modify: `frontend/src/App.tsx` - Modify: `frontend/src/main.tsx` **Step 1: Implement App.tsx** ```typescript // frontend/src/App.tsx import { useCallback } from "react"; import { Header } from "./components/Layout/Header"; import { ViewRotator } from "./components/Layout/ViewRotator"; import { NetworkGraph } from "./components/Topology/NetworkGraph"; import { KanbanBoard } from "./components/Tasks/KanbanBoard"; import { CalendarView } from "./components/Calendar/CalendarView"; import { useTopology, useTasks, useCalendar, useDisplayConfig } from "./hooks/useOdooData"; import { useWebSocket } from "./hooks/useWebSocket"; import { useRotation } from "./hooks/useRotation"; import type { WSMessage } from "./types"; const VIEW_NAMES = ["Topologia de Red", "Proyectos Odoo", "Calendario"]; function App() { const config = useDisplayConfig(); const intervalMs = (config?.rotation_interval_seconds ?? 30) * 1000; const activeView = useRotation(3, intervalMs); const topology = useTopology(); const tasks = useTasks(); const calendar = useCalendar(); const handleWsMessage = useCallback( (msg: WSMessage) => { if (msg.type === "ping_update") { topology.refetch(); } else if (msg.type === "odoo_refresh") { tasks.refetch(); calendar.refetch(); } }, [topology, tasks, calendar] ); useWebSocket(handleWsMessage); const connected = !topology.error && !tasks.error && !calendar.error; return (
{topology.data ? ( ) : ( )}
{tasks.data ? ( ) : ( )}
{calendar.data ? ( ) : ( )}
); } function LoadingScreen({ label }: { label: string }) { return (

{label}

); } export default App; ``` **Step 2: Clean up main.tsx** ```typescript // frontend/src/main.tsx import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; import App from "./App"; createRoot(document.getElementById("root")!).render( ); ``` **Step 3: Verify build** Run: `cd /root/Dashboard/frontend && npm run build` Expected: Build succeeds with no errors **Step 4: Commit** ```bash git add frontend/src/App.tsx frontend/src/main.tsx git commit -m "feat: wire up App.tsx with all views, hooks, and rotation logic" ``` --- ## Task 14: Docker Compose Setup **Files:** - Create: `docker-compose.yaml` - Create: `backend/Dockerfile` - Create: `frontend/Dockerfile` - Create: `frontend/nginx.conf` - Create: `.gitignore` **Step 1: Create backend Dockerfile** ```dockerfile # backend/Dockerfile FROM python:3.11-slim RUN apt-get update && apt-get install -y nmap iputils-ping && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] ``` **Step 2: Create frontend Dockerfile + nginx config** ```dockerfile # frontend/Dockerfile FROM node:20-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:alpine COPY --from=build /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 ``` ```nginx # frontend/nginx.conf server { listen 80; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://backend:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /ws { proxy_pass http://backend:8000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; } } ``` **Step 3: Create docker-compose.yaml** ```yaml # docker-compose.yaml services: backend: build: ./backend restart: always volumes: - ./backend/config:/app/config networks: - dashboard # host network needed for nmap and ping to work on local network network_mode: host frontend: build: ./frontend restart: always ports: - "80:80" depends_on: - backend networks: - dashboard networks: dashboard: driver: bridge ``` Note: The backend uses `network_mode: host` so nmap and ping can access the local network. The frontend connects to backend via Docker network. This needs adjustment — we'll use a single network with host mode on backend: ```yaml # docker-compose.yaml (corrected) services: backend: build: ./backend restart: always network_mode: host volumes: - ./backend/config:/app/config frontend: build: ./frontend restart: always ports: - "80:80" extra_hosts: - "backend:host-gateway" ``` **Step 4: Create .gitignore** ```gitignore # Python __pycache__/ *.pyc .venv/ venv/ *.egg-info/ # Node node_modules/ dist/ # IDE .vscode/ .idea/ # OS .DS_Store # Config with secrets (user should copy from example) # backend/config/settings.yaml # backend/config/services.yaml ``` **Step 5: Commit** ```bash git add docker-compose.yaml backend/Dockerfile frontend/Dockerfile frontend/nginx.conf .gitignore git commit -m "feat: add Docker Compose setup with backend, frontend/nginx, and gitignore" ``` --- ## Task 15: End-to-End Verification **Step 1: Verify backend starts and serves API** ```bash cd /root/Dashboard/backend pip install -r requirements.txt python -m uvicorn main:app --host 0.0.0.0 --port 8000 & sleep 2 curl http://localhost:8000/api/health curl http://localhost:8000/api/services/config kill %1 ``` Expected: Health endpoint returns JSON. Config endpoint returns display settings. **Step 2: Verify frontend builds** ```bash cd /root/Dashboard/frontend npm install npm run build ``` Expected: Build succeeds, `dist/` directory created. **Step 3: Verify Docker Compose builds** ```bash cd /root/Dashboard docker compose build ``` Expected: Both images build successfully. **Step 4: Final commit with any fixes** ```bash git add -A git status # Only commit if there are changes git commit -m "chore: final adjustments from end-to-end verification" ```