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