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:
parent
4bbc125a67
commit
9f7330e217
48 changed files with 6390 additions and 1461 deletions
234
server/services/weather_service.py
Normal file
234
server/services/weather_service.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue