2410 lines
78 KiB
Markdown
2410 lines
78 KiB
Markdown
# Homelab Dashboard Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Build a unified homelab command center with real-time data from Portainer, Jellyfin, Ollama, Prometheus, and automation scripts — deployed as two Docker containers.
|
|
|
|
**Architecture:** FastAPI backend (Python 3.12) reuses existing `scripts/lib/` modules to aggregate data from 15+ services. Next.js 15 frontend (shadcn/ui, Tailwind, dark theme) polls the API and uses SSE for real-time activity feed. Docker Compose runs both.
|
|
|
|
**Tech Stack:** Python 3.12, FastAPI, uvicorn, httpx | Next.js 15, React 19, shadcn/ui, Tailwind CSS, SWR, TypeScript
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
```
|
|
dashboard/
|
|
docker-compose.yml # Runs both containers
|
|
api/
|
|
Dockerfile # Python 3.12 slim
|
|
requirements.txt # fastapi, uvicorn, httpx
|
|
main.py # FastAPI app, CORS, router includes
|
|
routers/
|
|
overview.py # GET /api/stats/overview, /api/activity (SSE)
|
|
containers.py # GET /api/containers, /api/containers/{id}/logs, POST restart
|
|
media.py # GET /api/jellyfin/*, /api/sonarr/*, /api/radarr/*, /api/sabnzbd/*
|
|
automations.py # GET /api/automations/*
|
|
expenses.py # GET /api/expenses, /api/expenses/summary, /api/subscriptions
|
|
olares.py # GET /api/olares/pods, /api/olares/gpu
|
|
lib_bridge.py # Adds scripts/lib/ to sys.path, re-exports modules
|
|
log_parser.py # Parses automation log files into structured events
|
|
ui/
|
|
Dockerfile # Node 22 alpine, next build + start
|
|
package.json
|
|
next.config.ts
|
|
tailwind.config.ts
|
|
tsconfig.json
|
|
app/
|
|
layout.tsx # Root layout: dark bg, top nav, fonts
|
|
page.tsx # Dashboard overview tab
|
|
infrastructure/page.tsx # Container + pod tables
|
|
media/page.tsx # Jellyfin, Sonarr, Radarr, SABnzbd
|
|
automations/page.tsx # Email stats, restart history, backup, drift, disk
|
|
expenses/page.tsx # Expense table, monthly summary, subscriptions
|
|
globals.css # Tailwind directives + dark theme vars
|
|
components/
|
|
nav.tsx # Top navigation bar with active tab
|
|
stat-card.tsx # Metric card (value, label, indicator)
|
|
activity-feed.tsx # SSE-powered real-time feed
|
|
host-card.tsx # Host status badge
|
|
data-table.tsx # Generic sortable/filterable table
|
|
jellyfin-card.tsx # Now playing + library counts
|
|
ollama-card.tsx # Model status + VRAM
|
|
status-badge.tsx # Green/red/amber dot with label
|
|
container-logs-modal.tsx # Modal overlay for container logs
|
|
lib/
|
|
api.ts # Typed fetch wrapper for backend
|
|
use-poll.ts # SWR-based polling hook with configurable interval
|
|
use-sse.ts # EventSource hook for activity stream
|
|
types.ts # Shared TypeScript interfaces
|
|
```
|
|
|
|
---
|
|
|
|
## Task 1: Project Scaffolding + Docker Compose
|
|
|
|
**Files:**
|
|
- Create: `dashboard/docker-compose.yml`
|
|
- Create: `dashboard/api/Dockerfile`
|
|
- Create: `dashboard/api/requirements.txt`
|
|
- Create: `dashboard/api/main.py`
|
|
- Create: `dashboard/ui/Dockerfile`
|
|
|
|
- [ ] **Step 1: Create directory structure**
|
|
|
|
```bash
|
|
mkdir -p dashboard/api/routers dashboard/ui
|
|
```
|
|
|
|
- [ ] **Step 2: Create API requirements.txt**
|
|
|
|
```
|
|
# dashboard/api/requirements.txt
|
|
fastapi==0.115.12
|
|
uvicorn[standard]==0.34.2
|
|
httpx==0.28.1
|
|
pyyaml>=6.0
|
|
sse-starlette==2.3.3
|
|
```
|
|
|
|
- [ ] **Step 3: Create FastAPI main.py**
|
|
|
|
```python
|
|
# dashboard/api/main.py
|
|
"""Homelab Dashboard API — aggregates data from homelab services."""
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
from routers import overview, containers, media, automations, expenses, olares
|
|
|
|
app = FastAPI(title="Homelab Dashboard API", version="1.0.0")
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
app.include_router(overview.router, prefix="/api")
|
|
app.include_router(containers.router, prefix="/api")
|
|
app.include_router(media.router, prefix="/api")
|
|
app.include_router(automations.router, prefix="/api")
|
|
app.include_router(expenses.router, prefix="/api")
|
|
app.include_router(olares.router, prefix="/api")
|
|
|
|
|
|
@app.get("/api/health")
|
|
def health():
|
|
return {"status": "ok"}
|
|
```
|
|
|
|
- [ ] **Step 4: Create lib_bridge.py**
|
|
|
|
```python
|
|
# dashboard/api/lib_bridge.py
|
|
"""Bridge to import scripts/lib/ modules from the mounted volume."""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# scripts/ is mounted at /app/scripts in Docker, or relative in dev
|
|
SCRIPTS_DIR = Path("/app/scripts")
|
|
if not SCRIPTS_DIR.exists():
|
|
SCRIPTS_DIR = Path(__file__).parent.parent.parent / "scripts"
|
|
|
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
|
|
from lib.portainer import (
|
|
list_containers as portainer_list_containers,
|
|
get_container_logs as portainer_get_container_logs,
|
|
restart_container as portainer_restart_container,
|
|
inspect_container as portainer_inspect_container,
|
|
ENDPOINTS,
|
|
)
|
|
from lib.prometheus import prom_query, prom_query_range
|
|
from lib.ollama import ollama_available, ollama_generate, OLLAMA_URL, OLLAMA_MODEL
|
|
|
|
# DB paths
|
|
GMAIL_DB = SCRIPTS_DIR / "gmail-organizer" / "processed.db"
|
|
DVISH_DB = SCRIPTS_DIR / "gmail-organizer-dvish" / "processed.db"
|
|
PROTON_DB = SCRIPTS_DIR / "proton-organizer" / "processed.db"
|
|
RESTART_DB = SCRIPTS_DIR / "stack-restart.db"
|
|
|
|
# Data paths
|
|
DATA_DIR = Path("/app/data")
|
|
if not DATA_DIR.exists():
|
|
DATA_DIR = Path(__file__).parent.parent.parent / "data"
|
|
EXPENSES_CSV = DATA_DIR / "expenses.csv"
|
|
|
|
# Log paths
|
|
LOG_DIR = Path("/app/logs")
|
|
if not LOG_DIR.exists():
|
|
LOG_DIR = Path("/tmp")
|
|
```
|
|
|
|
- [ ] **Step 5: Create stub routers**
|
|
|
|
Create each router file with a minimal stub:
|
|
|
|
```python
|
|
# dashboard/api/routers/overview.py
|
|
from fastapi import APIRouter
|
|
router = APIRouter(tags=["overview"])
|
|
|
|
# dashboard/api/routers/containers.py
|
|
from fastapi import APIRouter
|
|
router = APIRouter(tags=["containers"])
|
|
|
|
# dashboard/api/routers/media.py
|
|
from fastapi import APIRouter
|
|
router = APIRouter(tags=["media"])
|
|
|
|
# dashboard/api/routers/automations.py
|
|
from fastapi import APIRouter
|
|
router = APIRouter(tags=["automations"])
|
|
|
|
# dashboard/api/routers/expenses.py
|
|
from fastapi import APIRouter
|
|
router = APIRouter(tags=["expenses"])
|
|
|
|
# dashboard/api/routers/olares.py
|
|
from fastapi import APIRouter
|
|
router = APIRouter(tags=["olares"])
|
|
```
|
|
|
|
- [ ] **Step 6: Create API Dockerfile**
|
|
|
|
```dockerfile
|
|
# dashboard/api/Dockerfile
|
|
FROM python:3.12-slim
|
|
|
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
openssh-client curl && rm -rf /var/lib/apt/lists/*
|
|
|
|
WORKDIR /app/api
|
|
|
|
COPY requirements.txt .
|
|
RUN pip install --no-cache-dir -r requirements.txt
|
|
|
|
COPY . .
|
|
|
|
EXPOSE 8888
|
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8888"]
|
|
```
|
|
|
|
- [ ] **Step 7: Create docker-compose.yml**
|
|
|
|
```yaml
|
|
# dashboard/docker-compose.yml
|
|
services:
|
|
dashboard-api:
|
|
build: ./api
|
|
ports:
|
|
- "8888:8888"
|
|
volumes:
|
|
- ../scripts:/app/scripts:ro
|
|
- ../data:/app/data:ro
|
|
- /tmp:/app/logs:ro
|
|
- ~/.ssh:/root/.ssh:ro
|
|
network_mode: host
|
|
restart: unless-stopped
|
|
|
|
dashboard-ui:
|
|
build: ./ui
|
|
ports:
|
|
- "3000:3000"
|
|
environment:
|
|
- NEXT_PUBLIC_API_URL=http://localhost:8888
|
|
network_mode: host
|
|
depends_on:
|
|
- dashboard-api
|
|
restart: unless-stopped
|
|
```
|
|
|
|
Note: `network_mode: host` so the API can SSH to olares and reach Portainer on Tailscale IP.
|
|
|
|
- [ ] **Step 8: Test API starts**
|
|
|
|
```bash
|
|
cd dashboard/api
|
|
pip install -r requirements.txt
|
|
uvicorn main:app --host 0.0.0.0 --port 8888 &
|
|
curl -s http://localhost:8888/api/health
|
|
# Expected: {"status":"ok"}
|
|
kill %1
|
|
```
|
|
|
|
- [ ] **Step 9: Commit**
|
|
|
|
```bash
|
|
git add dashboard/
|
|
git commit -m "feat(dashboard): project scaffolding with FastAPI + Docker Compose"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Overview API Routes
|
|
|
|
**Files:**
|
|
- Create: `dashboard/api/log_parser.py`
|
|
- Modify: `dashboard/api/routers/overview.py`
|
|
|
|
- [ ] **Step 1: Create log_parser.py**
|
|
|
|
```python
|
|
# dashboard/api/log_parser.py
|
|
"""Parse automation log files into structured events."""
|
|
|
|
import os
|
|
import re
|
|
from datetime import datetime, date
|
|
from pathlib import Path
|
|
|
|
LOG_FILES = {
|
|
"stack-restart": "stack-restart.log",
|
|
"backup": "backup-validator.log",
|
|
"email-lz": "gmail-organizer.log",
|
|
"email-dvish": "gmail-organizer-dvish.log",
|
|
"email-proton": "proton-organizer.log",
|
|
"receipt": "receipt-tracker.log",
|
|
"drift": "config-drift.log",
|
|
"digest": "email-digest.log",
|
|
}
|
|
|
|
EVENT_PATTERNS = [
|
|
(r"→ (\w+) \((\S+)\)", "email_classified", lambda m: {"category": m.group(1), "label": m.group(2)}),
|
|
(r"Cached: .+ → (\w+)", "email_cached", lambda m: {"category": m.group(1)}),
|
|
(r"Stack-restart check complete", "stack_healthy", lambda m: {}),
|
|
(r"Container (\S+) on (\S+) (restarted)", "container_restarted", lambda m: {"container": m.group(1), "endpoint": m.group(2)}),
|
|
(r"Unhealthy.*: (\S+) on (\S+)", "container_unhealthy", lambda m: {"container": m.group(1), "endpoint": m.group(2)}),
|
|
(r"Backup Validation: (OK|ISSUES FOUND)", "backup_result", lambda m: {"status": m.group(1)}),
|
|
(r"\[DRY-RUN\] Would write: (.+)", "receipt_extracted", lambda m: {"data": m.group(1)}),
|
|
(r"Would write:.*'vendor': '([^']+)'.*'amount': '([^']+)'", "receipt_extracted", lambda m: {"vendor": m.group(1), "amount": m.group(2)}),
|
|
(r"No drifts found", "drift_clean", lambda m: {}),
|
|
(r"Detected (\d+) drifts", "drift_found", lambda m: {"count": m.group(1)}),
|
|
]
|
|
|
|
|
|
def parse_log_line(line: str) -> dict | None:
|
|
"""Parse a single log line into a structured event."""
|
|
# Extract timestamp: "2026-04-03 15:30:01,283 INFO ..."
|
|
ts_match = re.match(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})", line)
|
|
if not ts_match:
|
|
return None
|
|
timestamp = ts_match.group(1)
|
|
|
|
for pattern, event_type, extractor in EVENT_PATTERNS:
|
|
m = re.search(pattern, line)
|
|
if m:
|
|
event = {"type": event_type, "timestamp": timestamp, "raw": line.strip()}
|
|
event.update(extractor(m))
|
|
return event
|
|
return None
|
|
|
|
|
|
def get_recent_events(log_dir: Path, max_events: int = 50) -> list[dict]:
|
|
"""Get recent events from all log files, sorted by time."""
|
|
events = []
|
|
today = date.today().isoformat()
|
|
|
|
for source, filename in LOG_FILES.items():
|
|
log_path = log_dir / filename
|
|
if not log_path.exists():
|
|
continue
|
|
try:
|
|
with open(log_path) as f:
|
|
for line in f:
|
|
if not line.startswith(today):
|
|
continue
|
|
event = parse_log_line(line)
|
|
if event:
|
|
event["source"] = source
|
|
events.append(event)
|
|
except Exception:
|
|
continue
|
|
|
|
events.sort(key=lambda e: e.get("timestamp", ""), reverse=True)
|
|
return events[:max_events]
|
|
|
|
|
|
def tail_logs(log_dir: Path) -> dict[str, int]:
|
|
"""Get file positions for all logs (used by SSE to detect new lines)."""
|
|
positions = {}
|
|
for source, filename in LOG_FILES.items():
|
|
log_path = log_dir / filename
|
|
if log_path.exists():
|
|
positions[source] = log_path.stat().st_size
|
|
return positions
|
|
```
|
|
|
|
- [ ] **Step 2: Implement overview router**
|
|
|
|
```python
|
|
# dashboard/api/routers/overview.py
|
|
"""Overview endpoints: stats, activity feed (SSE)."""
|
|
|
|
import asyncio
|
|
import json
|
|
import sqlite3
|
|
import subprocess
|
|
from datetime import date, datetime
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter
|
|
from sse_starlette.sse import EventSourceResponse
|
|
|
|
from lib_bridge import (
|
|
portainer_list_containers, ENDPOINTS, ollama_available,
|
|
GMAIL_DB, DVISH_DB, PROTON_DB, RESTART_DB, LOG_DIR, OLLAMA_URL,
|
|
)
|
|
from log_parser import get_recent_events, tail_logs, parse_log_line, LOG_FILES
|
|
|
|
router = APIRouter(tags=["overview"])
|
|
|
|
|
|
def _count_today_emails(db_path: Path) -> int:
|
|
"""Count emails classified today in a processed.db."""
|
|
if not db_path.exists():
|
|
return 0
|
|
today = date.today().isoformat()
|
|
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
|
row = conn.execute(
|
|
"SELECT COUNT(*) FROM processed WHERE processed_at LIKE ?", (f"{today}%",)
|
|
).fetchone()
|
|
conn.close()
|
|
return row[0] if row else 0
|
|
|
|
|
|
def _count_unhealthy() -> int:
|
|
"""Count currently tracked unhealthy containers."""
|
|
if not RESTART_DB.exists():
|
|
return 0
|
|
conn = sqlite3.connect(f"file:{RESTART_DB}?mode=ro", uri=True)
|
|
row = conn.execute("SELECT COUNT(*) FROM unhealthy_tracking").fetchone()
|
|
conn.close()
|
|
return row[0] if row else 0
|
|
|
|
|
|
def _get_gpu_info() -> dict:
|
|
"""Get GPU info via SSH to olares."""
|
|
try:
|
|
result = subprocess.run(
|
|
["ssh", "-o", "ConnectTimeout=3", "olares",
|
|
"nvidia-smi --query-gpu=temperature.gpu,power.draw,power.limit,"
|
|
"memory.used,memory.total,utilization.gpu --format=csv,noheader,nounits"],
|
|
capture_output=True, text=True, timeout=8,
|
|
)
|
|
if result.returncode != 0:
|
|
return {"available": False}
|
|
parts = [p.strip() for p in result.stdout.strip().split(",")]
|
|
return {
|
|
"available": True,
|
|
"temp_c": int(parts[0]),
|
|
"power_w": float(parts[1]),
|
|
"power_limit_w": float(parts[2]),
|
|
"vram_used_mb": int(parts[3]),
|
|
"vram_total_mb": int(parts[4]),
|
|
"utilization_pct": int(parts[5]),
|
|
}
|
|
except Exception:
|
|
return {"available": False}
|
|
|
|
|
|
def _get_container_summary() -> dict:
|
|
"""Get container counts across all endpoints."""
|
|
total = 0
|
|
healthy = 0
|
|
per_endpoint = {}
|
|
for name in ENDPOINTS:
|
|
try:
|
|
containers = portainer_list_containers(name)
|
|
running = [c for c in containers if c.get("State") == "running"]
|
|
per_endpoint[name] = {"total": len(containers), "running": len(running)}
|
|
total += len(containers)
|
|
healthy += len(running)
|
|
except Exception:
|
|
per_endpoint[name] = {"total": 0, "running": 0, "error": True}
|
|
return {"total": total, "running": healthy, "endpoints": per_endpoint}
|
|
|
|
|
|
@router.get("/stats/overview")
|
|
def get_overview():
|
|
"""Aggregated overview stats for the dashboard."""
|
|
containers = _get_container_summary()
|
|
gpu = _get_gpu_info()
|
|
emails_today = (
|
|
_count_today_emails(GMAIL_DB)
|
|
+ _count_today_emails(DVISH_DB)
|
|
+ _count_today_emails(PROTON_DB)
|
|
)
|
|
alerts = _count_unhealthy()
|
|
ollama_up = ollama_available()
|
|
|
|
return {
|
|
"containers": containers,
|
|
"gpu": gpu,
|
|
"emails_today": emails_today,
|
|
"alerts": alerts,
|
|
"ollama": {"available": ollama_up, "url": OLLAMA_URL},
|
|
"hosts_online": sum(
|
|
1 for ep in containers["endpoints"].values() if not ep.get("error")
|
|
),
|
|
}
|
|
|
|
|
|
@router.get("/activity")
|
|
async def activity_stream():
|
|
"""SSE stream of recent automation events."""
|
|
async def event_generator():
|
|
positions = tail_logs(LOG_DIR)
|
|
# Send initial batch
|
|
events = get_recent_events(LOG_DIR, max_events=20)
|
|
for event in reversed(events):
|
|
yield {"event": "activity", "data": json.dumps(event)}
|
|
|
|
# Poll for new lines
|
|
while True:
|
|
await asyncio.sleep(5)
|
|
for source, filename in LOG_FILES.items():
|
|
log_path = LOG_DIR / filename
|
|
if not log_path.exists():
|
|
continue
|
|
current_size = log_path.stat().st_size
|
|
prev_size = positions.get(source, 0)
|
|
if current_size > prev_size:
|
|
with open(log_path) as f:
|
|
f.seek(prev_size)
|
|
for line in f:
|
|
event = parse_log_line(line)
|
|
if event:
|
|
event["source"] = source
|
|
yield {"event": "activity", "data": json.dumps(event)}
|
|
positions[source] = current_size
|
|
|
|
return EventSourceResponse(event_generator())
|
|
```
|
|
|
|
- [ ] **Step 3: Test overview endpoint**
|
|
|
|
```bash
|
|
cd dashboard/api
|
|
uvicorn main:app --port 8888 &
|
|
sleep 2
|
|
curl -s http://localhost:8888/api/stats/overview | python3 -m json.tool | head -20
|
|
# Expected: JSON with containers, gpu, emails_today, alerts, ollama fields
|
|
kill %1
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add dashboard/api/
|
|
git commit -m "feat(dashboard): overview + activity SSE API routes"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Container, Media, and Olares API Routes
|
|
|
|
**Files:**
|
|
- Modify: `dashboard/api/routers/containers.py`
|
|
- Modify: `dashboard/api/routers/media.py`
|
|
- Modify: `dashboard/api/routers/olares.py`
|
|
|
|
- [ ] **Step 1: Implement containers router**
|
|
|
|
```python
|
|
# dashboard/api/routers/containers.py
|
|
"""Container management endpoints."""
|
|
|
|
from fastapi import APIRouter, Query
|
|
from lib_bridge import (
|
|
portainer_list_containers, portainer_get_container_logs,
|
|
portainer_restart_container, ENDPOINTS,
|
|
)
|
|
|
|
router = APIRouter(tags=["containers"])
|
|
|
|
|
|
@router.get("/containers")
|
|
def list_containers(endpoint: str | None = None):
|
|
"""List containers across endpoints."""
|
|
results = []
|
|
endpoints = [endpoint] if endpoint else list(ENDPOINTS.keys())
|
|
for ep in endpoints:
|
|
try:
|
|
containers = portainer_list_containers(ep)
|
|
for c in containers:
|
|
results.append({
|
|
"id": c.get("Id", "")[:12],
|
|
"name": (c.get("Names") or ["/unknown"])[0].lstrip("/"),
|
|
"image": c.get("Image", ""),
|
|
"state": c.get("State", ""),
|
|
"status": c.get("Status", ""),
|
|
"endpoint": ep,
|
|
})
|
|
except Exception as e:
|
|
results.append({"endpoint": ep, "error": str(e)})
|
|
return results
|
|
|
|
|
|
@router.get("/containers/{container_id}/logs")
|
|
def container_logs(container_id: str, endpoint: str = Query(...), tail: int = 100):
|
|
"""Get container logs."""
|
|
logs = portainer_get_container_logs(endpoint, container_id, tail=tail)
|
|
return {"container_id": container_id, "endpoint": endpoint, "logs": logs}
|
|
|
|
|
|
@router.post("/containers/{container_id}/restart")
|
|
def restart_container(container_id: str, endpoint: str = Query(...)):
|
|
"""Restart a container."""
|
|
success = portainer_restart_container(endpoint, container_id)
|
|
return {"success": success, "container_id": container_id, "endpoint": endpoint}
|
|
```
|
|
|
|
- [ ] **Step 2: Implement media router**
|
|
|
|
```python
|
|
# dashboard/api/routers/media.py
|
|
"""Media endpoints: Jellyfin, Sonarr, Radarr, SABnzbd."""
|
|
|
|
import subprocess
|
|
import json
|
|
from fastapi import APIRouter
|
|
|
|
router = APIRouter(tags=["media"])
|
|
|
|
JELLYFIN_TOKEN = "REDACTED_TOKEN"
|
|
|
|
|
|
def _jellyfin(path: str) -> dict | list:
|
|
sep = "&" if "?" in path else "?"
|
|
url = f"http://localhost:8096{path}{sep}api_key={JELLYFIN_TOKEN}"
|
|
result = subprocess.run(
|
|
["ssh", "-o", "ConnectTimeout=3", "olares",
|
|
f"kubectl exec -n jellyfin-vishinator deploy/jellyfin -c jellyfin -- curl -s '{url}'"],
|
|
capture_output=True, text=True, timeout=15,
|
|
)
|
|
if result.returncode != 0:
|
|
return {}
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
@router.get("/jellyfin/status")
|
|
def jellyfin_status():
|
|
info = _jellyfin("/System/Info/Public")
|
|
libraries = _jellyfin("/Library/VirtualFolders")
|
|
sessions = _jellyfin("/Sessions")
|
|
active = [s for s in (sessions or []) if s.get("NowPlayingItem")]
|
|
return {
|
|
"version": info.get("Version", "?"),
|
|
"server_name": info.get("ServerName", "?"),
|
|
"libraries": [
|
|
{"name": l["Name"], "type": l.get("CollectionType", "?"),
|
|
"paths": l.get("Locations", [])}
|
|
for l in (libraries or [])
|
|
],
|
|
"active_sessions": [
|
|
{
|
|
"user": s.get("UserName", "?"),
|
|
"device": s.get("DeviceName", "?"),
|
|
"client": s.get("Client", "?"),
|
|
"title": s["NowPlayingItem"].get("Name", "?"),
|
|
"type": s["NowPlayingItem"].get("Type", "?"),
|
|
}
|
|
for s in active
|
|
],
|
|
"idle_sessions": len([s for s in (sessions or []) if not s.get("NowPlayingItem")]),
|
|
}
|
|
|
|
|
|
@router.get("/sonarr/queue")
|
|
def sonarr_queue():
|
|
import sys
|
|
sys.path.insert(0, "/app/scripts")
|
|
from lib.portainer import portainer_api
|
|
import httpx
|
|
try:
|
|
with httpx.Client(timeout=10) as client:
|
|
r = client.get(
|
|
"http://192.168.0.200:8989/api/v3/queue",
|
|
headers={"X-Api-Key": "REDACTED_SONARR_API_KEY"},
|
|
params={"pageSize": 20},
|
|
)
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
return {
|
|
"total": data.get("totalRecords", 0),
|
|
"items": [
|
|
{"title": i.get("title", "?"), "status": i.get("status", "?"),
|
|
"size": i.get("size", 0), "sizeleft": i.get("sizeleft", 0)}
|
|
for i in data.get("records", [])[:10]
|
|
],
|
|
}
|
|
except Exception as e:
|
|
return {"total": 0, "items": [], "error": str(e)}
|
|
|
|
|
|
@router.get("/radarr/queue")
|
|
def radarr_queue():
|
|
import httpx
|
|
try:
|
|
with httpx.Client(timeout=10) as client:
|
|
r = client.get(
|
|
"http://192.168.0.200:7878/api/v3/queue",
|
|
headers={"X-Api-Key": "REDACTED_RADARR_API_KEY"},
|
|
params={"pageSize": 20},
|
|
)
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
return {
|
|
"total": data.get("totalRecords", 0),
|
|
"items": [
|
|
{"title": i.get("title", "?"), "status": i.get("status", "?"),
|
|
"size": i.get("size", 0), "sizeleft": i.get("sizeleft", 0)}
|
|
for i in data.get("records", [])[:10]
|
|
],
|
|
}
|
|
except Exception as e:
|
|
return {"total": 0, "items": [], "error": str(e)}
|
|
|
|
|
|
@router.get("/sabnzbd/queue")
|
|
def sabnzbd_queue():
|
|
import httpx
|
|
try:
|
|
with httpx.Client(timeout=10) as client:
|
|
r = client.get(
|
|
"http://192.168.0.200:8080/api",
|
|
params={"apikey": "6ae289de5a4f45f7a0124b43ba9c3dea", "output": "json", "mode": "queue"},
|
|
)
|
|
r.raise_for_status()
|
|
data = r.json().get("queue", {})
|
|
return {
|
|
"speed": data.get("speed", "0"),
|
|
"size_left": data.get("sizeleft", "0"),
|
|
"eta": data.get("eta", "unknown"),
|
|
"items": [
|
|
{"name": s.get("filename", "?"), "size": s.get("size", "?"),
|
|
"percentage": s.get("percentage", "0")}
|
|
for s in data.get("slots", [])[:10]
|
|
],
|
|
}
|
|
except Exception as e:
|
|
return {"speed": "0", "items": [], "error": str(e)}
|
|
```
|
|
|
|
- [ ] **Step 3: Implement olares router**
|
|
|
|
```python
|
|
# dashboard/api/routers/olares.py
|
|
"""Olares K3s cluster endpoints."""
|
|
|
|
import subprocess
|
|
from fastapi import APIRouter, Query
|
|
|
|
router = APIRouter(tags=["olares"])
|
|
|
|
|
|
def _kubectl(cmd: str, timeout: int = 10) -> str:
|
|
result = subprocess.run(
|
|
["ssh", "-o", "ConnectTimeout=3", "olares", f"kubectl {cmd}"],
|
|
capture_output=True, text=True, timeout=timeout,
|
|
)
|
|
if result.returncode != 0:
|
|
return f"Error: {result.stderr.strip()}"
|
|
return result.stdout.strip()
|
|
|
|
|
|
@router.get("/olares/pods")
|
|
def olares_pods(namespace: str | None = None):
|
|
ns_flag = f"-n {namespace}" if namespace else "-A"
|
|
output = _kubectl(f"get pods {ns_flag} -o wide --no-headers")
|
|
pods = []
|
|
for line in output.splitlines():
|
|
parts = line.split()
|
|
if len(parts) >= 7:
|
|
pods.append({
|
|
"namespace": parts[0] if not namespace else namespace,
|
|
"name": parts[1] if not namespace else parts[0],
|
|
"ready": parts[2] if not namespace else parts[1],
|
|
"status": parts[3] if not namespace else parts[2],
|
|
"restarts": parts[4] if not namespace else parts[3],
|
|
"age": parts[5] if not namespace else parts[4],
|
|
})
|
|
return pods
|
|
|
|
|
|
@router.get("/olares/gpu")
|
|
def olares_gpu():
|
|
try:
|
|
result = subprocess.run(
|
|
["ssh", "-o", "ConnectTimeout=3", "olares",
|
|
"nvidia-smi --query-gpu=name,temperature.gpu,power.draw,power.limit,"
|
|
"memory.used,memory.total,utilization.gpu --format=csv,noheader,nounits"],
|
|
capture_output=True, text=True, timeout=8,
|
|
)
|
|
if result.returncode != 0:
|
|
return {"available": False, "error": result.stderr.strip()}
|
|
parts = [p.strip() for p in result.stdout.strip().split(",")]
|
|
return {
|
|
"available": True,
|
|
"name": parts[0],
|
|
"temp_c": int(parts[1]),
|
|
"power_w": float(parts[2]),
|
|
"power_limit_w": float(parts[3]),
|
|
"vram_used_mb": int(parts[4]),
|
|
"vram_total_mb": int(parts[5]),
|
|
"utilization_pct": int(parts[6]),
|
|
}
|
|
except Exception as e:
|
|
return {"available": False, "error": str(e)}
|
|
```
|
|
|
|
- [ ] **Step 4: Test all routes**
|
|
|
|
```bash
|
|
cd dashboard/api
|
|
uvicorn main:app --port 8888 &
|
|
sleep 2
|
|
echo "=== Containers ===" && curl -s http://localhost:8888/api/containers | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'{len(d)} containers')"
|
|
echo "=== Jellyfin ===" && curl -s http://localhost:8888/api/jellyfin/status | python3 -m json.tool | head -10
|
|
echo "=== GPU ===" && curl -s http://localhost:8888/api/olares/gpu | python3 -m json.tool
|
|
echo "=== Sonarr ===" && curl -s http://localhost:8888/api/sonarr/queue | python3 -m json.tool | head -5
|
|
kill %1
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add dashboard/api/
|
|
git commit -m "feat(dashboard): container, media, and olares API routes"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Automations and Expenses API Routes
|
|
|
|
**Files:**
|
|
- Modify: `dashboard/api/routers/automations.py`
|
|
- Modify: `dashboard/api/routers/expenses.py`
|
|
|
|
- [ ] **Step 1: Implement automations router**
|
|
|
|
```python
|
|
# dashboard/api/routers/automations.py
|
|
"""Automation status endpoints."""
|
|
|
|
import csv
|
|
import sqlite3
|
|
from collections import Counter
|
|
from datetime import date, datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter
|
|
|
|
from lib_bridge import GMAIL_DB, DVISH_DB, PROTON_DB, RESTART_DB, LOG_DIR
|
|
|
|
router = APIRouter(tags=["automations"])
|
|
|
|
|
|
def _email_stats(db_path: Path, account_name: str) -> dict:
|
|
"""Get today's email classification stats from a processed.db."""
|
|
if not db_path.exists():
|
|
return {"account": account_name, "today": 0, "categories": {}}
|
|
today = date.today().isoformat()
|
|
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
|
rows = conn.execute(
|
|
"SELECT category FROM processed WHERE processed_at LIKE ?", (f"{today}%",)
|
|
).fetchall()
|
|
conn.close()
|
|
categories = Counter(r[0] for r in rows)
|
|
return {"account": account_name, "today": len(rows), "categories": dict(categories)}
|
|
|
|
|
|
def _sender_cache_count(db_path: Path) -> int:
|
|
if not db_path.exists():
|
|
return 0
|
|
try:
|
|
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
|
row = conn.execute("SELECT COUNT(*) FROM sender_cache").fetchone()
|
|
conn.close()
|
|
return row[0] if row else 0
|
|
except Exception:
|
|
return 0
|
|
|
|
|
|
@router.get("/automations/email")
|
|
def email_stats():
|
|
return {
|
|
"accounts": [
|
|
_email_stats(GMAIL_DB, "lzbellina92@gmail.com"),
|
|
_email_stats(DVISH_DB, "your-email@example.com"),
|
|
_email_stats(PROTON_DB, "admin@thevish.io"),
|
|
],
|
|
"sender_cache": {
|
|
"lzbellina92": _sender_cache_count(GMAIL_DB),
|
|
"dvish92": _sender_cache_count(DVISH_DB),
|
|
},
|
|
}
|
|
|
|
|
|
@router.get("/automations/restarts")
|
|
def restart_history():
|
|
if not RESTART_DB.exists():
|
|
return {"entries": []}
|
|
conn = sqlite3.connect(f"file:{RESTART_DB}?mode=ro", uri=True)
|
|
rows = conn.execute(
|
|
"SELECT container_id, endpoint, first_seen, last_checked, restart_count, last_restart "
|
|
"FROM unhealthy_tracking ORDER BY last_checked DESC LIMIT 50"
|
|
).fetchall()
|
|
conn.close()
|
|
return {
|
|
"entries": [
|
|
{"container_id": r[0], "endpoint": r[1], "first_seen": r[2],
|
|
"last_checked": r[3], "restart_count": r[4], "last_restart": r[5]}
|
|
for r in rows
|
|
]
|
|
}
|
|
|
|
|
|
@router.get("/automations/backup")
|
|
def backup_status():
|
|
log_path = LOG_DIR / "gmail-backup-daily.log"
|
|
if not log_path.exists():
|
|
return {"status": "unknown", "message": "No backup log found"}
|
|
try:
|
|
with open(log_path) as f:
|
|
content = f.read()
|
|
today = date.today().isoformat()
|
|
today_lines = [l for l in content.splitlines() if today in l]
|
|
has_error = any("ERROR" in l for l in today_lines)
|
|
return {
|
|
"status": "error" if has_error else "ok",
|
|
"today_entries": len(today_lines),
|
|
"last_line": today_lines[-1] if today_lines else "No entries today",
|
|
}
|
|
except Exception as e:
|
|
return {"status": "error", "message": str(e)}
|
|
|
|
|
|
@router.get("/automations/drift")
|
|
def drift_status():
|
|
log_path = LOG_DIR / "config-drift.log"
|
|
if not log_path.exists():
|
|
return {"status": "unknown", "last_run": None, "drifts": 0}
|
|
try:
|
|
with open(log_path) as f:
|
|
lines = f.readlines()
|
|
last_lines = lines[-20:] if len(lines) > 20 else lines
|
|
drift_count = 0
|
|
last_run = None
|
|
for line in reversed(last_lines):
|
|
if "Detected" in line and "drifts" in line:
|
|
import re
|
|
m = re.search(r"Detected (\d+) drifts", line)
|
|
if m:
|
|
drift_count = int(m.group(1))
|
|
if "No drifts found" in line:
|
|
drift_count = 0
|
|
ts = line[:19] if len(line) > 19 else None
|
|
if ts and not last_run:
|
|
last_run = ts
|
|
return {"status": "clean" if drift_count == 0 else "drifted",
|
|
"drifts": drift_count, "last_run": last_run}
|
|
except Exception as e:
|
|
return {"status": "error", "message": str(e)}
|
|
```
|
|
|
|
- [ ] **Step 2: Implement expenses router**
|
|
|
|
```python
|
|
# dashboard/api/routers/expenses.py
|
|
"""Expense tracking endpoints."""
|
|
|
|
import csv
|
|
from collections import Counter
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Query
|
|
|
|
from lib_bridge import EXPENSES_CSV
|
|
|
|
router = APIRouter(tags=["expenses"])
|
|
|
|
|
|
def _read_expenses() -> list[dict]:
|
|
"""Read expenses.csv into a list of dicts."""
|
|
if not EXPENSES_CSV.exists():
|
|
return []
|
|
with open(EXPENSES_CSV, newline="") as f:
|
|
reader = csv.DictReader(f)
|
|
return list(reader)
|
|
|
|
|
|
@router.get("/expenses")
|
|
def get_expenses(month: str | None = None):
|
|
"""Get expense data, optionally filtered by month (YYYY-MM)."""
|
|
expenses = _read_expenses()
|
|
if month:
|
|
expenses = [e for e in expenses if e.get("date", "").startswith(month)]
|
|
return {
|
|
"count": len(expenses),
|
|
"expenses": expenses,
|
|
}
|
|
|
|
|
|
@router.get("/expenses/summary")
|
|
def expense_summary(month: str | None = None):
|
|
"""Monthly expense summary."""
|
|
if not month:
|
|
month = date.today().strftime("%Y-%m")
|
|
expenses = [e for e in _read_expenses() if e.get("date", "").startswith(month)]
|
|
total = sum(float(e.get("amount", 0) or 0) for e in expenses)
|
|
by_vendor = Counter()
|
|
for e in expenses:
|
|
vendor = e.get("vendor", "Unknown")
|
|
by_vendor[vendor] += float(e.get("amount", 0) or 0)
|
|
top_vendors = sorted(by_vendor.items(), key=lambda x: -x[1])[:10]
|
|
return {
|
|
"month": month,
|
|
"total": round(total, 2),
|
|
"count": len(expenses),
|
|
"top_vendors": [{"vendor": v, "amount": round(a, 2)} for v, a in top_vendors],
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Test**
|
|
|
|
```bash
|
|
cd dashboard/api
|
|
uvicorn main:app --port 8888 &
|
|
sleep 2
|
|
curl -s http://localhost:8888/api/automations/email | python3 -m json.tool | head -15
|
|
curl -s http://localhost:8888/api/automations/backup | python3 -m json.tool
|
|
curl -s http://localhost:8888/api/expenses/summary | python3 -m json.tool
|
|
kill %1
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add dashboard/api/
|
|
git commit -m "feat(dashboard): automations and expenses API routes"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Next.js Frontend Scaffolding
|
|
|
|
**Files:**
|
|
- Create: `dashboard/ui/package.json`
|
|
- Create: `dashboard/ui/next.config.ts`
|
|
- Create: `dashboard/ui/tailwind.config.ts`
|
|
- Create: `dashboard/ui/tsconfig.json`
|
|
- Create: `dashboard/ui/app/globals.css`
|
|
- Create: `dashboard/ui/app/layout.tsx`
|
|
- Create: `dashboard/ui/lib/api.ts`
|
|
- Create: `dashboard/ui/lib/types.ts`
|
|
- Create: `dashboard/ui/lib/use-poll.ts`
|
|
- Create: `dashboard/ui/lib/use-sse.ts`
|
|
- Create: `dashboard/ui/components/nav.tsx`
|
|
- Create: `dashboard/ui/Dockerfile`
|
|
|
|
- [ ] **Step 1: Initialize Next.js project**
|
|
|
|
```bash
|
|
cd dashboard/ui
|
|
npx create-next-app@latest . --typescript --tailwind --eslint --app --src-dir=false --import-alias="@/*" --no-git --use-npm
|
|
```
|
|
|
|
- [ ] **Step 2: Install dependencies**
|
|
|
|
```bash
|
|
cd dashboard/ui
|
|
npm install swr clsx
|
|
npx shadcn@latest init -d
|
|
npx shadcn@latest add card badge table tabs separator scroll-area dialog button
|
|
```
|
|
|
|
- [ ] **Step 3: Create globals.css with dark theme**
|
|
|
|
```css
|
|
/* dashboard/ui/app/globals.css */
|
|
@tailwind base;
|
|
@tailwind components;
|
|
@tailwind utilities;
|
|
|
|
@layer base {
|
|
:root {
|
|
--background: 222 47% 5%;
|
|
--foreground: 210 40% 96%;
|
|
--card: 217 33% 6%;
|
|
--card-foreground: 210 40% 96%;
|
|
--popover: 222 47% 5%;
|
|
--popover-foreground: 210 40% 96%;
|
|
--primary: 217 91% 60%;
|
|
--primary-foreground: 222 47% 5%;
|
|
--secondary: 217 33% 12%;
|
|
--secondary-foreground: 210 40% 96%;
|
|
--muted: 217 33% 12%;
|
|
--muted-foreground: 215 20% 55%;
|
|
--accent: 217 33% 12%;
|
|
--accent-foreground: 210 40% 96%;
|
|
--destructive: 0 84% 60%;
|
|
--destructive-foreground: 210 40% 96%;
|
|
--border: 217 33% 17%;
|
|
--input: 217 33% 17%;
|
|
--ring: 217 91% 60%;
|
|
--radius: 0.625rem;
|
|
}
|
|
|
|
* {
|
|
border-color: hsl(var(--border));
|
|
}
|
|
|
|
body {
|
|
background-color: hsl(var(--background));
|
|
color: hsl(var(--foreground));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Create lib/types.ts**
|
|
|
|
```typescript
|
|
// dashboard/ui/lib/types.ts
|
|
export interface OverviewStats {
|
|
containers: {
|
|
total: number;
|
|
running: number;
|
|
endpoints: Record<string, { total: number; running: number; error?: boolean }>;
|
|
};
|
|
gpu: {
|
|
available: boolean;
|
|
temp_c?: number;
|
|
power_w?: number;
|
|
power_limit_w?: number;
|
|
vram_used_mb?: number;
|
|
vram_total_mb?: number;
|
|
utilization_pct?: number;
|
|
};
|
|
emails_today: number;
|
|
alerts: number;
|
|
ollama: { available: boolean; url: string };
|
|
hosts_online: number;
|
|
}
|
|
|
|
export interface ActivityEvent {
|
|
type: string;
|
|
timestamp: string;
|
|
source: string;
|
|
raw: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface Container {
|
|
id: string;
|
|
name: string;
|
|
image: string;
|
|
state: string;
|
|
status: string;
|
|
endpoint: string;
|
|
error?: string;
|
|
}
|
|
|
|
export interface JellyfinStatus {
|
|
version: string;
|
|
server_name: string;
|
|
libraries: { name: string; type: string; paths: string[] }[];
|
|
active_sessions: { user: string; device: string; client: string; title: string; type: string }[];
|
|
idle_sessions: number;
|
|
}
|
|
|
|
export interface EmailStats {
|
|
accounts: { account: string; today: number; categories: Record<string, number> }[];
|
|
sender_cache: Record<string, number>;
|
|
}
|
|
|
|
export interface ExpenseSummary {
|
|
month: string;
|
|
total: number;
|
|
count: number;
|
|
top_vendors: { vendor: string; amount: number }[];
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Create lib/api.ts**
|
|
|
|
```typescript
|
|
// dashboard/ui/lib/api.ts
|
|
const API = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8888";
|
|
|
|
export async function fetchAPI<T>(path: string): Promise<T> {
|
|
const res = await fetch(`${API}${path}`);
|
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
export async function postAPI<T>(path: string): Promise<T> {
|
|
const res = await fetch(`${API}${path}`, { method: "POST" });
|
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
|
return res.json();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Create lib/use-poll.ts**
|
|
|
|
```typescript
|
|
// dashboard/ui/lib/use-poll.ts
|
|
import useSWR from "swr";
|
|
import { fetchAPI } from "./api";
|
|
|
|
export function usePoll<T>(path: string, interval: number = 60000) {
|
|
return useSWR<T>(path, () => fetchAPI<T>(path), {
|
|
refreshInterval: interval,
|
|
revalidateOnFocus: false,
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 7: Create lib/use-sse.ts**
|
|
|
|
```typescript
|
|
// dashboard/ui/lib/use-sse.ts
|
|
"use client";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { ActivityEvent } from "./types";
|
|
|
|
const API = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8888";
|
|
|
|
export function useSSE(path: string, maxEvents: number = 30) {
|
|
const [events, setEvents] = useState<ActivityEvent[]>([]);
|
|
const esRef = useRef<EventSource | null>(null);
|
|
|
|
useEffect(() => {
|
|
const es = new EventSource(`${API}${path}`);
|
|
esRef.current = es;
|
|
|
|
es.addEventListener("activity", (e) => {
|
|
const event: ActivityEvent = JSON.parse(e.data);
|
|
setEvents((prev) => [event, ...prev].slice(0, maxEvents));
|
|
});
|
|
|
|
es.onerror = () => {
|
|
es.close();
|
|
// Reconnect after 5s
|
|
setTimeout(() => {
|
|
esRef.current = new EventSource(`${API}${path}`);
|
|
}, 5000);
|
|
};
|
|
|
|
return () => es.close();
|
|
}, [path, maxEvents]);
|
|
|
|
return events;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 8: Create components/nav.tsx**
|
|
|
|
```tsx
|
|
// dashboard/ui/components/nav.tsx
|
|
"use client";
|
|
import Link from "next/link";
|
|
import { usePathname } from "next/navigation";
|
|
import { clsx } from "clsx";
|
|
|
|
const tabs = [
|
|
{ href: "/", label: "Dashboard" },
|
|
{ href: "/infrastructure", label: "Infrastructure" },
|
|
{ href: "/media", label: "Media" },
|
|
{ href: "/automations", label: "Automations" },
|
|
{ href: "/expenses", label: "Expenses" },
|
|
];
|
|
|
|
export function Nav() {
|
|
const pathname = usePathname();
|
|
|
|
return (
|
|
<header className="sticky top-0 z-50 border-b border-border bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/60">
|
|
<div className="flex h-14 items-center justify-between px-6">
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-gradient-to-br from-blue-500 to-violet-500 text-sm font-bold text-white">
|
|
H
|
|
</div>
|
|
<span className="text-sm font-semibold">Homelab</span>
|
|
</div>
|
|
<nav className="flex">
|
|
{tabs.map((tab) => {
|
|
const active = tab.href === "/" ? pathname === "/" : pathname.startsWith(tab.href);
|
|
return (
|
|
<Link
|
|
key={tab.href}
|
|
href={tab.href}
|
|
className={clsx(
|
|
"px-4 py-2 text-sm transition-colors border-b-2",
|
|
active
|
|
? "text-foreground border-primary"
|
|
: "text-muted-foreground border-transparent hover:text-foreground"
|
|
)}
|
|
>
|
|
{tab.label}
|
|
</Link>
|
|
);
|
|
})}
|
|
</nav>
|
|
<div className="text-xs text-muted-foreground">
|
|
{new Date().toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 9: Create layout.tsx**
|
|
|
|
```tsx
|
|
// dashboard/ui/app/layout.tsx
|
|
import type { Metadata } from "next";
|
|
import { Inter } from "next/font/google";
|
|
import "./globals.css";
|
|
import { Nav } from "@/components/nav";
|
|
|
|
const inter = Inter({ subsets: ["latin"] });
|
|
|
|
export const metadata: Metadata = {
|
|
title: "Homelab Dashboard",
|
|
description: "Unified command center for homelab infrastructure",
|
|
};
|
|
|
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<html lang="en" className="dark">
|
|
<body className={inter.className}>
|
|
<Nav />
|
|
<main className="p-6">{children}</main>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 10: Create UI Dockerfile**
|
|
|
|
```dockerfile
|
|
# dashboard/ui/Dockerfile
|
|
FROM node:22-alpine AS builder
|
|
WORKDIR /app
|
|
COPY package*.json ./
|
|
RUN npm ci
|
|
COPY . .
|
|
RUN npm run build
|
|
|
|
FROM node:22-alpine AS runner
|
|
WORKDIR /app
|
|
ENV NODE_ENV=production
|
|
COPY --from=builder /app/.next/standalone ./
|
|
COPY --from=builder /app/.next/static ./.next/static
|
|
COPY --from=builder /app/public ./public
|
|
EXPOSE 3000
|
|
CMD ["node", "server.js"]
|
|
```
|
|
|
|
- [ ] **Step 11: Update next.config.ts for standalone output**
|
|
|
|
```typescript
|
|
// dashboard/ui/next.config.ts
|
|
import type { NextConfig } from "next";
|
|
|
|
const nextConfig: NextConfig = {
|
|
output: "standalone",
|
|
};
|
|
|
|
export default nextConfig;
|
|
```
|
|
|
|
- [ ] **Step 12: Test frontend builds**
|
|
|
|
```bash
|
|
cd dashboard/ui
|
|
npm run build
|
|
# Expected: Build succeeds with no errors
|
|
```
|
|
|
|
- [ ] **Step 13: Commit**
|
|
|
|
```bash
|
|
git add dashboard/ui/
|
|
git commit -m "feat(dashboard): Next.js frontend scaffolding with nav, API hooks, dark theme"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Dashboard Overview Page
|
|
|
|
**Files:**
|
|
- Create: `dashboard/ui/components/stat-card.tsx`
|
|
- Create: `dashboard/ui/components/activity-feed.tsx`
|
|
- Create: `dashboard/ui/components/host-card.tsx`
|
|
- Create: `dashboard/ui/components/status-badge.tsx`
|
|
- Create: `dashboard/ui/components/jellyfin-card.tsx`
|
|
- Create: `dashboard/ui/components/ollama-card.tsx`
|
|
- Modify: `dashboard/ui/app/page.tsx`
|
|
|
|
- [ ] **Step 1: Create stat-card.tsx**
|
|
|
|
```tsx
|
|
// dashboard/ui/components/stat-card.tsx
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
|
|
interface StatCardProps {
|
|
label: string;
|
|
value: string | number;
|
|
sub?: React.ReactNode;
|
|
}
|
|
|
|
export function StatCard({ label, value, sub }: StatCardProps) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{label}</p>
|
|
<p className="mt-1 text-2xl font-bold">{value}</p>
|
|
{sub && <div className="mt-1">{sub}</div>}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create status-badge.tsx**
|
|
|
|
```tsx
|
|
// dashboard/ui/components/status-badge.tsx
|
|
import { clsx } from "clsx";
|
|
|
|
const colors = {
|
|
green: "bg-green-500",
|
|
red: "bg-red-500",
|
|
amber: "bg-amber-500",
|
|
blue: "bg-blue-500",
|
|
purple: "bg-violet-500",
|
|
};
|
|
|
|
interface REDACTED_APP_PASSWORD {
|
|
color: keyof typeof colors;
|
|
label?: string;
|
|
}
|
|
|
|
export function StatusBadge({ color, label }: StatusBadgeProps) {
|
|
return (
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<span className={clsx("h-1.5 w-1.5 rounded-full", colors[color])} />
|
|
{label && <span className="text-xs">{label}</span>}
|
|
</span>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create activity-feed.tsx**
|
|
|
|
```tsx
|
|
// dashboard/ui/components/activity-feed.tsx
|
|
"use client";
|
|
import { useSSE } from "@/lib/use-sse";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { StatusBadge } from "./status-badge";
|
|
|
|
const typeColors: Record<string, "green" | "blue" | "amber" | "red" | "purple"> = {
|
|
stack_healthy: "green",
|
|
backup_result: "green",
|
|
email_classified: "blue",
|
|
email_cached: "blue",
|
|
receipt_extracted: "amber",
|
|
container_unhealthy: "red",
|
|
container_restarted: "amber",
|
|
drift_clean: "green",
|
|
drift_found: "red",
|
|
};
|
|
|
|
function formatEvent(e: { type: string; raw: string; timestamp: string }): string {
|
|
// Clean up the raw log line — remove timestamp prefix and log level
|
|
const clean = e.raw.replace(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},?\d* \w+\s+/, "");
|
|
return clean.length > 120 ? clean.slice(0, 117) + "..." : clean;
|
|
}
|
|
|
|
export function ActivityFeed() {
|
|
const events = useSSE("/api/activity", 20);
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm font-semibold">Activity Feed</CardTitle>
|
|
<span className="text-[10px] text-blue-400">LIVE</span>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ScrollArea className="h-[220px]">
|
|
<div className="space-y-3">
|
|
{events.length === 0 && (
|
|
<p className="text-xs text-muted-foreground">Waiting for events...</p>
|
|
)}
|
|
{events.map((event, i) => (
|
|
<div key={`${event.timestamp}-${i}`} className="flex gap-2.5 items-start">
|
|
<StatusBadge color={typeColors[event.type] || "blue"} />
|
|
<div className="min-w-0">
|
|
<p className="text-xs leading-relaxed">{formatEvent(event)}</p>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{event.timestamp.split(" ")[1]?.slice(0, 5)} · {event.source}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Create jellyfin-card.tsx**
|
|
|
|
```tsx
|
|
// dashboard/ui/components/jellyfin-card.tsx
|
|
"use client";
|
|
import { usePoll } from "@/lib/use-poll";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { JellyfinStatus } from "@/lib/types";
|
|
|
|
export function JellyfinCard() {
|
|
const { data } = usePoll<JellyfinStatus>("/api/jellyfin/status", 30000);
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-semibold">Jellyfin</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{data?.active_sessions && data.active_sessions.length > 0 ? (
|
|
<div className="text-center">
|
|
<p className="text-[10px] uppercase text-violet-400">Now Playing</p>
|
|
{data.active_sessions.map((s, i) => (
|
|
<div key={i} className="mt-1">
|
|
<p className="text-sm font-medium">{s.title}</p>
|
|
<p className="text-[10px] text-muted-foreground">{s.user} on {s.device}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-2">
|
|
<p className="text-[10px] uppercase text-muted-foreground">No Active Streams</p>
|
|
</div>
|
|
)}
|
|
<Separator className="my-3" />
|
|
<div className="space-y-1">
|
|
{data?.libraries?.map((lib) => (
|
|
<div key={lib.name} className="flex justify-between text-xs">
|
|
<span className="text-muted-foreground">{lib.name}</span>
|
|
<span>{lib.type}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Create ollama-card.tsx**
|
|
|
|
```tsx
|
|
// dashboard/ui/components/ollama-card.tsx
|
|
"use client";
|
|
import { usePoll } from "@/lib/use-poll";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { StatusBadge } from "./status-badge";
|
|
import { OverviewStats } from "@/lib/types";
|
|
|
|
export function OllamaCard() {
|
|
const { data } = usePoll<OverviewStats>("/api/stats/overview", 60000);
|
|
const gpu = data?.gpu;
|
|
const ollama = data?.ollama;
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-semibold">Ollama LLM</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-center py-2">
|
|
<StatusBadge color={ollama?.available ? "green" : "red"} label={ollama?.available ? "Online" : "Offline"} />
|
|
<p className="mt-1 text-sm font-medium">qwen3-coder</p>
|
|
<p className="text-[10px] text-muted-foreground">65K context</p>
|
|
</div>
|
|
<Separator className="my-3" />
|
|
<div className="space-y-1">
|
|
<div className="flex justify-between text-xs">
|
|
<span className="text-muted-foreground">VRAM</span>
|
|
<span>{gpu?.vram_used_mb ?? "?"} / {gpu?.vram_total_mb ?? "?"} MB</span>
|
|
</div>
|
|
<div className="flex justify-between text-xs">
|
|
<span className="text-muted-foreground">GPU Temp</span>
|
|
<span>{gpu?.temp_c ?? "?"}°C</span>
|
|
</div>
|
|
<div className="flex justify-between text-xs">
|
|
<span className="text-muted-foreground">Power</span>
|
|
<span>{gpu?.power_w ?? "?"}W / {gpu?.power_limit_w ?? "?"}W</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Create host-card.tsx**
|
|
|
|
```tsx
|
|
// dashboard/ui/components/host-card.tsx
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { StatusBadge } from "./status-badge";
|
|
|
|
interface HostCardProps {
|
|
name: string;
|
|
containers?: number;
|
|
description: string;
|
|
online: boolean;
|
|
}
|
|
|
|
const hostDescriptions: Record<string, string> = {
|
|
atlantis: "NAS · media stack",
|
|
calypso: "DNS · SSO · Headscale",
|
|
olares: "K3s · RTX 5090",
|
|
nuc: "lightweight svcs",
|
|
rpi5: "Uptime Kuma",
|
|
homelab: "monitoring VM",
|
|
};
|
|
|
|
export function HostCard({ name, containers, description, online }: HostCardProps) {
|
|
return (
|
|
<Card className="bg-background">
|
|
<CardContent className="p-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-medium capitalize">{name}</span>
|
|
<StatusBadge color={online ? "green" : "red"} />
|
|
</div>
|
|
<p className="mt-1 text-[10px] text-muted-foreground">
|
|
{containers !== undefined ? `${containers} containers` : ""}
|
|
</p>
|
|
<p className="text-[10px] text-muted-foreground">{description}</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export { REDACTED_APP_PASSWORD };
|
|
```
|
|
|
|
- [ ] **Step 7: Create Dashboard page (app/page.tsx)**
|
|
|
|
```tsx
|
|
// dashboard/ui/app/page.tsx
|
|
"use client";
|
|
import { usePoll } from "@/lib/use-poll";
|
|
import { OverviewStats } from "@/lib/types";
|
|
import { StatCard } from "@/components/stat-card";
|
|
import { ActivityFeed } from "@/components/activity-feed";
|
|
import { JellyfinCard } from "@/components/jellyfin-card";
|
|
import { OllamaCard } from "@/components/ollama-card";
|
|
import { HostCard, REDACTED_APP_PASSWORD } from "@/components/host-card";
|
|
import { StatusBadge } from "@/components/status-badge";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
|
|
export default function DashboardPage() {
|
|
const { data } = usePoll<OverviewStats>("/api/stats/overview", 60000);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Quick Stats */}
|
|
<div className="grid grid-cols-5 gap-3">
|
|
<StatCard
|
|
label="Containers"
|
|
value={data?.containers?.total ?? "—"}
|
|
sub={<StatusBadge color="green" label={data ? `${data.containers.running} running` : "loading..."} />}
|
|
/>
|
|
<StatCard
|
|
label="Hosts Online"
|
|
value={data?.hosts_online ?? "—"}
|
|
sub={
|
|
<div className="flex gap-0.5 mt-1">
|
|
{data && Object.entries(data.containers.endpoints).map(([name, ep]) => (
|
|
<div key={name} className={`h-1 flex-1 rounded-full ${ep.error ? "bg-red-500" : "bg-green-500"}`} title={name} />
|
|
))}
|
|
</div>
|
|
}
|
|
/>
|
|
<StatCard
|
|
label="GPU — RTX 5090"
|
|
value={data?.gpu?.available ? `${data.gpu.temp_c}°C` : "—"}
|
|
sub={data?.gpu?.available && (
|
|
<div className="flex justify-between text-[10px] text-muted-foreground">
|
|
<span>{data.gpu.power_w}W / {data.gpu.power_limit_w}W</span>
|
|
<span className="text-green-400">{data.gpu.utilization_pct}%</span>
|
|
</div>
|
|
)}
|
|
/>
|
|
<StatCard
|
|
label="Emails Today"
|
|
value={data?.emails_today ?? "—"}
|
|
/>
|
|
<StatCard
|
|
label="Alerts"
|
|
value={data?.alerts ?? 0}
|
|
sub={<StatusBadge color={data?.alerts ? "red" : "green"} label={data?.alerts ? `${data.alerts} active` : "All clear"} />}
|
|
/>
|
|
</div>
|
|
|
|
{/* Activity + Jellyfin + Ollama */}
|
|
<div className="grid grid-cols-[2fr_1fr_1fr] gap-3">
|
|
<ActivityFeed />
|
|
<JellyfinCard />
|
|
<OllamaCard />
|
|
</div>
|
|
|
|
{/* Hosts */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm font-semibold">Hosts</CardTitle>
|
|
<span className="text-[10px] text-muted-foreground">5 Portainer endpoints</span>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-5 gap-2">
|
|
{data && Object.entries(data.containers.endpoints).map(([name, ep]) => (
|
|
<HostCard
|
|
key={name}
|
|
name={name}
|
|
containers={ep.total}
|
|
description={hostDescriptions[name] || ""}
|
|
online={!ep.error}
|
|
/>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 8: Test dashboard page renders**
|
|
|
|
```bash
|
|
cd dashboard/ui
|
|
NEXT_PUBLIC_API_URL=http://localhost:8888 npm run dev &
|
|
# Open http://localhost:3000 — verify dashboard renders with real data
|
|
kill %1
|
|
```
|
|
|
|
- [ ] **Step 9: Commit**
|
|
|
|
```bash
|
|
git add dashboard/ui/
|
|
git commit -m "feat(dashboard): overview page with stats, activity feed, Jellyfin, Ollama, hosts"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Infrastructure Page
|
|
|
|
**Files:**
|
|
- Create: `dashboard/ui/components/data-table.tsx`
|
|
- Create: `dashboard/ui/components/container-logs-modal.tsx`
|
|
- Create: `dashboard/ui/app/infrastructure/page.tsx`
|
|
|
|
- [ ] **Step 1: Create data-table.tsx**
|
|
|
|
```tsx
|
|
// dashboard/ui/components/data-table.tsx
|
|
"use client";
|
|
import { useState } from "react";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
|
|
interface Column<T> {
|
|
key: string;
|
|
label: string;
|
|
render?: (item: T) => React.ReactNode;
|
|
}
|
|
|
|
interface DataTableProps<T> {
|
|
data: T[];
|
|
columns: Column<T>[];
|
|
searchKey?: string;
|
|
filterKey?: string;
|
|
filterOptions?: string[];
|
|
}
|
|
|
|
export function DataTable<T extends Record<string, unknown>>({
|
|
data, columns, searchKey, filterKey, filterOptions,
|
|
}: DataTableProps<T>) {
|
|
const [search, setSearch] = useState("");
|
|
const [filter, setFilter] = useState("");
|
|
|
|
let filtered = data;
|
|
if (search && searchKey) {
|
|
filtered = filtered.filter((item) =>
|
|
String(item[searchKey] ?? "").toLowerCase().includes(search.toLowerCase())
|
|
);
|
|
}
|
|
if (filter && filterKey) {
|
|
filtered = filtered.filter((item) => String(item[filterKey]) === filter);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-3 flex gap-2">
|
|
{searchKey && (
|
|
<input
|
|
type="text"
|
|
placeholder="Search..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="rounded-md border border-border bg-background px-3 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
|
|
/>
|
|
)}
|
|
{filterKey && filterOptions && (
|
|
<select
|
|
value={filter}
|
|
onChange={(e) => setFilter(e.target.value)}
|
|
className="rounded-md border border-border bg-background px-3 py-1.5 text-xs"
|
|
>
|
|
<option value="">All</option>
|
|
{filterOptions.map((opt) => (
|
|
<option key={opt} value={opt}>{opt}</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
{columns.map((col) => (
|
|
<TableHead key={col.key} className="text-xs">{col.label}</TableHead>
|
|
))}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filtered.map((item, i) => (
|
|
<TableRow key={i}>
|
|
{columns.map((col) => (
|
|
<TableCell key={col.key} className="text-xs py-2">
|
|
{col.render ? col.render(item) : String(item[col.key] ?? "")}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
<p className="mt-2 text-[10px] text-muted-foreground">{filtered.length} of {data.length} items</p>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create container-logs-modal.tsx**
|
|
|
|
```tsx
|
|
// dashboard/ui/components/container-logs-modal.tsx
|
|
"use client";
|
|
import { useEffect, useState } from "react";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { fetchAPI } from "@/lib/api";
|
|
|
|
interface ContainerLogsModalProps {
|
|
containerId: string | null;
|
|
endpoint: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function ContainerLogsModal({ containerId, endpoint, onClose }: ContainerLogsModalProps) {
|
|
const [logs, setLogs] = useState<string>("");
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!containerId) return;
|
|
setLoading(true);
|
|
fetchAPI<{ logs: string }>(`/api/containers/${containerId}/logs?endpoint=${endpoint}&tail=100`)
|
|
.then((data) => setLogs(data.logs))
|
|
.catch((e) => setLogs(`Error: ${e.message}`))
|
|
.finally(() => setLoading(false));
|
|
}, [containerId, endpoint]);
|
|
|
|
return (
|
|
<Dialog open={!!containerId} onOpenChange={() => onClose()}>
|
|
<DialogContent className="max-w-4xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-sm">Container Logs — {containerId}</DialogTitle>
|
|
</DialogHeader>
|
|
<ScrollArea className="h-[400px]">
|
|
<pre className="whitespace-pre-wrap text-[11px] font-mono leading-relaxed text-muted-foreground">
|
|
{loading ? "Loading..." : logs || "No logs available"}
|
|
</pre>
|
|
</ScrollArea>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create infrastructure page**
|
|
|
|
```tsx
|
|
// dashboard/ui/app/infrastructure/page.tsx
|
|
"use client";
|
|
import { useState } from "react";
|
|
import { usePoll } from "@/lib/use-poll";
|
|
import { postAPI } from "@/lib/api";
|
|
import { Container } from "@/lib/types";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { DataTable } from "@/components/data-table";
|
|
import { ContainerLogsModal } from "@/components/container-logs-modal";
|
|
|
|
export default function InfrastructurePage() {
|
|
const { data: containers, mutate } = usePoll<Container[]>("/api/containers", 30000);
|
|
const { data: pods } = usePoll<Record<string, string>[]>("/api/olares/pods", 30000);
|
|
const { data: gpu } = usePoll<Record<string, unknown>>("/api/olares/gpu", 60000);
|
|
const [logsModal, setLogsModal] = useState<{ id: string; endpoint: string } | null>(null);
|
|
|
|
const endpoints = [...new Set((containers || []).map((c) => c.endpoint))];
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-semibold">Containers</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<DataTable
|
|
data={containers || []}
|
|
searchKey="name"
|
|
filterKey="endpoint"
|
|
filterOptions={endpoints}
|
|
columns={[
|
|
{ key: "name", label: "Name" },
|
|
{ key: "endpoint", label: "Host", render: (c) => <Badge variant="outline" className="text-[10px]">{c.endpoint}</Badge> },
|
|
{
|
|
key: "state", label: "Status",
|
|
render: (c) => (
|
|
<Badge variant={c.state === "running" ? "default" : "destructive"} className="text-[10px]">
|
|
{c.status}
|
|
</Badge>
|
|
),
|
|
},
|
|
{ key: "image", label: "Image", render: (c) => <span className="text-muted-foreground truncate max-w-[200px] block">{c.image}</span> },
|
|
{
|
|
key: "actions", label: "",
|
|
render: (c) => (
|
|
<div className="flex gap-1">
|
|
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={() => setLogsModal({ id: c.id, endpoint: c.endpoint })}>
|
|
Logs
|
|
</Button>
|
|
<Button
|
|
size="sm" variant="ghost" className="h-6 text-[10px]"
|
|
onClick={async () => { await postAPI(`/api/containers/${c.id}/restart?endpoint=${c.endpoint}`); mutate(); }}
|
|
>
|
|
Restart
|
|
</Button>
|
|
</div>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-semibold">Olares Pods</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<DataTable
|
|
data={pods || []}
|
|
searchKey="name"
|
|
columns={[
|
|
{ key: "namespace", label: "Namespace" },
|
|
{ key: "name", label: "Pod" },
|
|
{ key: "ready", label: "Ready" },
|
|
{ key: "status", label: "Status", render: (p) => <Badge variant={p.status === "Running" ? "default" : "destructive"} className="text-[10px]">{String(p.status)}</Badge> },
|
|
{ key: "age", label: "Age" },
|
|
]}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-semibold">GPU — {String(gpu?.name || "RTX 5090")}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{gpu?.available ? (
|
|
<>
|
|
<div className="grid grid-cols-3 gap-3 text-center">
|
|
<div>
|
|
<p className="text-xl font-bold">{String(gpu.temp_c)}°C</p>
|
|
<p className="text-[10px] text-muted-foreground">Temperature</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xl font-bold">{String(gpu.utilization_pct)}%</p>
|
|
<p className="text-[10px] text-muted-foreground">Utilization</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xl font-bold">{String(gpu.power_w)}W</p>
|
|
<p className="text-[10px] text-muted-foreground">Power</p>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="flex justify-between text-xs mb-1">
|
|
<span>VRAM</span>
|
|
<span>{String(gpu.vram_used_mb)} / {String(gpu.vram_total_mb)} MB</span>
|
|
</div>
|
|
<div className="h-2 rounded-full bg-secondary">
|
|
<div
|
|
className="h-2 rounded-full bg-violet-500"
|
|
style={{ width: `${((Number(gpu.vram_used_mb) / Number(gpu.vram_total_mb)) * 100)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground">GPU unavailable</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{logsModal && (
|
|
<ContainerLogsModal
|
|
containerId={logsModal.id}
|
|
endpoint={logsModal.endpoint}
|
|
onClose={() => setLogsModal(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add dashboard/ui/
|
|
git commit -m "feat(dashboard): infrastructure page with container table, pods, GPU, logs modal"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Media, Automations, and Expenses Pages
|
|
|
|
**Files:**
|
|
- Create: `dashboard/ui/app/media/page.tsx`
|
|
- Create: `dashboard/ui/app/automations/page.tsx`
|
|
- Create: `dashboard/ui/app/expenses/page.tsx`
|
|
|
|
- [ ] **Step 1: Create media page**
|
|
|
|
```tsx
|
|
// dashboard/ui/app/media/page.tsx
|
|
"use client";
|
|
import { usePoll } from "@/lib/use-poll";
|
|
import { JellyfinStatus } from "@/lib/types";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { DataTable } from "@/components/data-table";
|
|
|
|
export default function MediaPage() {
|
|
const { data: jellyfin } = usePoll<JellyfinStatus>("/api/jellyfin/status", 15000);
|
|
const { data: sonarr } = usePoll<{ total: number; items: Record<string, unknown>[] }>("/api/sonarr/queue", 30000);
|
|
const { data: radarr } = usePoll<{ total: number; items: Record<string, unknown>[] }>("/api/radarr/queue", 30000);
|
|
const { data: sab } = usePoll<{ speed: string; items: Record<string, unknown>[] }>("/api/sabnzbd/queue", 30000);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Jellyfin */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm font-semibold">Jellyfin — {jellyfin?.server_name || "..."}</CardTitle>
|
|
<Badge variant="outline" className="text-[10px]">v{jellyfin?.version || "?"}</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{jellyfin?.active_sessions && jellyfin.active_sessions.length > 0 ? (
|
|
<div className="space-y-2 mb-4">
|
|
<p className="text-[10px] uppercase text-violet-400 font-medium">Now Playing</p>
|
|
{jellyfin.active_sessions.map((s, i) => (
|
|
<div key={i} className="flex items-center justify-between rounded-md bg-secondary/50 p-2">
|
|
<div>
|
|
<p className="text-sm font-medium">{s.title}</p>
|
|
<p className="text-[10px] text-muted-foreground">{s.type}</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-xs">{s.user}</p>
|
|
<p className="text-[10px] text-muted-foreground">{s.device} · {s.client}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground mb-4">No active streams</p>
|
|
)}
|
|
<div className="grid grid-cols-4 gap-3">
|
|
{jellyfin?.libraries?.map((lib) => (
|
|
<div key={lib.name} className="rounded-md border border-border p-3">
|
|
<p className="text-xs font-medium">{lib.name}</p>
|
|
<p className="text-[10px] text-muted-foreground">{lib.type}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Download Queues */}
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm font-semibold">Sonarr</CardTitle>
|
|
<Badge variant="outline" className="text-[10px]">{sonarr?.total ?? 0} items</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<DataTable
|
|
data={sonarr?.items || []}
|
|
columns={[
|
|
{ key: "title", label: "Title" },
|
|
{ key: "status", label: "Status" },
|
|
]}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm font-semibold">Radarr</CardTitle>
|
|
<Badge variant="outline" className="text-[10px]">{radarr?.total ?? 0} items</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<DataTable
|
|
data={radarr?.items || []}
|
|
columns={[
|
|
{ key: "title", label: "Title" },
|
|
{ key: "status", label: "Status" },
|
|
]}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm font-semibold">SABnzbd</CardTitle>
|
|
<Badge variant="outline" className="text-[10px]">{sab?.speed || "0"} KB/s</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<DataTable
|
|
data={sab?.items || []}
|
|
columns={[
|
|
{ key: "name", label: "File" },
|
|
{ key: "percentage", label: "Progress" },
|
|
]}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create automations page**
|
|
|
|
```tsx
|
|
// dashboard/ui/app/automations/page.tsx
|
|
"use client";
|
|
import { usePoll } from "@/lib/use-poll";
|
|
import { EmailStats } from "@/lib/types";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { StatusBadge } from "@/components/status-badge";
|
|
|
|
export default function AutomationsPage() {
|
|
const { data: email } = usePoll<EmailStats>("/api/automations/email", 120000);
|
|
const { data: restarts } = usePoll<{ entries: Record<string, unknown>[] }>("/api/automations/restarts", 60000);
|
|
const { data: backup } = usePoll<{ status: string; today_entries?: number; last_line?: string }>("/api/automations/backup", 300000);
|
|
const { data: drift } = usePoll<{ status: string; drifts: number; last_run?: string }>("/api/automations/drift", 300000);
|
|
|
|
const totalEmails = email?.accounts?.reduce((sum, a) => sum + a.today, 0) ?? 0;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Email Organizers */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm font-semibold">Email Organizers</CardTitle>
|
|
<Badge variant="outline" className="text-[10px]">{totalEmails} today</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{email?.accounts?.map((acct) => (
|
|
<div key={acct.account} className="rounded-md border border-border p-3">
|
|
<p className="text-xs font-medium truncate">{acct.account}</p>
|
|
<p className="text-lg font-bold mt-1">{acct.today}</p>
|
|
<div className="flex flex-wrap gap-1 mt-2">
|
|
{Object.entries(acct.categories).map(([cat, count]) => (
|
|
<Badge key={cat} variant="secondary" className="text-[9px]">{cat}: {count}</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{email?.sender_cache && (
|
|
<p className="text-[10px] text-muted-foreground mt-3">
|
|
Sender cache: {Object.values(email.sender_cache).reduce((a, b) => a + b, 0)} known senders
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Status Cards */}
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-semibold">Backup</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<StatusBadge color={backup?.status === "ok" ? "green" : backup?.status === "error" ? "red" : "amber"} label={backup?.status || "unknown"} />
|
|
<p className="text-[10px] text-muted-foreground mt-2">{backup?.last_line || "No data"}</p>
|
|
{backup?.today_entries !== undefined && (
|
|
<p className="text-[10px] text-muted-foreground">{backup.today_entries} log entries today</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-semibold">Config Drift</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<StatusBadge color={drift?.status === "clean" ? "green" : drift?.status === "drifted" ? "red" : "amber"} label={drift?.status || "unknown"} />
|
|
<p className="text-[10px] text-muted-foreground mt-2">{drift?.drifts ?? 0} drifts detected</p>
|
|
{drift?.last_run && <p className="text-[10px] text-muted-foreground">Last run: {drift.last_run}</p>}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-semibold">Stack Restart</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<StatusBadge color={(restarts?.entries?.length ?? 0) > 0 ? "amber" : "green"} label={`${restarts?.entries?.length ?? 0} tracked`} />
|
|
<p className="text-[10px] text-muted-foreground mt-2">
|
|
{restarts?.entries?.length ? "Containers being monitored" : "All containers healthy"}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create expenses page**
|
|
|
|
```tsx
|
|
// dashboard/ui/app/expenses/page.tsx
|
|
"use client";
|
|
import { usePoll } from "@/lib/use-poll";
|
|
import { ExpenseSummary } from "@/lib/types";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { DataTable } from "@/components/data-table";
|
|
|
|
export default function ExpensesPage() {
|
|
const month = new Date().toISOString().slice(0, 7);
|
|
const { data: expenses } = usePoll<{ count: number; expenses: Record<string, string>[] }>(`/api/expenses?month=${month}`, 300000);
|
|
const { data: summary } = usePoll<ExpenseSummary>(`/api/expenses/summary?month=${month}`, 300000);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Monthly Summary */}
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Total Spend</p>
|
|
<p className="mt-1 text-3xl font-bold">${summary?.total?.toFixed(2) ?? "0.00"}</p>
|
|
<p className="text-[10px] text-muted-foreground mt-1">{month}</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Transactions</p>
|
|
<p className="mt-1 text-3xl font-bold">{summary?.count ?? 0}</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Top Vendor</p>
|
|
<p className="mt-1 text-lg font-bold">{summary?.top_vendors?.[0]?.vendor ?? "—"}</p>
|
|
<p className="text-xs text-muted-foreground">${summary?.top_vendors?.[0]?.amount?.toFixed(2) ?? "0"}</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Top Vendors */}
|
|
{summary?.top_vendors && summary.top_vendors.length > 0 && (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-semibold">Top Vendors</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-2">
|
|
{summary.top_vendors.map((v) => (
|
|
<div key={v.vendor} className="flex items-center justify-between">
|
|
<span className="text-xs">{v.vendor}</span>
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-1.5 rounded-full bg-blue-500" style={{ width: `${(v.amount / (summary.total || 1)) * 200}px` }} />
|
|
<span className="text-xs font-medium">${v.amount.toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Transaction Table */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm font-semibold">Transactions</CardTitle>
|
|
<Badge variant="outline" className="text-[10px]">{expenses?.count ?? 0} this month</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<DataTable
|
|
data={expenses?.expenses || []}
|
|
searchKey="vendor"
|
|
columns={[
|
|
{ key: "date", label: "Date" },
|
|
{ key: "vendor", label: "Vendor" },
|
|
{ key: "amount", label: "Amount", render: (e) => <span className="font-medium">${e.amount} {e.currency}</span> },
|
|
{ key: "order_number", label: "Order #" },
|
|
{ key: "email_account", label: "Account", render: (e) => <span className="text-muted-foreground truncate max-w-[120px] block">{String(e.email_account)}</span> },
|
|
]}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add dashboard/ui/
|
|
git commit -m "feat(dashboard): media, automations, and expenses pages"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Docker Build + Integration Test
|
|
|
|
**Files:**
|
|
- Verify: All files from Tasks 1-8
|
|
|
|
- [ ] **Step 1: Build and test API container**
|
|
|
|
```bash
|
|
cd dashboard
|
|
docker compose build dashboard-api
|
|
docker compose up dashboard-api -d
|
|
sleep 3
|
|
curl -s http://localhost:8888/api/health
|
|
curl -s http://localhost:8888/api/stats/overview | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Containers: {d[\"containers\"][\"total\"]}, GPU: {d[\"gpu\"][\"available\"]}')"
|
|
```
|
|
|
|
- [ ] **Step 2: Build and test UI container**
|
|
|
|
```bash
|
|
docker compose build dashboard-ui
|
|
docker compose up -d
|
|
sleep 5
|
|
curl -s -o /dev/null -w '%{http_code}' http://localhost:3000
|
|
# Expected: 200
|
|
```
|
|
|
|
- [ ] **Step 3: Full integration test**
|
|
|
|
Open `http://homelab.tail.vish.gg:3000` and verify:
|
|
- Dashboard tab loads with real container counts, GPU temp, email stats
|
|
- Activity feed shows events (may need to wait for next organizer cron)
|
|
- Infrastructure tab shows container table with Logs and Restart buttons
|
|
- Media tab shows Jellyfin libraries
|
|
- Automations tab shows email organizer stats
|
|
- Expenses tab renders (may be empty if no expense data yet)
|
|
|
|
- [ ] **Step 4: Final commit**
|
|
|
|
```bash
|
|
git add dashboard/
|
|
git commit -m "feat(dashboard): Docker build verified, full integration working"
|
|
git push origin main
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review Checklist
|
|
|
|
- **Spec coverage**: All 5 tabs implemented (Dashboard, Infrastructure, Media, Automations, Expenses). SSE activity feed included. Docker Compose deployment. Dark theme with spec color tokens.
|
|
- **Placeholder scan**: No TBDs. All code blocks complete.
|
|
- **Type consistency**: `OverviewStats`, `Container`, `JellyfinStatus`, `EmailStats`, `ExpenseSummary` used consistently across types.ts, API responses, and component props.
|
|
- **Scope check**: Single project, frontend + backend. 9 tasks, each produces a working commit.
|