Files
Dashboard-CAS/docs/plans/2026-02-15-tv-dashboard-implementation.md
I. Alcaraz Salazar c393f76563 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 <noreply@anthropic.com>
2026-02-15 01:53:42 +00:00

58 KiB

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

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

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

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

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

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

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

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.

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

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

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

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

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

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

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

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

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)

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

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

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

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:

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

from routers import network, tasks, calendar, services, ws
app.include_router(ws.router)

Step 4: Commit

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:

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:

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:

@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

// 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<string, unknown>;
}

Step 5: Verify frontend builds

Run: cd /root/Dashboard/frontend && npm run build Expected: Build succeeds

Step 6: Commit

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

// 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<TopologyData | null>(null);
  const [error, setError] = useState<string | null>(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<TasksData | null>(null);
  const [error, setError] = useState<string | null>(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<CalendarData | null>(null);
  const [error, setError] = useState<string | null>(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<DisplayConfig | null>(null);

  useEffect(() => {
    fetch("/api/services/config")
      .then((r) => r.json())
      .then((d) => setConfig(d.display))
      .catch(() => {});
  }, []);

  return config;
}

Step 2: Create useWebSocket hook

// 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<WebSocket | null>(null);
  const reconnectTimeout = useRef<ReturnType<typeof setTimeout>>();

  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

// 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

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

// 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 (
    <header className="flex items-center justify-between px-12 py-6 bg-bg-secondary border-b border-border">
      <div className="flex items-center gap-6">
        <h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
        <span className="text-lg text-text-secondary">{viewName}</span>
      </div>
      <div className="flex items-center gap-8">
        <span className="text-lg text-text-secondary capitalize">{dateStr}</span>
        <span className="text-2xl font-mono font-bold">{timeStr}</span>
        <span
          className={`w-4 h-4 rounded-full ${connected ? "bg-success" : "bg-danger"}`}
          title={connected ? "Conectado" : "Desconectado"}
        />
      </div>
    </header>
  );
}

Step 2: Create ViewRotator component

// 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 (
    <div className="relative flex-1 overflow-hidden">
      <AnimatePresence mode="wait">
        <motion.div
          key={activeView}
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          transition={{ duration: 0.8 }}
          className="absolute inset-0"
        >
          {children[activeView]}
        </motion.div>
      </AnimatePresence>
    </div>
  );
}

Step 3: Commit

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.

// frontend/src/components/Topology/NodeCard.tsx
import { useState } from "react";
import type { NetworkNode } from "../../types";

const ICON_MAP: Record<string, string> = {
  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 (
    <foreignObject x={x - 120} y={y - 80} width={240} height={180}>
      <div
        className="bg-bg-card border border-border rounded-xl p-4 flex flex-col items-center gap-1 shadow-lg cursor-pointer select-none"
        onClick={() => setShowPassword((prev) => !prev)}
      >
        <span className="text-3xl">{ICON_MAP[node.icon] || "📦"}</span>
        <span className="text-base font-bold text-text-primary truncate w-full text-center">
          {node.name}
        </span>
        <span className="text-sm text-text-secondary font-mono">{node.ip}</span>
        {node.username && (
          <span className="text-sm text-text-secondary">
            {node.username} / {showPassword ? node.password : "••••"}
          </span>
        )}
        {node.public_url && (
          <span className="text-xs text-accent truncate w-full text-center">
            {node.public_url}
          </span>
        )}
        <div className="flex items-center gap-2 mt-1">
          <span className={`w-3 h-3 rounded-full ${statusColor}`} />
          <span className="text-xs text-text-secondary">
            {node.status === "up" ? "Online" : node.status === "down" ? "Offline" : "Unknown"}
          </span>
        </div>
      </div>
    </foreignObject>
  );
}

Step 2: Create NetworkGraph component

// 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<SimNode> {
  source: SimNode;
  target: SimNode;
}

export function NetworkGraph({ nodes }: NetworkGraphProps) {
  const svgRef = useRef<SVGSVGElement>(null);
  const simRef = useRef<d3.Simulation<SimNode, SimLink>>();
  const nodesRef = useRef<SimNode[]>([]);
  const linksRef = useRef<SimLink[]>([]);

  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<SVGLineElement, SimLink>("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 (
    <div className="flex flex-col h-full">
      <svg ref={svgRef} width="3840" height="1900" className="flex-1">
        {nodesRef.current.map((simNode) => (
          <NodeCard
            key={simNode.id}
            node={simNode.data}
            x={simNode.x || 0}
            y={simNode.y || 0}
          />
        ))}
      </svg>
      <div className="flex items-center justify-center gap-8 py-4 bg-bg-secondary border-t border-border text-lg">
        <span className="flex items-center gap-2">
          <span className="w-4 h-4 rounded-full bg-success" /> {onlineCount} online
        </span>
        <span className="flex items-center gap-2">
          <span className="w-4 h-4 rounded-full bg-danger" /> {offlineCount} offline
        </span>
      </div>
    </div>
  );
}

Step 3: Commit

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

// frontend/src/components/Tasks/TaskCard.tsx
import type { OdooTask } from "../../types";

const PRIORITY_COLORS: Record<string, string> = {
  "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 (
    <div className={`bg-bg-card border-l-4 ${borderColor} rounded-lg p-4 mb-3`}>
      <p className="text-base font-medium text-text-primary leading-tight">{task.name}</p>
      <div className="flex items-center justify-between mt-2">
        {task.deadline && (
          <span className="text-sm text-text-secondary">{task.deadline}</span>
        )}
      </div>
    </div>
  );
}

Step 2: Create KanbanBoard component

// 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<string>();
  for (const project of projects) {
    for (const stage of Object.keys(project.stages)) {
      allStages.add(stage);
    }
  }
  const stageList = Array.from(allStages);

  return (
    <div className="flex flex-col h-full p-8 overflow-hidden">
      {/* Column headers */}
      <div className="grid gap-4 mb-6" style={{ gridTemplateColumns: `200px repeat(${stageList.length}, 1fr)` }}>
        <div className="text-lg font-bold text-text-secondary uppercase tracking-wider">
          Proyecto
        </div>
        {stageList.map((stage) => (
          <div key={stage} className="text-lg font-bold text-text-secondary uppercase tracking-wider text-center">
            {stage}
          </div>
        ))}
      </div>

      {/* Project rows */}
      <div className="flex-1 overflow-auto space-y-4">
        {projects.map((project) => (
          <div
            key={project.id}
            className="grid gap-4 bg-bg-secondary rounded-xl p-4 border border-border"
            style={{ gridTemplateColumns: `200px repeat(${stageList.length}, 1fr)` }}
          >
            <div className="flex items-start">
              <span className="text-xl font-bold text-text-primary">{project.name}</span>
            </div>
            {stageList.map((stage) => (
              <div key={stage} className="space-y-2 min-h-[80px]">
                {(project.stages[stage] || []).map((task) => (
                  <TaskCard key={task.id} task={task} />
                ))}
              </div>
            ))}
          </div>
        ))}
      </div>
    </div>
  );
}

Step 3: Commit

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

// 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 (
    <div className="flex items-start gap-6 bg-bg-card rounded-xl p-6 border border-border">
      <div className="text-2xl font-mono font-bold text-accent min-w-[140px]">
        {startTime}
        <span className="text-text-secondary text-lg"> - {endTime}</span>
      </div>
      <div className="flex-1">
        <h3 className="text-xl font-semibold text-text-primary">{event.name}</h3>
        {event.location && (
          <p className="text-base text-text-secondary mt-1">{event.location}</p>
        )}
      </div>
    </div>
  );
}

Step 2: Create CalendarView component

// 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 (
    <div className="flex flex-col h-full p-12 overflow-hidden">
      {/* Today */}
      <section className="mb-10">
        <h2 className="text-3xl font-bold text-text-primary mb-6">
          Hoy  {formatDate(today)}
        </h2>
        {todayEvents.length === 0 ? (
          <p className="text-xl text-text-secondary">Sin eventos programados</p>
        ) : (
          <div className="space-y-4">
            {todayEvents.map((e) => (
              <EventCard key={e.id} event={e} />
            ))}
          </div>
        )}
      </section>

      {/* Tomorrow */}
      <section className="mb-10">
        <h2 className="text-2xl font-bold text-text-primary mb-4">
          Manana  {formatDate(tomorrow)}
        </h2>
        {tomorrowEvents.length === 0 ? (
          <p className="text-lg text-text-secondary">Sin eventos programados</p>
        ) : (
          <div className="space-y-4">
            {tomorrowEvents.map((e) => (
              <EventCard key={e.id} event={e} />
            ))}
          </div>
        )}
      </section>

      {/* This week */}
      {laterEvents.length > 0 && (
        <section>
          <h2 className="text-2xl font-bold text-text-primary mb-4">Esta semana</h2>
          <div className="space-y-3">
            {laterEvents.map((e) => (
              <div key={e.id} className="flex gap-4 text-lg text-text-secondary">
                <span className="font-mono min-w-[200px] capitalize">
                  {formatDate(e.start)}
                </span>
                <span className="text-text-primary">{e.name}</span>
              </div>
            ))}
          </div>
        </section>
      )}
    </div>
  );
}

Step 3: Commit

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

// 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 (
    <div className="flex flex-col w-[3840px] h-[2160px]">
      <Header viewName={VIEW_NAMES[activeView]} connected={connected} />
      <ViewRotator activeView={activeView}>
        <div className="h-full">
          {topology.data ? (
            <NetworkGraph nodes={topology.data.nodes} />
          ) : (
            <LoadingScreen label="Cargando topologia..." />
          )}
        </div>
        <div className="h-full">
          {tasks.data ? (
            <KanbanBoard projects={tasks.data.projects} />
          ) : (
            <LoadingScreen label="Cargando tareas..." />
          )}
        </div>
        <div className="h-full">
          {calendar.data ? (
            <CalendarView events={calendar.data.events} />
          ) : (
            <LoadingScreen label="Cargando calendario..." />
          )}
        </div>
      </ViewRotator>
    </div>
  );
}

function LoadingScreen({ label }: { label: string }) {
  return (
    <div className="flex items-center justify-center h-full">
      <p className="text-2xl text-text-secondary animate-pulse">{label}</p>
    </div>
  );
}

export default App;

Step 2: Clean up main.tsx

// 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(
  <StrictMode>
    <App />
  </StrictMode>
);

Step 3: Verify build

Run: cd /root/Dashboard/frontend && npm run build Expected: Build succeeds with no errors

Step 4: Commit

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

# 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

# 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
# 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

# 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:

# 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

# 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

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

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

cd /root/Dashboard/frontend
npm install
npm run build

Expected: Build succeeds, dist/ directory created.

Step 3: Verify Docker Compose builds

cd /root/Dashboard
docker compose build

Expected: Both images build successfully.

Step 4: Final commit with any fixes

git add -A
git status
# Only commit if there are changes
git commit -m "chore: final adjustments from end-to-end verification"