refactor: complete rewrite as React+FastAPI dashboard

Replace monolithic Jinja2 template with modern stack:

Backend (FastAPI):
- Modular router/service architecture
- Async PostgreSQL (asyncpg) for news from n8n pipeline
- Live Unraid server stats (2 servers via API)
- Home Assistant, Vikunja tasks, weather (wttr.in)
- WebSocket broadcast for real-time updates (15s)
- TTL cache per endpoint, all config via ENV vars

Frontend (React + Vite + TypeScript):
- Glassmorphism dark theme with Tailwind CSS
- Responsive grid: mobile/tablet/desktop/ultrawide
- Weather cards, hourly forecast, news with category tabs
- Server stats (CPU ring, RAM bar, Docker list)
- Home Assistant controls, task management
- Live clock, WebSocket connection indicator

Infrastructure:
- Multi-stage Dockerfile (node:22-alpine + python:3.11-slim)
- docker-compose with full ENV configuration
- Kaniko CI/CD pipeline for GitLab registry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sam 2026-03-02 01:48:51 +01:00
parent 4bbc125a67
commit 9f7330e217
48 changed files with 6390 additions and 1461 deletions

85
server/routers/weather.py Normal file
View file

@ -0,0 +1,85 @@
"""Weather data router -- primary + secondary locations and hourly forecast."""
from __future__ import annotations
import asyncio
import logging
from typing import Any, Dict, List
from fastapi import APIRouter
from server.cache import cache
from server.config import settings
from server.services.weather_service import fetch_hourly_forecast, fetch_weather
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["weather"])
CACHE_KEY = "weather"
@router.get("/weather")
async def get_weather() -> Dict[str, Any]:
"""Return weather for both configured locations plus an hourly forecast.
The response shape is::
{
"primary": { ... weather dict or error stub },
"secondary": { ... weather dict or error stub },
"hourly": [ ... forecast entries or empty list ],
}
"""
# --- cache hit? -----------------------------------------------------------
cached = await cache.get(CACHE_KEY)
if cached is not None:
return cached
# --- cache miss -- fetch all three in parallel ----------------------------
primary_data: Dict[str, Any] = {}
secondary_data: Dict[str, Any] = {}
hourly_data: List[Dict[str, Any]] = []
results = await asyncio.gather(
_safe_fetch_weather(settings.weather_location),
_safe_fetch_weather(settings.weather_location_secondary),
_safe_fetch_hourly(settings.weather_location),
return_exceptions=False, # we handle errors inside the helpers
)
primary_data = results[0]
secondary_data = results[1]
hourly_data = results[2]
payload: Dict[str, Any] = {
"primary": primary_data,
"secondary": secondary_data,
"hourly": hourly_data,
}
await cache.set(CACHE_KEY, payload, settings.weather_cache_ttl)
return payload
# -- internal helpers ---------------------------------------------------------
async def _safe_fetch_weather(location: str) -> Dict[str, Any]:
"""Fetch weather for *location*, returning an error stub on failure."""
try:
data = await fetch_weather(location)
return data
except Exception as exc:
logger.exception("Failed to fetch weather for %s", location)
return {"error": True, "message": str(exc), "location": location}
async def _safe_fetch_hourly(location: str) -> List[Dict[str, Any]]:
"""Fetch hourly forecast for *location*, returning ``[]`` on failure."""
try:
data = await fetch_hourly_forecast(location)
return data
except Exception as exc:
logger.exception("Failed to fetch hourly forecast for %s", location)
return []