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>
2241 lines
58 KiB
Markdown
2241 lines
58 KiB
Markdown
# TV Dashboard Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Build a full-screen rotating TV dashboard (4K, 3840x2160) that displays network topology with credentials, Odoo 19 project tasks (Kanban), and Odoo Calendar events.
|
|
|
|
**Architecture:** Python FastAPI backend with three modules (Odoo JSON-RPC client, nmap network scanner, YAML config manager) serving a React/Vite/Tailwind frontend. WebSocket for real-time updates. Three full-screen views rotate every 30 seconds with fade transitions.
|
|
|
|
**Tech Stack:** Python 3.11+, FastAPI, python-nmap, React 18, TypeScript, Vite, Tailwind CSS, D3.js, Framer Motion, Docker Compose
|
|
|
|
**Design doc:** `docs/plans/2026-02-15-tv-dashboard-design.md`
|
|
|
|
---
|
|
|
|
## Task 1: Backend Project Scaffolding
|
|
|
|
**Files:**
|
|
- Create: `backend/main.py`
|
|
- Create: `backend/requirements.txt`
|
|
- Create: `backend/config/settings.yaml`
|
|
- Create: `backend/config/services.yaml`
|
|
- Create: `backend/modules/__init__.py`
|
|
- Create: `backend/routers/__init__.py`
|
|
- Create: `backend/config/__init__.py`
|
|
|
|
**Step 1: Create requirements.txt**
|
|
|
|
```
|
|
fastapi==0.115.6
|
|
uvicorn[standard]==0.34.0
|
|
python-nmap==0.7.1
|
|
PyYAML==6.0.2
|
|
websockets==14.1
|
|
httpx==0.28.1
|
|
pydantic==2.10.4
|
|
pydantic-settings==2.7.1
|
|
```
|
|
|
|
**Step 2: Create settings.yaml with defaults**
|
|
|
|
```yaml
|
|
display:
|
|
resolution: "3840x2160"
|
|
rotation_interval_seconds: 30
|
|
transition: "fade"
|
|
theme: "dark"
|
|
|
|
odoo:
|
|
url: "http://localhost:8069"
|
|
database: "odoo"
|
|
username: "admin"
|
|
password: "admin"
|
|
|
|
refresh:
|
|
odoo_minutes: 5
|
|
network_minutes: 10
|
|
ping_seconds: 60
|
|
```
|
|
|
|
**Step 3: Create services.yaml with example data**
|
|
|
|
```yaml
|
|
nodes:
|
|
- name: "Router Principal"
|
|
ip: "192.168.1.1"
|
|
username: "admin"
|
|
password: "admin"
|
|
icon: "router"
|
|
connections: []
|
|
|
|
- name: "Servidor Ejemplo"
|
|
ip: "192.168.1.10"
|
|
username: "root"
|
|
password: "password"
|
|
public_url: "https://ejemplo.com"
|
|
icon: "server"
|
|
connections: ["Router Principal"]
|
|
|
|
network_scan:
|
|
enabled: true
|
|
subnet: "192.168.1.0/24"
|
|
interval_minutes: 10
|
|
```
|
|
|
|
**Step 4: Create main.py with FastAPI app**
|
|
|
|
```python
|
|
from contextlib import asynccontextmanager
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
# Startup
|
|
yield
|
|
# Shutdown
|
|
|
|
|
|
app = FastAPI(title="TV Dashboard API", lifespan=lifespan)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
@app.get("/api/health")
|
|
async def health():
|
|
return {"status": "ok"}
|
|
```
|
|
|
|
**Step 5: Create __init__.py files for packages**
|
|
|
|
Empty files for `backend/modules/__init__.py`, `backend/routers/__init__.py`, `backend/config/__init__.py`.
|
|
|
|
**Step 6: Verify the backend starts**
|
|
|
|
Run: `cd /root/Dashboard/backend && pip install -r requirements.txt && python -m uvicorn main:app --host 0.0.0.0 --port 8000 &`
|
|
Then: `curl http://localhost:8000/api/health`
|
|
Expected: `{"status":"ok"}`
|
|
Kill: `kill %1`
|
|
|
|
**Step 7: Commit**
|
|
|
|
```bash
|
|
git add backend/
|
|
git commit -m "feat: scaffold backend with FastAPI, config files, and health endpoint"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Config Manager Module
|
|
|
|
**Files:**
|
|
- Create: `backend/modules/config_manager.py`
|
|
- Create: `backend/tests/__init__.py`
|
|
- Create: `backend/tests/test_config_manager.py`
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
```python
|
|
import os
|
|
import tempfile
|
|
import pytest
|
|
import yaml
|
|
from modules.config_manager import ConfigManager
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_settings(tmp_path):
|
|
settings = {
|
|
"display": {
|
|
"resolution": "3840x2160",
|
|
"rotation_interval_seconds": 30,
|
|
"transition": "fade",
|
|
"theme": "dark",
|
|
},
|
|
"odoo": {
|
|
"url": "http://localhost:8069",
|
|
"database": "test_db",
|
|
"username": "admin",
|
|
"password": "admin",
|
|
},
|
|
"refresh": {
|
|
"odoo_minutes": 5,
|
|
"network_minutes": 10,
|
|
"ping_seconds": 60,
|
|
},
|
|
}
|
|
path = tmp_path / "settings.yaml"
|
|
path.write_text(yaml.dump(settings))
|
|
return str(path)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_services(tmp_path):
|
|
services = {
|
|
"nodes": [
|
|
{
|
|
"name": "Router",
|
|
"ip": "192.168.1.1",
|
|
"username": "admin",
|
|
"password": "pass",
|
|
"icon": "router",
|
|
"connections": [],
|
|
},
|
|
{
|
|
"name": "Server",
|
|
"ip": "192.168.1.10",
|
|
"username": "root",
|
|
"password": "secret",
|
|
"public_url": "https://example.com",
|
|
"icon": "server",
|
|
"connections": ["Router"],
|
|
},
|
|
],
|
|
"network_scan": {
|
|
"enabled": True,
|
|
"subnet": "192.168.1.0/24",
|
|
"interval_minutes": 10,
|
|
},
|
|
}
|
|
path = tmp_path / "services.yaml"
|
|
path.write_text(yaml.dump(services))
|
|
return str(path)
|
|
|
|
|
|
def test_load_settings(sample_settings):
|
|
cm = ConfigManager(settings_path=sample_settings, services_path="dummy")
|
|
settings = cm.get_settings()
|
|
assert settings["odoo"]["database"] == "test_db"
|
|
assert settings["display"]["rotation_interval_seconds"] == 30
|
|
|
|
|
|
def test_load_services(sample_services):
|
|
cm = ConfigManager(settings_path="dummy", services_path=sample_services)
|
|
nodes = cm.get_nodes()
|
|
assert len(nodes) == 2
|
|
assert nodes[0]["name"] == "Router"
|
|
assert nodes[1]["public_url"] == "https://example.com"
|
|
|
|
|
|
def test_get_node_by_ip(sample_services):
|
|
cm = ConfigManager(settings_path="dummy", services_path=sample_services)
|
|
node = cm.get_node_by_ip("192.168.1.10")
|
|
assert node["name"] == "Server"
|
|
assert cm.get_node_by_ip("10.0.0.1") is None
|
|
|
|
|
|
def test_get_network_scan_config(sample_services):
|
|
cm = ConfigManager(settings_path="dummy", services_path=sample_services)
|
|
scan = cm.get_network_scan_config()
|
|
assert scan["enabled"] is True
|
|
assert scan["subnet"] == "192.168.1.0/24"
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `cd /root/Dashboard/backend && python -m pytest tests/test_config_manager.py -v`
|
|
Expected: FAIL — `ModuleNotFoundError: No module named 'modules.config_manager'`
|
|
|
|
**Step 3: Write implementation**
|
|
|
|
```python
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
|
|
class ConfigManager:
|
|
def __init__(self, settings_path: str, services_path: str):
|
|
self._settings_path = settings_path
|
|
self._services_path = services_path
|
|
self._settings: dict[str, Any] = {}
|
|
self._services: dict[str, Any] = {}
|
|
|
|
def _load_yaml(self, path: str) -> dict[str, Any]:
|
|
p = Path(path)
|
|
if not p.exists():
|
|
return {}
|
|
with open(p) as f:
|
|
return yaml.safe_load(f) or {}
|
|
|
|
def get_settings(self) -> dict[str, Any]:
|
|
if not self._settings:
|
|
self._settings = self._load_yaml(self._settings_path)
|
|
return self._settings
|
|
|
|
def get_nodes(self) -> list[dict[str, Any]]:
|
|
if not self._services:
|
|
self._services = self._load_yaml(self._services_path)
|
|
return self._services.get("nodes", [])
|
|
|
|
def get_node_by_ip(self, ip: str) -> dict[str, Any] | None:
|
|
for node in self.get_nodes():
|
|
if node.get("ip") == ip:
|
|
return node
|
|
return None
|
|
|
|
def get_network_scan_config(self) -> dict[str, Any]:
|
|
if not self._services:
|
|
self._services = self._load_yaml(self._services_path)
|
|
return self._services.get("network_scan", {})
|
|
|
|
def reload(self) -> None:
|
|
self._settings = {}
|
|
self._services = {}
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `cd /root/Dashboard/backend && python -m pytest tests/test_config_manager.py -v`
|
|
Expected: 4 passed
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add backend/modules/config_manager.py backend/tests/
|
|
git commit -m "feat: add config manager module with YAML loading for settings and services"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Odoo JSON-RPC Client Module
|
|
|
|
**Files:**
|
|
- Create: `backend/modules/odoo_client.py`
|
|
- Create: `backend/tests/test_odoo_client.py`
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
The Odoo client uses JSON-RPC over HTTP. We mock the HTTP calls for testing.
|
|
|
|
```python
|
|
import json
|
|
import pytest
|
|
from unittest.mock import AsyncMock, patch, MagicMock
|
|
from modules.odoo_client import OdooClient
|
|
|
|
|
|
@pytest.fixture
|
|
def odoo():
|
|
return OdooClient(
|
|
url="http://localhost:8069",
|
|
database="test_db",
|
|
username="admin",
|
|
password="admin",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authenticate(odoo):
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"jsonrpc": "2.0", "result": 2}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response):
|
|
uid = await odoo.authenticate()
|
|
assert uid == 2
|
|
assert odoo.uid == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_read(odoo):
|
|
odoo.uid = 2
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"jsonrpc": "2.0",
|
|
"result": [
|
|
{"id": 1, "name": "Project A"},
|
|
{"id": 2, "name": "Project B"},
|
|
],
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response):
|
|
results = await odoo.search_read(
|
|
model="project.project",
|
|
domain=[("active", "=", True)],
|
|
fields=["name"],
|
|
)
|
|
assert len(results) == 2
|
|
assert results[0]["name"] == "Project A"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_projects(odoo):
|
|
odoo.uid = 2
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"jsonrpc": "2.0",
|
|
"result": [
|
|
{"id": 1, "name": "Web App", "task_count": 5},
|
|
],
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response):
|
|
projects = await odoo.get_projects()
|
|
assert len(projects) == 1
|
|
assert projects[0]["name"] == "Web App"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_tasks(odoo):
|
|
odoo.uid = 2
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"jsonrpc": "2.0",
|
|
"result": [
|
|
{
|
|
"id": 1,
|
|
"name": "Implement login",
|
|
"project_id": [1, "Web App"],
|
|
"stage_id": [1, "In Progress"],
|
|
"user_ids": [2],
|
|
"priority": "1",
|
|
"date_deadline": "2026-02-20",
|
|
},
|
|
],
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response):
|
|
tasks = await odoo.get_tasks()
|
|
assert len(tasks) == 1
|
|
assert tasks[0]["name"] == "Implement login"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_calendar_events(odoo):
|
|
odoo.uid = 2
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"jsonrpc": "2.0",
|
|
"result": [
|
|
{
|
|
"id": 1,
|
|
"name": "Standup",
|
|
"start": "2026-02-15 09:00:00",
|
|
"stop": "2026-02-15 09:30:00",
|
|
"location": "Sala de juntas",
|
|
},
|
|
],
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response):
|
|
events = await odoo.get_calendar_events(
|
|
date_from="2026-02-15",
|
|
date_to="2026-02-22",
|
|
)
|
|
assert len(events) == 1
|
|
assert events[0]["name"] == "Standup"
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `cd /root/Dashboard/backend && pip install pytest-asyncio && python -m pytest tests/test_odoo_client.py -v`
|
|
Expected: FAIL — `ModuleNotFoundError: No module named 'modules.odoo_client'`
|
|
|
|
**Step 3: Write implementation**
|
|
|
|
```python
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
|
|
class OdooClient:
|
|
def __init__(self, url: str, database: str, username: str, password: str):
|
|
self.url = url.rstrip("/")
|
|
self.database = database
|
|
self.username = username
|
|
self.password = password
|
|
self.uid: int | None = None
|
|
|
|
async def _jsonrpc(self, endpoint: str, params: dict[str, Any]) -> Any:
|
|
payload = {
|
|
"jsonrpc": "2.0",
|
|
"method": "call",
|
|
"params": params,
|
|
"id": 1,
|
|
}
|
|
async with httpx.AsyncClient() as client:
|
|
response = await client.post(
|
|
f"{self.url}{endpoint}",
|
|
json=payload,
|
|
timeout=30.0,
|
|
)
|
|
response.raise_for_status()
|
|
result = response.json()
|
|
if "error" in result:
|
|
raise Exception(f"Odoo error: {result['error']}")
|
|
return result.get("result")
|
|
|
|
async def authenticate(self) -> int:
|
|
self.uid = await self._jsonrpc("/web/session/authenticate", {
|
|
"db": self.database,
|
|
"login": self.username,
|
|
"password": self.password,
|
|
})
|
|
return self.uid
|
|
|
|
async def search_read(
|
|
self,
|
|
model: str,
|
|
domain: list | None = None,
|
|
fields: list[str] | None = None,
|
|
limit: int = 0,
|
|
order: str = "",
|
|
) -> list[dict[str, Any]]:
|
|
return await self._jsonrpc("/web/dataset/call_kw", {
|
|
"model": model,
|
|
"method": "search_read",
|
|
"args": [domain or []],
|
|
"kwargs": {
|
|
"fields": fields or [],
|
|
"limit": limit,
|
|
"order": order,
|
|
},
|
|
})
|
|
|
|
async def get_projects(self) -> list[dict[str, Any]]:
|
|
return await self.search_read(
|
|
model="project.project",
|
|
domain=[("active", "=", True)],
|
|
fields=["name", "task_count", "color"],
|
|
)
|
|
|
|
async def get_tasks(self, project_id: int | None = None) -> list[dict[str, Any]]:
|
|
domain: list = [("active", "=", True)]
|
|
if project_id:
|
|
domain.append(("project_id", "=", project_id))
|
|
return await self.search_read(
|
|
model="project.task",
|
|
domain=domain,
|
|
fields=[
|
|
"name", "project_id", "stage_id", "user_ids",
|
|
"priority", "date_deadline", "kanban_state",
|
|
],
|
|
)
|
|
|
|
async def get_calendar_events(
|
|
self, date_from: str, date_to: str
|
|
) -> list[dict[str, Any]]:
|
|
return await self.search_read(
|
|
model="calendar.event",
|
|
domain=[
|
|
("start", ">=", f"{date_from} 00:00:00"),
|
|
("start", "<=", f"{date_to} 23:59:59"),
|
|
],
|
|
fields=["name", "start", "stop", "location", "description", "partner_ids"],
|
|
order="start asc",
|
|
)
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `cd /root/Dashboard/backend && python -m pytest tests/test_odoo_client.py -v`
|
|
Expected: 5 passed
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add backend/modules/odoo_client.py backend/tests/test_odoo_client.py
|
|
git commit -m "feat: add Odoo JSON-RPC client with projects, tasks, and calendar methods"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Network Scanner Module
|
|
|
|
**Files:**
|
|
- Create: `backend/modules/network_scanner.py`
|
|
- Create: `backend/tests/test_network_scanner.py`
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
```python
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch
|
|
from modules.network_scanner import NetworkScanner
|
|
|
|
|
|
@pytest.fixture
|
|
def scanner():
|
|
return NetworkScanner(subnet="192.168.1.0/24")
|
|
|
|
|
|
def test_parse_scan_results(scanner):
|
|
mock_scan_result = {
|
|
"scan": {
|
|
"192.168.1.1": {
|
|
"status": {"state": "up"},
|
|
"hostnames": [{"name": "router.local", "type": "PTR"}],
|
|
"vendor": {"AA:BB:CC:DD:EE:FF": "Cisco"},
|
|
},
|
|
"192.168.1.10": {
|
|
"status": {"state": "up"},
|
|
"hostnames": [{"name": "", "type": ""}],
|
|
"vendor": {},
|
|
},
|
|
}
|
|
}
|
|
|
|
nodes = scanner.parse_scan_results(mock_scan_result)
|
|
assert len(nodes) == 2
|
|
assert nodes[0]["ip"] == "192.168.1.1"
|
|
assert nodes[0]["hostname"] == "router.local"
|
|
assert nodes[0]["status"] == "up"
|
|
assert nodes[1]["ip"] == "192.168.1.10"
|
|
|
|
|
|
def test_merge_with_config(scanner):
|
|
discovered = [
|
|
{"ip": "192.168.1.1", "hostname": "router.local", "status": "up", "vendor": "Cisco"},
|
|
{"ip": "192.168.1.50", "hostname": "", "status": "up", "vendor": ""},
|
|
]
|
|
configured = [
|
|
{
|
|
"name": "Router Principal",
|
|
"ip": "192.168.1.1",
|
|
"username": "admin",
|
|
"password": "pass",
|
|
"icon": "router",
|
|
"connections": [],
|
|
},
|
|
]
|
|
|
|
merged = scanner.merge_with_config(discovered, configured)
|
|
# Router should have config data merged in
|
|
router = next(n for n in merged if n["ip"] == "192.168.1.1")
|
|
assert router["name"] == "Router Principal"
|
|
assert router["username"] == "admin"
|
|
assert router["status"] == "up"
|
|
# Unknown device keeps discovered data
|
|
unknown = next(n for n in merged if n["ip"] == "192.168.1.50")
|
|
assert unknown["name"] == "192.168.1.50"
|
|
assert unknown.get("username") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ping_host(scanner):
|
|
with patch("asyncio.create_subprocess_exec") as mock_exec:
|
|
mock_proc = MagicMock()
|
|
mock_proc.wait = MagicMock(return_value=0)
|
|
mock_exec.return_value = mock_proc
|
|
result = await scanner.ping_host("192.168.1.1")
|
|
assert result is True
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `cd /root/Dashboard/backend && python -m pytest tests/test_network_scanner.py -v`
|
|
Expected: FAIL
|
|
|
|
**Step 3: Write implementation**
|
|
|
|
```python
|
|
import asyncio
|
|
import subprocess
|
|
from typing import Any
|
|
|
|
|
|
class NetworkScanner:
|
|
def __init__(self, subnet: str):
|
|
self.subnet = subnet
|
|
|
|
def scan(self) -> dict[str, Any]:
|
|
import nmap
|
|
nm = nmap.PortScanner()
|
|
nm.scan(hosts=self.subnet, arguments="-sn")
|
|
return {"scan": dict(nm._scan_result.get("scan", {}))}
|
|
|
|
def parse_scan_results(self, scan_data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
nodes = []
|
|
for ip, data in scan_data.get("scan", {}).items():
|
|
hostnames = data.get("hostnames", [{}])
|
|
hostname = hostnames[0].get("name", "") if hostnames else ""
|
|
vendors = data.get("vendor", {})
|
|
vendor = next(iter(vendors.values()), "") if vendors else ""
|
|
nodes.append({
|
|
"ip": ip,
|
|
"hostname": hostname,
|
|
"status": data.get("status", {}).get("state", "unknown"),
|
|
"vendor": vendor,
|
|
})
|
|
nodes.sort(key=lambda n: tuple(int(p) for p in n["ip"].split(".")))
|
|
return nodes
|
|
|
|
def merge_with_config(
|
|
self,
|
|
discovered: list[dict[str, Any]],
|
|
configured: list[dict[str, Any]],
|
|
) -> list[dict[str, Any]]:
|
|
config_by_ip = {n["ip"]: n for n in configured}
|
|
merged = []
|
|
seen_ips = set()
|
|
|
|
for node in discovered:
|
|
ip = node["ip"]
|
|
seen_ips.add(ip)
|
|
if ip in config_by_ip:
|
|
entry = {**config_by_ip[ip], **{"status": node["status"], "auto_discovered": True}}
|
|
else:
|
|
entry = {
|
|
"name": node["hostname"] or ip,
|
|
"ip": ip,
|
|
"status": node["status"],
|
|
"vendor": node.get("vendor", ""),
|
|
"icon": "device",
|
|
"auto_discovered": True,
|
|
"connections": [],
|
|
}
|
|
merged.append(entry)
|
|
|
|
for node in configured:
|
|
if node["ip"] not in seen_ips:
|
|
merged.append({**node, "status": "unknown", "auto_discovered": False})
|
|
|
|
return merged
|
|
|
|
async def ping_host(self, ip: str) -> bool:
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"ping", "-c", "1", "-W", "1", ip,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
code = await proc.wait()
|
|
return code == 0
|
|
except Exception:
|
|
return False
|
|
|
|
async def ping_all(self, ips: list[str]) -> dict[str, bool]:
|
|
results = await asyncio.gather(*[self.ping_host(ip) for ip in ips])
|
|
return dict(zip(ips, results))
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `cd /root/Dashboard/backend && python -m pytest tests/test_network_scanner.py -v`
|
|
Expected: 3 passed
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add backend/modules/network_scanner.py backend/tests/test_network_scanner.py
|
|
git commit -m "feat: add network scanner module with nmap discovery, config merge, and ping"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Backend API Routers
|
|
|
|
**Files:**
|
|
- Create: `backend/routers/network.py`
|
|
- Create: `backend/routers/tasks.py`
|
|
- Create: `backend/routers/calendar.py`
|
|
- Create: `backend/routers/services.py`
|
|
- Modify: `backend/main.py`
|
|
|
|
**Step 1: Create network router**
|
|
|
|
```python
|
|
from fastapi import APIRouter, Depends
|
|
from modules.config_manager import ConfigManager
|
|
from modules.network_scanner import NetworkScanner
|
|
|
|
router = APIRouter(prefix="/api/network", tags=["network"])
|
|
|
|
|
|
def get_config() -> ConfigManager:
|
|
from main import app_config
|
|
return app_config
|
|
|
|
|
|
@router.get("/topology")
|
|
async def get_topology(config: ConfigManager = Depends(get_config)):
|
|
scan_config = config.get_network_scan_config()
|
|
configured_nodes = config.get_nodes()
|
|
|
|
scanner = NetworkScanner(subnet=scan_config.get("subnet", "192.168.1.0/24"))
|
|
|
|
if scan_config.get("enabled", False):
|
|
try:
|
|
scan_data = scanner.scan()
|
|
discovered = scanner.parse_scan_results(scan_data)
|
|
nodes = scanner.merge_with_config(discovered, configured_nodes)
|
|
except Exception:
|
|
nodes = [{**n, "status": "unknown"} for n in configured_nodes]
|
|
else:
|
|
nodes = [{**n, "status": "unknown"} for n in configured_nodes]
|
|
|
|
# Ping all nodes for current status
|
|
ips = [n["ip"] for n in nodes]
|
|
statuses = await scanner.ping_all(ips)
|
|
for node in nodes:
|
|
node["status"] = "up" if statuses.get(node["ip"], False) else "down"
|
|
|
|
return {"nodes": nodes, "scan_enabled": scan_config.get("enabled", False)}
|
|
```
|
|
|
|
**Step 2: Create tasks router**
|
|
|
|
```python
|
|
from fastapi import APIRouter
|
|
|
|
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
|
|
|
|
|
@router.get("/by-project")
|
|
async def get_tasks_by_project():
|
|
from main import odoo_client
|
|
projects = await odoo_client.get_projects()
|
|
tasks = await odoo_client.get_tasks()
|
|
|
|
# Group tasks by project
|
|
result = []
|
|
for project in projects:
|
|
project_tasks = [t for t in tasks if t.get("project_id") and t["project_id"][0] == project["id"]]
|
|
# Group by stage
|
|
stages: dict[str, list] = {}
|
|
for task in project_tasks:
|
|
stage_name = task["stage_id"][1] if task.get("stage_id") else "Sin etapa"
|
|
stages.setdefault(stage_name, []).append({
|
|
"id": task["id"],
|
|
"name": task["name"],
|
|
"assigned": task.get("user_ids", []),
|
|
"priority": task.get("priority", "0"),
|
|
"deadline": task.get("date_deadline"),
|
|
"kanban_state": task.get("kanban_state", "normal"),
|
|
})
|
|
result.append({
|
|
"id": project["id"],
|
|
"name": project["name"],
|
|
"color": project.get("color", 0),
|
|
"stages": stages,
|
|
})
|
|
|
|
return {"projects": result}
|
|
```
|
|
|
|
**Step 3: Create calendar router**
|
|
|
|
```python
|
|
from datetime import datetime, timedelta
|
|
from fastapi import APIRouter, Query
|
|
|
|
router = APIRouter(prefix="/api/calendar", tags=["calendar"])
|
|
|
|
|
|
@router.get("/events")
|
|
async def get_events(
|
|
days: int = Query(default=7, ge=1, le=30),
|
|
):
|
|
from main import odoo_client
|
|
today = datetime.now().strftime("%Y-%m-%d")
|
|
end = (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d")
|
|
events = await odoo_client.get_calendar_events(date_from=today, date_to=end)
|
|
return {"events": events, "date_from": today, "date_to": end}
|
|
```
|
|
|
|
**Step 4: Create services router (config/settings endpoint)**
|
|
|
|
```python
|
|
from fastapi import APIRouter
|
|
|
|
router = APIRouter(prefix="/api/services", tags=["services"])
|
|
|
|
|
|
@router.get("/config")
|
|
async def get_config():
|
|
from main import app_config
|
|
settings = app_config.get_settings()
|
|
return {
|
|
"display": settings.get("display", {}),
|
|
"refresh": settings.get("refresh", {}),
|
|
}
|
|
```
|
|
|
|
**Step 5: Update main.py to wire everything together**
|
|
|
|
```python
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
from modules.config_manager import ConfigManager
|
|
from modules.odoo_client import OdooClient
|
|
|
|
CONFIG_DIR = Path(__file__).parent / "config"
|
|
|
|
app_config = ConfigManager(
|
|
settings_path=str(CONFIG_DIR / "settings.yaml"),
|
|
services_path=str(CONFIG_DIR / "services.yaml"),
|
|
)
|
|
|
|
settings = app_config.get_settings()
|
|
odoo_settings = settings.get("odoo", {})
|
|
|
|
odoo_client = OdooClient(
|
|
url=odoo_settings.get("url", "http://localhost:8069"),
|
|
database=odoo_settings.get("database", "odoo"),
|
|
username=odoo_settings.get("username", "admin"),
|
|
password=odoo_settings.get("password", "admin"),
|
|
)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
# Authenticate with Odoo on startup
|
|
try:
|
|
await odoo_client.authenticate()
|
|
except Exception as e:
|
|
print(f"Warning: Could not connect to Odoo: {e}")
|
|
yield
|
|
|
|
|
|
app = FastAPI(title="TV Dashboard API", lifespan=lifespan)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
from routers import network, tasks, calendar, services
|
|
|
|
app.include_router(network.router)
|
|
app.include_router(tasks.router)
|
|
app.include_router(calendar.router)
|
|
app.include_router(services.router)
|
|
|
|
|
|
@app.get("/api/health")
|
|
async def health():
|
|
return {"status": "ok", "odoo_connected": odoo_client.uid is not None}
|
|
```
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add backend/routers/ backend/main.py
|
|
git commit -m "feat: add API routers for network, tasks, calendar, and services"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: WebSocket for Real-Time Updates
|
|
|
|
**Files:**
|
|
- Create: `backend/routers/ws.py`
|
|
- Modify: `backend/main.py` (add ws router + background tasks)
|
|
|
|
**Step 1: Create WebSocket router**
|
|
|
|
```python
|
|
import asyncio
|
|
import json
|
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
|
|
router = APIRouter()
|
|
|
|
connected_clients: list[WebSocket] = []
|
|
|
|
|
|
@router.websocket("/ws")
|
|
async def websocket_endpoint(ws: WebSocket):
|
|
await ws.accept()
|
|
connected_clients.append(ws)
|
|
try:
|
|
while True:
|
|
await ws.receive_text() # Keep connection alive
|
|
except WebSocketDisconnect:
|
|
connected_clients.remove(ws)
|
|
|
|
|
|
async def broadcast(event_type: str, data: dict):
|
|
message = json.dumps({"type": event_type, "data": data})
|
|
disconnected = []
|
|
for ws in connected_clients:
|
|
try:
|
|
await ws.send_text(message)
|
|
except Exception:
|
|
disconnected.append(ws)
|
|
for ws in disconnected:
|
|
connected_clients.remove(ws)
|
|
```
|
|
|
|
**Step 2: Add background refresh tasks to main.py lifespan**
|
|
|
|
Add this to `main.py` inside the `lifespan` function, after Odoo authentication:
|
|
|
|
```python
|
|
import asyncio
|
|
from routers.ws import broadcast
|
|
|
|
async def refresh_loop():
|
|
refresh = app_config.get_settings().get("refresh", {})
|
|
odoo_interval = refresh.get("odoo_minutes", 5) * 60
|
|
network_interval = refresh.get("network_minutes", 10) * 60
|
|
ping_interval = refresh.get("ping_seconds", 60)
|
|
|
|
last_odoo = 0
|
|
last_network = 0
|
|
last_ping = 0
|
|
|
|
while True:
|
|
now = asyncio.get_event_loop().time()
|
|
|
|
if now - last_ping >= ping_interval:
|
|
try:
|
|
from modules.network_scanner import NetworkScanner
|
|
scan_config = app_config.get_network_scan_config()
|
|
scanner = NetworkScanner(scan_config.get("subnet", "192.168.1.0/24"))
|
|
nodes = app_config.get_nodes()
|
|
ips = [n["ip"] for n in nodes]
|
|
statuses = await scanner.ping_all(ips)
|
|
await broadcast("ping_update", statuses)
|
|
except Exception:
|
|
pass
|
|
last_ping = now
|
|
|
|
if now - last_odoo >= odoo_interval:
|
|
try:
|
|
await broadcast("odoo_refresh", {"trigger": "scheduled"})
|
|
except Exception:
|
|
pass
|
|
last_odoo = now
|
|
|
|
await asyncio.sleep(10)
|
|
```
|
|
|
|
Add to lifespan startup: `task = asyncio.create_task(refresh_loop())`
|
|
Add to lifespan shutdown: `task.cancel()`
|
|
|
|
**Step 3: Register ws router in main.py**
|
|
|
|
```python
|
|
from routers import network, tasks, calendar, services, ws
|
|
app.include_router(ws.router)
|
|
```
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add backend/routers/ws.py backend/main.py
|
|
git commit -m "feat: add WebSocket for real-time updates with background refresh loop"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Frontend Project Scaffolding
|
|
|
|
**Files:**
|
|
- Create: `frontend/` (via Vite scaffold)
|
|
- Modify: `frontend/vite.config.ts`
|
|
- Modify: `frontend/tailwind.config.js`
|
|
- Create: `frontend/src/types/index.ts`
|
|
|
|
**Step 1: Scaffold React + TypeScript project with Vite**
|
|
|
|
Run:
|
|
```bash
|
|
cd /root/Dashboard
|
|
npm create vite@latest frontend -- --template react-ts
|
|
cd frontend
|
|
npm install
|
|
npm install -D tailwindcss @tailwindcss/vite
|
|
npm install d3 @types/d3 framer-motion
|
|
```
|
|
|
|
**Step 2: Configure Vite with Tailwind and API proxy**
|
|
|
|
Update `frontend/vite.config.ts`:
|
|
|
|
```typescript
|
|
import { defineConfig } from "vite";
|
|
import react from "@vitejs/plugin-react";
|
|
import tailwindcss from "@tailwindcss/vite";
|
|
|
|
export default defineConfig({
|
|
plugins: [react(), tailwindcss()],
|
|
server: {
|
|
host: "0.0.0.0",
|
|
port: 5173,
|
|
proxy: {
|
|
"/api": "http://localhost:8000",
|
|
"/ws": {
|
|
target: "ws://localhost:8000",
|
|
ws: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
```
|
|
|
|
**Step 3: Configure Tailwind for 4K**
|
|
|
|
Replace `frontend/src/index.css`:
|
|
|
|
```css
|
|
@import "tailwindcss";
|
|
|
|
@theme {
|
|
--color-bg-primary: #0a0a0f;
|
|
--color-bg-secondary: #12121a;
|
|
--color-bg-card: #1a1a2e;
|
|
--color-border: #2a2a3e;
|
|
--color-text-primary: #e4e4e7;
|
|
--color-text-secondary: #a1a1aa;
|
|
--color-accent: #3b82f6;
|
|
--color-success: #22c55e;
|
|
--color-danger: #ef4444;
|
|
--color-warning: #f59e0b;
|
|
}
|
|
|
|
html {
|
|
font-size: 24px; /* Base for 4K — all rem values scale from here */
|
|
}
|
|
|
|
body {
|
|
@apply bg-bg-primary text-text-primary;
|
|
font-family: "Inter", system-ui, sans-serif;
|
|
margin: 0;
|
|
overflow: hidden;
|
|
width: 3840px;
|
|
height: 2160px;
|
|
}
|
|
```
|
|
|
|
**Step 4: Create TypeScript types**
|
|
|
|
```typescript
|
|
// frontend/src/types/index.ts
|
|
|
|
export interface NetworkNode {
|
|
name: string;
|
|
ip: string;
|
|
username?: string;
|
|
password?: string;
|
|
public_url?: string;
|
|
icon: string;
|
|
status: "up" | "down" | "unknown";
|
|
connections: string[];
|
|
auto_discovered?: boolean;
|
|
vendor?: string;
|
|
}
|
|
|
|
export interface TopologyData {
|
|
nodes: NetworkNode[];
|
|
scan_enabled: boolean;
|
|
}
|
|
|
|
export interface OdooTask {
|
|
id: number;
|
|
name: string;
|
|
assigned: number[];
|
|
priority: string;
|
|
deadline: string | null;
|
|
kanban_state: string;
|
|
}
|
|
|
|
export interface ProjectStages {
|
|
[stageName: string]: OdooTask[];
|
|
}
|
|
|
|
export interface Project {
|
|
id: number;
|
|
name: string;
|
|
color: number;
|
|
stages: ProjectStages;
|
|
}
|
|
|
|
export interface TasksData {
|
|
projects: Project[];
|
|
}
|
|
|
|
export interface CalendarEvent {
|
|
id: number;
|
|
name: string;
|
|
start: string;
|
|
stop: string;
|
|
location?: string;
|
|
description?: string;
|
|
}
|
|
|
|
export interface CalendarData {
|
|
events: CalendarEvent[];
|
|
date_from: string;
|
|
date_to: string;
|
|
}
|
|
|
|
export interface DisplayConfig {
|
|
resolution: string;
|
|
rotation_interval_seconds: number;
|
|
transition: string;
|
|
theme: string;
|
|
}
|
|
|
|
export interface WSMessage {
|
|
type: string;
|
|
data: Record<string, unknown>;
|
|
}
|
|
```
|
|
|
|
**Step 5: Verify frontend builds**
|
|
|
|
Run: `cd /root/Dashboard/frontend && npm run build`
|
|
Expected: Build succeeds
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add frontend/
|
|
git commit -m "feat: scaffold frontend with Vite, React, Tailwind 4K config, and TypeScript types"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Frontend Hooks (Data Fetching + WebSocket + Rotation)
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/hooks/useOdooData.ts`
|
|
- Create: `frontend/src/hooks/useWebSocket.ts`
|
|
- Create: `frontend/src/hooks/useRotation.ts`
|
|
|
|
**Step 1: Create useOdooData hook**
|
|
|
|
```typescript
|
|
// frontend/src/hooks/useOdooData.ts
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import type { TopologyData, TasksData, CalendarData, DisplayConfig } from "../types";
|
|
|
|
export function useTopology(refreshMs: number = 600_000) {
|
|
const [data, setData] = useState<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**
|
|
|
|
```typescript
|
|
// frontend/src/hooks/useWebSocket.ts
|
|
import { useEffect, useRef, useCallback } from "react";
|
|
import type { WSMessage } from "../types";
|
|
|
|
export function useWebSocket(onMessage: (msg: WSMessage) => void) {
|
|
const wsRef = useRef<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**
|
|
|
|
```typescript
|
|
// frontend/src/hooks/useRotation.ts
|
|
import { useState, useEffect } from "react";
|
|
|
|
export function useRotation(totalViews: number, intervalMs: number = 30_000) {
|
|
const [activeView, setActiveView] = useState(0);
|
|
|
|
useEffect(() => {
|
|
const id = setInterval(() => {
|
|
setActiveView((prev) => (prev + 1) % totalViews);
|
|
}, intervalMs);
|
|
return () => clearInterval(id);
|
|
}, [totalViews, intervalMs]);
|
|
|
|
return activeView;
|
|
}
|
|
```
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/hooks/
|
|
git commit -m "feat: add React hooks for data fetching, WebSocket, and view rotation"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Layout Components (Header + ViewRotator)
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/components/Layout/Header.tsx`
|
|
- Create: `frontend/src/components/Layout/ViewRotator.tsx`
|
|
|
|
**Step 1: Create Header component**
|
|
|
|
```typescript
|
|
// frontend/src/components/Layout/Header.tsx
|
|
import { useState, useEffect } from "react";
|
|
|
|
interface HeaderProps {
|
|
viewName: string;
|
|
connected: boolean;
|
|
}
|
|
|
|
export function Header({ viewName, connected }: HeaderProps) {
|
|
const [time, setTime] = useState(new Date());
|
|
|
|
useEffect(() => {
|
|
const id = setInterval(() => setTime(new Date()), 1000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
const dateStr = time.toLocaleDateString("es-MX", {
|
|
weekday: "long",
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
});
|
|
|
|
const timeStr = time.toLocaleTimeString("es-MX", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
});
|
|
|
|
return (
|
|
<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**
|
|
|
|
```typescript
|
|
// frontend/src/components/Layout/ViewRotator.tsx
|
|
import { type ReactNode } from "react";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
|
|
interface ViewRotatorProps {
|
|
activeView: number;
|
|
children: ReactNode[];
|
|
}
|
|
|
|
export function ViewRotator({ activeView, children }: ViewRotatorProps) {
|
|
return (
|
|
<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**
|
|
|
|
```bash
|
|
git add frontend/src/components/Layout/
|
|
git commit -m "feat: add Header with clock and ViewRotator with fade transitions"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Network Topology View (D3.js Graph)
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/components/Topology/NodeCard.tsx`
|
|
- Create: `frontend/src/components/Topology/NetworkGraph.tsx`
|
|
|
|
**Step 1: Create NodeCard component**
|
|
|
|
This is the tooltip/card that shows node details when hovering. For the TV, nodes show info directly.
|
|
|
|
```typescript
|
|
// frontend/src/components/Topology/NodeCard.tsx
|
|
import { useState } from "react";
|
|
import type { NetworkNode } from "../../types";
|
|
|
|
const ICON_MAP: Record<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**
|
|
|
|
```typescript
|
|
// frontend/src/components/Topology/NetworkGraph.tsx
|
|
import { useEffect, useRef } from "react";
|
|
import * as d3 from "d3";
|
|
import { NodeCard } from "./NodeCard";
|
|
import type { NetworkNode } from "../../types";
|
|
|
|
interface NetworkGraphProps {
|
|
nodes: NetworkNode[];
|
|
}
|
|
|
|
interface SimNode extends d3.SimulationNodeDatum {
|
|
id: string;
|
|
data: NetworkNode;
|
|
}
|
|
|
|
interface SimLink extends d3.SimulationLinkDatum<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**
|
|
|
|
```bash
|
|
git add frontend/src/components/Topology/
|
|
git commit -m "feat: add network topology view with D3.js force graph and node cards"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Kanban Board View (Odoo Tasks)
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/components/Tasks/TaskCard.tsx`
|
|
- Create: `frontend/src/components/Tasks/KanbanBoard.tsx`
|
|
|
|
**Step 1: Create TaskCard component**
|
|
|
|
```typescript
|
|
// frontend/src/components/Tasks/TaskCard.tsx
|
|
import type { OdooTask } from "../../types";
|
|
|
|
const PRIORITY_COLORS: Record<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**
|
|
|
|
```typescript
|
|
// frontend/src/components/Tasks/KanbanBoard.tsx
|
|
import { TaskCard } from "./TaskCard";
|
|
import type { Project } from "../../types";
|
|
|
|
interface KanbanBoardProps {
|
|
projects: Project[];
|
|
}
|
|
|
|
export function KanbanBoard({ projects }: KanbanBoardProps) {
|
|
// Collect all unique stage names across projects
|
|
const allStages = new Set<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**
|
|
|
|
```bash
|
|
git add frontend/src/components/Tasks/
|
|
git commit -m "feat: add Kanban board view with project rows and task cards"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Calendar View
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/components/Calendar/EventCard.tsx`
|
|
- Create: `frontend/src/components/Calendar/CalendarView.tsx`
|
|
|
|
**Step 1: Create EventCard component**
|
|
|
|
```typescript
|
|
// frontend/src/components/Calendar/EventCard.tsx
|
|
import type { CalendarEvent } from "../../types";
|
|
|
|
interface EventCardProps {
|
|
event: CalendarEvent;
|
|
}
|
|
|
|
export function EventCard({ event }: EventCardProps) {
|
|
const startTime = new Date(event.start).toLocaleTimeString("es-MX", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
const endTime = new Date(event.stop).toLocaleTimeString("es-MX", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
|
|
return (
|
|
<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**
|
|
|
|
```typescript
|
|
// frontend/src/components/Calendar/CalendarView.tsx
|
|
import { EventCard } from "./EventCard";
|
|
import type { CalendarEvent } from "../../types";
|
|
|
|
interface CalendarViewProps {
|
|
events: CalendarEvent[];
|
|
}
|
|
|
|
export function CalendarView({ events }: CalendarViewProps) {
|
|
const now = new Date();
|
|
const today = now.toISOString().split("T")[0];
|
|
const tomorrow = new Date(now.getTime() + 86400000).toISOString().split("T")[0];
|
|
|
|
const todayEvents = events.filter((e) => e.start.startsWith(today));
|
|
const tomorrowEvents = events.filter((e) => e.start.startsWith(tomorrow));
|
|
const laterEvents = events.filter(
|
|
(e) => !e.start.startsWith(today) && !e.start.startsWith(tomorrow)
|
|
);
|
|
|
|
const formatDate = (dateStr: string) => {
|
|
return new Date(dateStr).toLocaleDateString("es-MX", {
|
|
weekday: "long",
|
|
month: "long",
|
|
day: "numeric",
|
|
});
|
|
};
|
|
|
|
return (
|
|
<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**
|
|
|
|
```bash
|
|
git add frontend/src/components/Calendar/
|
|
git commit -m "feat: add calendar view with today, tomorrow, and weekly sections"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: Wire Up App.tsx (Main Application)
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/App.tsx`
|
|
- Modify: `frontend/src/main.tsx`
|
|
|
|
**Step 1: Implement App.tsx**
|
|
|
|
```typescript
|
|
// frontend/src/App.tsx
|
|
import { useCallback } from "react";
|
|
import { Header } from "./components/Layout/Header";
|
|
import { ViewRotator } from "./components/Layout/ViewRotator";
|
|
import { NetworkGraph } from "./components/Topology/NetworkGraph";
|
|
import { KanbanBoard } from "./components/Tasks/KanbanBoard";
|
|
import { CalendarView } from "./components/Calendar/CalendarView";
|
|
import { useTopology, useTasks, useCalendar, useDisplayConfig } from "./hooks/useOdooData";
|
|
import { useWebSocket } from "./hooks/useWebSocket";
|
|
import { useRotation } from "./hooks/useRotation";
|
|
import type { WSMessage } from "./types";
|
|
|
|
const VIEW_NAMES = ["Topologia de Red", "Proyectos Odoo", "Calendario"];
|
|
|
|
function App() {
|
|
const config = useDisplayConfig();
|
|
const intervalMs = (config?.rotation_interval_seconds ?? 30) * 1000;
|
|
|
|
const activeView = useRotation(3, intervalMs);
|
|
const topology = useTopology();
|
|
const tasks = useTasks();
|
|
const calendar = useCalendar();
|
|
|
|
const handleWsMessage = useCallback(
|
|
(msg: WSMessage) => {
|
|
if (msg.type === "ping_update") {
|
|
topology.refetch();
|
|
} else if (msg.type === "odoo_refresh") {
|
|
tasks.refetch();
|
|
calendar.refetch();
|
|
}
|
|
},
|
|
[topology, tasks, calendar]
|
|
);
|
|
|
|
useWebSocket(handleWsMessage);
|
|
|
|
const connected = !topology.error && !tasks.error && !calendar.error;
|
|
|
|
return (
|
|
<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**
|
|
|
|
```typescript
|
|
// frontend/src/main.tsx
|
|
import { StrictMode } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import "./index.css";
|
|
import App from "./App";
|
|
|
|
createRoot(document.getElementById("root")!).render(
|
|
<StrictMode>
|
|
<App />
|
|
</StrictMode>
|
|
);
|
|
```
|
|
|
|
**Step 3: Verify build**
|
|
|
|
Run: `cd /root/Dashboard/frontend && npm run build`
|
|
Expected: Build succeeds with no errors
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/App.tsx frontend/src/main.tsx
|
|
git commit -m "feat: wire up App.tsx with all views, hooks, and rotation logic"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 14: Docker Compose Setup
|
|
|
|
**Files:**
|
|
- Create: `docker-compose.yaml`
|
|
- Create: `backend/Dockerfile`
|
|
- Create: `frontend/Dockerfile`
|
|
- Create: `frontend/nginx.conf`
|
|
- Create: `.gitignore`
|
|
|
|
**Step 1: Create backend Dockerfile**
|
|
|
|
```dockerfile
|
|
# backend/Dockerfile
|
|
FROM python:3.11-slim
|
|
|
|
RUN apt-get update && apt-get install -y nmap iputils-ping && rm -rf /var/lib/apt/lists/*
|
|
|
|
WORKDIR /app
|
|
|
|
COPY requirements.txt .
|
|
RUN pip install --no-cache-dir -r requirements.txt
|
|
|
|
COPY . .
|
|
|
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
```
|
|
|
|
**Step 2: Create frontend Dockerfile + nginx config**
|
|
|
|
```dockerfile
|
|
# frontend/Dockerfile
|
|
FROM node:20-alpine AS build
|
|
|
|
WORKDIR /app
|
|
COPY package*.json ./
|
|
RUN npm ci
|
|
COPY . .
|
|
RUN npm run build
|
|
|
|
FROM nginx:alpine
|
|
COPY --from=build /app/dist /usr/share/nginx/html
|
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
EXPOSE 80
|
|
```
|
|
|
|
```nginx
|
|
# frontend/nginx.conf
|
|
server {
|
|
listen 80;
|
|
root /usr/share/nginx/html;
|
|
index index.html;
|
|
|
|
location / {
|
|
try_files $uri $uri/ /index.html;
|
|
}
|
|
|
|
location /api/ {
|
|
proxy_pass http://backend:8000;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
}
|
|
|
|
location /ws {
|
|
proxy_pass http://backend:8000;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Upgrade $http_upgrade;
|
|
proxy_set_header Connection "upgrade";
|
|
proxy_set_header Host $host;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Create docker-compose.yaml**
|
|
|
|
```yaml
|
|
# docker-compose.yaml
|
|
services:
|
|
backend:
|
|
build: ./backend
|
|
restart: always
|
|
volumes:
|
|
- ./backend/config:/app/config
|
|
networks:
|
|
- dashboard
|
|
# host network needed for nmap and ping to work on local network
|
|
network_mode: host
|
|
|
|
frontend:
|
|
build: ./frontend
|
|
restart: always
|
|
ports:
|
|
- "80:80"
|
|
depends_on:
|
|
- backend
|
|
networks:
|
|
- dashboard
|
|
|
|
networks:
|
|
dashboard:
|
|
driver: bridge
|
|
```
|
|
|
|
Note: The backend uses `network_mode: host` so nmap and ping can access the local network. The frontend connects to backend via Docker network. This needs adjustment — we'll use a single network with host mode on backend:
|
|
|
|
```yaml
|
|
# docker-compose.yaml (corrected)
|
|
services:
|
|
backend:
|
|
build: ./backend
|
|
restart: always
|
|
network_mode: host
|
|
volumes:
|
|
- ./backend/config:/app/config
|
|
|
|
frontend:
|
|
build: ./frontend
|
|
restart: always
|
|
ports:
|
|
- "80:80"
|
|
extra_hosts:
|
|
- "backend:host-gateway"
|
|
```
|
|
|
|
**Step 4: Create .gitignore**
|
|
|
|
```gitignore
|
|
# Python
|
|
__pycache__/
|
|
*.pyc
|
|
.venv/
|
|
venv/
|
|
*.egg-info/
|
|
|
|
# Node
|
|
node_modules/
|
|
dist/
|
|
|
|
# IDE
|
|
.vscode/
|
|
.idea/
|
|
|
|
# OS
|
|
.DS_Store
|
|
|
|
# Config with secrets (user should copy from example)
|
|
# backend/config/settings.yaml
|
|
# backend/config/services.yaml
|
|
```
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add docker-compose.yaml backend/Dockerfile frontend/Dockerfile frontend/nginx.conf .gitignore
|
|
git commit -m "feat: add Docker Compose setup with backend, frontend/nginx, and gitignore"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 15: End-to-End Verification
|
|
|
|
**Step 1: Verify backend starts and serves API**
|
|
|
|
```bash
|
|
cd /root/Dashboard/backend
|
|
pip install -r requirements.txt
|
|
python -m uvicorn main:app --host 0.0.0.0 --port 8000 &
|
|
sleep 2
|
|
curl http://localhost:8000/api/health
|
|
curl http://localhost:8000/api/services/config
|
|
kill %1
|
|
```
|
|
|
|
Expected: Health endpoint returns JSON. Config endpoint returns display settings.
|
|
|
|
**Step 2: Verify frontend builds**
|
|
|
|
```bash
|
|
cd /root/Dashboard/frontend
|
|
npm install
|
|
npm run build
|
|
```
|
|
|
|
Expected: Build succeeds, `dist/` directory created.
|
|
|
|
**Step 3: Verify Docker Compose builds**
|
|
|
|
```bash
|
|
cd /root/Dashboard
|
|
docker compose build
|
|
```
|
|
|
|
Expected: Both images build successfully.
|
|
|
|
**Step 4: Final commit with any fixes**
|
|
|
|
```bash
|
|
git add -A
|
|
git status
|
|
# Only commit if there are changes
|
|
git commit -m "chore: final adjustments from end-to-end verification"
|
|
```
|