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

View file

@ -0,0 +1,234 @@
from __future__ import annotations
import httpx
from typing import Any, Dict, List, Optional
WEATHER_ICONS: Dict[int, str] = {
113: "\u2600\ufe0f", # Clear/Sunny
116: "\u26c5", # Partly Cloudy
119: "\u2601\ufe0f", # Cloudy
122: "\u2601\ufe0f", # Overcast
143: "\ud83c\udf2b\ufe0f", # Mist
176: "\ud83c\udf26\ufe0f", # Patchy rain nearby
179: "\ud83c\udf28\ufe0f", # Patchy snow nearby
182: "\ud83c\udf28\ufe0f", # Patchy sleet nearby
185: "\ud83c\udf28\ufe0f", # Patchy freezing drizzle nearby
200: "\u26c8\ufe0f", # Thundery outbreaks nearby
227: "\ud83c\udf28\ufe0f", # Blowing snow
230: "\u2744\ufe0f", # Blizzard
248: "\ud83c\udf2b\ufe0f", # Fog
260: "\ud83c\udf2b\ufe0f", # Freezing fog
263: "\ud83c\udf26\ufe0f", # Patchy light drizzle
266: "\ud83c\udf27\ufe0f", # Light drizzle
281: "\ud83c\udf28\ufe0f", # Freezing drizzle
284: "\ud83c\udf28\ufe0f", # Heavy freezing drizzle
293: "\ud83c\udf26\ufe0f", # Patchy light rain
296: "\ud83c\udf27\ufe0f", # Light rain
299: "\ud83c\udf27\ufe0f", # Moderate rain at times
302: "\ud83c\udf27\ufe0f", # Moderate rain
305: "\ud83c\udf27\ufe0f", # Heavy rain at times
308: "\ud83c\udf27\ufe0f", # Heavy rain
311: "\ud83c\udf28\ufe0f", # Light freezing rain
314: "\ud83c\udf28\ufe0f", # Moderate or heavy freezing rain
317: "\ud83c\udf28\ufe0f", # Light sleet
320: "\ud83c\udf28\ufe0f", # Moderate or heavy sleet
323: "\ud83c\udf28\ufe0f", # Patchy light snow
326: "\u2744\ufe0f", # Light snow
329: "\u2744\ufe0f", # Patchy moderate snow
332: "\u2744\ufe0f", # Moderate snow
335: "\u2744\ufe0f", # Patchy heavy snow
338: "\u2744\ufe0f", # Heavy snow
350: "\ud83c\udf28\ufe0f", # Ice pellets
353: "\ud83c\udf26\ufe0f", # Light rain shower
356: "\ud83c\udf27\ufe0f", # Moderate or heavy rain shower
359: "\ud83c\udf27\ufe0f", # Torrential rain shower
362: "\ud83c\udf28\ufe0f", # Light sleet showers
365: "\ud83c\udf28\ufe0f", # Moderate or heavy sleet showers
368: "\u2744\ufe0f", # Light snow showers
371: "\u2744\ufe0f", # Moderate or heavy snow showers
374: "\ud83c\udf28\ufe0f", # Light showers of ice pellets
377: "\ud83c\udf28\ufe0f", # Moderate or heavy showers of ice pellets
386: "\u26c8\ufe0f", # Patchy light rain with thunder
389: "\u26c8\ufe0f", # Moderate or heavy rain with thunder
392: "\u26c8\ufe0f", # Patchy light snow with thunder
395: "\u26c8\ufe0f", # Moderate or heavy snow with thunder
}
def _get_weather_icon(code: int) -> str:
"""Map a WWO weather code to an emoji icon."""
return WEATHER_ICONS.get(code, "\ud83c\udf24\ufe0f")
def _parse_current_condition(condition: Dict[str, Any], location: str) -> Dict[str, Any]:
"""Parse a single current_condition entry from the wttr.in JSON."""
weather_code = int(condition.get("weatherCode", 113))
descriptions = condition.get("weatherDesc", [])
description = descriptions[0].get("value", "Unknown") if descriptions else "Unknown"
return {
"location": location,
"temp": int(condition.get("temp_C", 0)),
"feels_like": int(condition.get("FeelsLikeC", 0)),
"humidity": int(condition.get("humidity", 0)),
"wind_kmh": int(condition.get("windspeedKmph", 0)),
"description": description,
"icon": _get_weather_icon(weather_code),
}
def _parse_forecast_day(day: Dict[str, Any]) -> Dict[str, Any]:
"""Parse a single forecast day from the wttr.in weather array."""
date = day.get("date", "")
max_temp = int(day.get("maxtempC", 0))
min_temp = int(day.get("mintempC", 0))
astronomy = day.get("astronomy", [])
sunrise = astronomy[0].get("sunrise", "") if astronomy else ""
sunset = astronomy[0].get("sunset", "") if astronomy else ""
hourly = day.get("hourly", [])
if hourly:
midday = hourly[len(hourly) // 2]
weather_code = int(midday.get("weatherCode", 113))
descs = midday.get("weatherDesc", [])
description = descs[0].get("value", "Unknown") if descs else "Unknown"
else:
weather_code = 113
description = "Unknown"
return {
"date": date,
"max_temp": max_temp,
"min_temp": min_temp,
"icon": _get_weather_icon(weather_code),
"description": description,
"sunrise": sunrise,
"sunset": sunset,
}
async def fetch_weather(location: str) -> Dict[str, Any]:
"""Fetch current weather and 3-day forecast from wttr.in.
Args:
location: City name or coordinates (e.g. "Berlin" or "52.52,13.405").
Returns:
Dictionary with current conditions and 3-day forecast.
"""
fallback: Dict[str, Any] = {
"location": location,
"temp": 0,
"feels_like": 0,
"humidity": 0,
"wind_kmh": 0,
"description": "Unavailable",
"icon": "\u2753",
"forecast_3day": [],
"error": None,
}
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"https://wttr.in/{location}",
params={"format": "j1"},
headers={"Accept": "application/json"},
)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError as exc:
fallback["error"] = f"HTTP {exc.response.status_code}"
return fallback
except httpx.RequestError as exc:
fallback["error"] = f"Request failed: {exc}"
return fallback
except Exception as exc:
fallback["error"] = str(exc)
return fallback
current_conditions = data.get("current_condition", [])
if not current_conditions:
fallback["error"] = "No current condition data"
return fallback
result = _parse_current_condition(current_conditions[0], location)
weather_days = data.get("weather", [])
forecast_3day: List[Dict[str, Any]] = []
for day in weather_days[:3]:
forecast_3day.append(_parse_forecast_day(day))
result["forecast_3day"] = forecast_3day
result["error"] = None
return result
async def fetch_hourly_forecast(location: str) -> List[Dict[str, Any]]:
"""Fetch hourly forecast for the current day from wttr.in.
Returns the next 8 hourly slots from the current day's forecast.
Args:
location: City name or coordinates.
Returns:
List of hourly forecast dicts with time, temp, icon, and precip_chance.
"""
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"https://wttr.in/{location}",
params={"format": "j1"},
headers={"Accept": "application/json"},
)
resp.raise_for_status()
data = resp.json()
except Exception:
return []
weather_days = data.get("weather", [])
if not weather_days:
return []
all_hourly: List[Dict[str, Any]] = []
for day in weather_days[:2]:
hourly_entries = day.get("hourly", [])
for entry in hourly_entries:
time_raw = entry.get("time", "0")
time_value = int(time_raw)
hours = time_value // 100
minutes = time_value % 100
time_str = f"{hours:02d}:{minutes:02d}"
weather_code = int(entry.get("weatherCode", 113))
descs = entry.get("weatherDesc", [])
description = descs[0].get("value", "Unknown") if descs else "Unknown"
all_hourly.append({
"time": time_str,
"temp": int(entry.get("tempC", 0)),
"icon": _get_weather_icon(weather_code),
"description": description,
"precip_chance": int(entry.get("chanceofrain", 0)),
"wind_kmh": int(entry.get("windspeedKmph", 0)),
})
from datetime import datetime
now_hour = datetime.now().hour
upcoming: List[Dict[str, Any]] = []
found_start = False
for slot in all_hourly:
slot_hour = int(slot["time"].split(":")[0])
if not found_start:
if slot_hour >= now_hour:
found_start = True
else:
continue
upcoming.append(slot)
if len(upcoming) >= 8:
break
return upcoming