From c393f765630d0c524d9322be2be94e8db4551a43 Mon Sep 17 00:00:00 2001 From: "I. Alcaraz Salazar" Date: Sun, 15 Feb 2026 01:53:42 +0000 Subject: [PATCH] Add implementation plan for TV dashboard 15 tasks covering backend modules, frontend components, Docker setup, and end-to-end verification. TDD approach with tests first. Co-Authored-By: Claude Opus 4.6 --- .../2026-02-15-tv-dashboard-implementation.md | 2240 +++++++++++++++++ 1 file changed, 2240 insertions(+) create mode 100644 docs/plans/2026-02-15-tv-dashboard-implementation.md diff --git a/docs/plans/2026-02-15-tv-dashboard-implementation.md b/docs/plans/2026-02-15-tv-dashboard-implementation.md new file mode 100644 index 0000000..14450a9 --- /dev/null +++ b/docs/plans/2026-02-15-tv-dashboard-implementation.md @@ -0,0 +1,2240 @@ +# 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" +```