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

29
.gitignore vendored Normal file
View file

@ -0,0 +1,29 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
# Node / Frontend
web/node_modules/
web/dist/
# IDE
.vscode/
.idea/
# Environment
.env
.env.*
!.env.example
# Docker
*.log
# OS
.DS_Store
Thumbs.db
# Claude
.claude/

View file

@ -11,7 +11,7 @@ docker-build:
name: gcr.io/kaniko-project/executor:v1.23.2-debug
entrypoint: [""]
rules:
- if: $CI_COMMIT_BRANCH
- if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH
before_script:
- mkdir -p /kaniko/.docker
- |
@ -27,14 +27,15 @@ docker-build:
EOF
script:
- |
TAG="$CI_COMMIT_REF_SLUG"
DESTINATIONS="--destination=$IMAGE_NAME:$CI_COMMIT_SHA --destination=$IMAGE_NAME:$TAG"
if [ "$CI_COMMIT_REF_NAME" = "master" ]; then
DESTINATIONS="$DESTINATIONS --destination=$IMAGE_NAME:latest"
if [ "$CI_COMMIT_REF_NAME" = "main" ] || [ "$CI_COMMIT_REF_NAME" = "master" ]; then
TAG="latest"
else
TAG="$CI_COMMIT_REF_SLUG"
fi
DESTINATIONS="--destination=$IMAGE_NAME:$CI_COMMIT_SHA --destination=$IMAGE_NAME:$TAG"
echo "Building daily-briefing for ref $CI_COMMIT_REF_NAME with tag $TAG"
echo "Using registry image: $IMAGE_NAME"
/kaniko/executor \
--context "$CI_PROJECT_DIR" \

View file

@ -1,23 +1,21 @@
FROM python:3.11-slim
# Stage 1: Build React Frontend
FROM node:22-alpine AS frontend
WORKDIR /app/web
COPY web/package*.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
# Stage 2: Python Backend + Static Files
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY src/ ./src/
COPY templates/ ./templates/
COPY static/ ./static/
# Environment variables (will be overridden at runtime)
ENV VIKUNJA_URL=http://10.10.10.10:3456/api/v1
ENV VIKUNJA_TOKEN=""
ENV HA_URL=https://homeassistant.daddelolymp.de
ENV HA_TOKEN=""
ENV WEATHER_LOCATION=Leverkusen
COPY server/ ./server/
COPY --from=frontend /app/web/dist ./static/
EXPOSE 8080
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8080"]
CMD ["uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8080"]

View file

@ -1,5 +1,3 @@
version: '3.8'
services:
daily-briefing:
build: .
@ -7,25 +5,23 @@ services:
ports:
- "8080:8080"
environment:
- VIKUNJA_URL=http://10.10.10.10:3456/api/v1
- VIKUNJA_TOKEN=tk_dfaf845721a9fabe0656960ab77fd57cba127f8d
- HA_URL=https://homeassistant.daddelolymp.de
- HA_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkZmM4M2Q5OTZiMDE0Mzg3YWEzZTMwYzkzYTNhNTRjNiIsImlhdCI6MTc3MDU5Mjk3NiwiZXhwIjoyMDg1OTUyOTc2fQ.fnldrKNQwVdz275-omj93FldpywEpfPQSq8VLcmcyu4
# Database (PostgreSQL)
- DB_HOST=10.10.10.10
- DB_PORT=5433
- DB_NAME=openclaw
- DB_USER=sam
- DB_PASSWORD=sam
# Weather
- WEATHER_LOCATION=Leverkusen
- OPENCLAW_GATEWAY_URL=http://host.docker.internal:18789
- DASHBOARD_DATA_PATH=/app/data/dashboard_data.json
# Discord Integration
- DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/1470494144953581652/7g3rq2p-ynwTR9KyUhYwubIZL75NQkOR_xnXOvSsuY72qwUjmsSokfSS3Y0wae2veMem
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-}
- DISCORD_CHANNEL_ID=${DISCORD_CHANNEL_ID:-}
volumes:
- /home/sam/briefing/dashboard_data.json:/app/data/dashboard_data.json:ro
- WEATHER_LOCATION_SECONDARY=Rab,Croatia
# Home Assistant
- HA_URL=https://homeassistant.daddelolymp.de
- HA_TOKEN=${HA_TOKEN}
# Vikunja Tasks
- VIKUNJA_URL=http://10.10.10.10:3456/api/v1
- VIKUNJA_TOKEN=${VIKUNJA_TOKEN}
# Unraid Servers (JSON array)
- UNRAID_SERVERS=${UNRAID_SERVERS:-[]}
extra_hosts:
- "host.docker.internal:host-gateway"
restart: always
networks:
- briefing-network
networks:
briefing-network:
driver: bridge

View file

@ -1,7 +1,6 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
jinja2==3.1.2
httpx==0.25.2
python-multipart==0.0.6
websockets==12.0
psutil==5.9.6
fastapi==0.115.0
uvicorn[standard]==0.34.0
httpx==0.28.1
asyncpg==0.30.0
jinja2==3.1.5
websockets==14.2

0
server/__init__.py Normal file
View file

41
server/cache.py Normal file
View file

@ -0,0 +1,41 @@
"""Simple async-safe TTL cache."""
from __future__ import annotations
import asyncio
import time
from typing import Any, Dict, Optional, Tuple
class TTLCache:
"""Thread/async-safe in-memory cache with per-key TTL."""
def __init__(self) -> None:
self._store: Dict[str, Tuple[Any, float]] = {}
self._lock = asyncio.Lock()
async def get(self, key: str) -> Optional[Any]:
async with self._lock:
entry = self._store.get(key)
if entry is None:
return None
value, expires_at = entry
if time.time() > expires_at:
del self._store[key]
return None
return value
async def set(self, key: str, value: Any, ttl: int) -> None:
async with self._lock:
self._store[key] = (value, time.time() + ttl)
async def invalidate(self, key: str) -> None:
async with self._lock:
self._store.pop(key, None)
async def clear(self) -> None:
async with self._lock:
self._store.clear()
cache = TTLCache()

98
server/config.py Normal file
View file

@ -0,0 +1,98 @@
"""Centralized configuration via environment variables."""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from typing import List
@dataclass
class UnraidServer:
name: str
host: str
api_key: str = ""
port: int = 80
@dataclass
class Settings:
# --- Database (PostgreSQL) ---
db_host: str = "10.10.10.10"
db_port: int = 5433
db_name: str = "openclaw"
db_user: str = "sam"
db_password: str = "sam"
# --- Weather ---
weather_location: str = "Leverkusen"
weather_location_secondary: str = "Rab,Croatia"
weather_cache_ttl: int = 1800 # 30 min
# --- Home Assistant ---
ha_url: str = "https://homeassistant.daddelolymp.de"
ha_token: str = ""
ha_cache_ttl: int = 30
# --- Vikunja Tasks ---
vikunja_url: str = "http://10.10.10.10:3456/api/v1"
vikunja_token: str = ""
vikunja_cache_ttl: int = 60
# --- Unraid Servers ---
unraid_servers: List[UnraidServer] = field(default_factory=list)
unraid_cache_ttl: int = 15
# --- News ---
news_cache_ttl: int = 300 # 5 min
news_max_age_hours: int = 48
# --- Server ---
host: str = "0.0.0.0"
port: int = 8080
debug: bool = False
@classmethod
def from_env(cls) -> "Settings":
s = cls()
s.db_host = os.getenv("DB_HOST", s.db_host)
s.db_port = int(os.getenv("DB_PORT", str(s.db_port)))
s.db_name = os.getenv("DB_NAME", s.db_name)
s.db_user = os.getenv("DB_USER", s.db_user)
s.db_password = os.getenv("DB_PASSWORD", s.db_password)
s.weather_location = os.getenv("WEATHER_LOCATION", s.weather_location)
s.weather_location_secondary = os.getenv(
"WEATHER_LOCATION_SECONDARY", s.weather_location_secondary
)
s.ha_url = os.getenv("HA_URL", s.ha_url)
s.ha_token = os.getenv("HA_TOKEN", s.ha_token)
s.vikunja_url = os.getenv("VIKUNJA_URL", s.vikunja_url)
s.vikunja_token = os.getenv("VIKUNJA_TOKEN", s.vikunja_token)
s.debug = os.getenv("DEBUG", "").lower() in ("1", "true", "yes")
# Parse UNRAID_SERVERS JSON
raw = os.getenv("UNRAID_SERVERS", "[]")
try:
servers_data = json.loads(raw)
s.unraid_servers = [
UnraidServer(
name=srv.get("name", f"Server {i+1}"),
host=srv.get("host", ""),
api_key=srv.get("api_key", ""),
port=int(srv.get("port", 80)),
)
for i, srv in enumerate(servers_data)
if srv.get("host")
]
except (json.JSONDecodeError, TypeError):
s.unraid_servers = []
return s
settings = Settings.from_env()

89
server/main.py Normal file
View file

@ -0,0 +1,89 @@
"""Daily Briefing Dashboard — FastAPI Application."""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from server.config import settings
from server.services import news_service
logger = logging.getLogger("daily-briefing")
logging.basicConfig(
level=logging.DEBUG if settings.debug else logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup / shutdown lifecycle."""
logger.info("Starting Daily Briefing Dashboard...")
logger.info(
"Unraid servers configured: %d",
len(settings.unraid_servers),
)
# Initialize database pool
try:
await news_service.init_pool(
host=settings.db_host,
port=settings.db_port,
dbname=settings.db_name,
user=settings.db_user,
password=settings.db_password,
)
logger.info("Database pool initialized")
except Exception:
logger.exception("Failed to initialize database pool — news will be unavailable")
yield
# Shutdown
logger.info("Shutting down...")
await news_service.close_pool()
app = FastAPI(
title="Daily Briefing",
version="2.0.0",
lifespan=lifespan,
)
# CORS — allow frontend dev server
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- Register Routers ---
from server.routers import dashboard, homeassistant, news, servers, tasks, weather # noqa: E402
app.include_router(weather.router)
app.include_router(news.router)
app.include_router(servers.router)
app.include_router(homeassistant.router)
app.include_router(tasks.router)
app.include_router(dashboard.router)
# --- Serve static frontend (production) ---
static_dir = Path(__file__).parent.parent / "static"
if static_dir.is_dir():
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
logger.info("Serving static frontend from %s", static_dir)
else:
@app.get("/")
async def root():
return {
"status": "ok",
"message": "Daily Briefing API — Frontend not built yet",
"endpoints": ["/api/all", "/api/weather", "/api/news", "/api/servers", "/api/ha", "/api/tasks"],
}

View file

123
server/routers/dashboard.py Normal file
View file

@ -0,0 +1,123 @@
"""Dashboard aggregate router -- combined endpoint and WebSocket push."""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from server.routers.homeassistant import get_ha
from server.routers.news import get_news_articles
from server.routers.servers import get_servers
from server.routers.tasks import get_tasks
from server.routers.weather import get_weather
logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"])
# Connected WebSocket clients
clients: List[WebSocket] = []
@router.get("/api/all")
async def get_all() -> Dict[str, Any]:
"""Fetch every data source in parallel and return a single combined dict.
Response shape::
{
"weather": { ... },
"news": { ... },
"servers": { ... },
"ha": { ... },
"tasks": { ... },
"timestamp": "ISO-8601 string"
}
Individual sections that fail will contain ``{"error": true, "message": "..."}``.
"""
results = await asyncio.gather(
_safe(get_weather, "weather"),
_safe(lambda: get_news_articles(limit=20, offset=0, category=None), "news"),
_safe(get_servers, "servers"),
_safe(get_ha, "ha"),
_safe(get_tasks, "tasks"),
)
weather_data, news_data, servers_data, ha_data, tasks_data = results
return {
"weather": weather_data,
"news": news_data,
"servers": servers_data,
"ha": ha_data,
"tasks": tasks_data,
"timestamp": datetime.now(timezone.utc).isoformat(),
}
@router.websocket("/ws")
async def ws_endpoint(ws: WebSocket) -> None:
"""WebSocket that pushes fresh dashboard data on every client ping.
The client should send periodic text messages (e.g. ``"ping"``) to request
an update. If no message arrives within 20 seconds the server sends a
refresh anyway, keeping the connection alive.
"""
await ws.accept()
clients.append(ws)
logger.info("WebSocket client connected (%d total)", len(clients))
try:
while True:
# Wait for a client ping / keepalive; refresh on timeout too.
try:
_msg = await asyncio.wait_for(ws.receive_text(), timeout=20.0)
except asyncio.TimeoutError:
pass
# Build and push the latest data
try:
data = await get_all()
await ws.send_json(data)
except Exception as exc:
logger.exception("Error sending WebSocket payload")
# Try to send a lightweight error frame; if that also fails the
# outer handler will close the connection.
try:
await ws.send_json({"error": True, "message": str(exc)})
except Exception:
break
except WebSocketDisconnect:
logger.info("WebSocket client disconnected")
except Exception as exc:
logger.exception("Unexpected WebSocket error")
finally:
if ws in clients:
clients.remove(ws)
logger.info("WebSocket clients remaining: %d", len(clients))
# -- internal helpers ---------------------------------------------------------
async def _safe(coro_or_callable: Any, label: str) -> Dict[str, Any]:
"""Call an async function and return its result, or an error dict."""
try:
if asyncio.iscoroutinefunction(coro_or_callable):
return await coro_or_callable()
# Support lambdas that return coroutines
result = coro_or_callable()
if asyncio.iscoroutine(result):
return await result
return result
except Exception as exc:
logger.exception("Failed to fetch %s data for dashboard", label)
return {"error": True, "message": str(exc)}

View file

@ -0,0 +1,47 @@
"""Home Assistant data router."""
from __future__ import annotations
import logging
from typing import Any, Dict
from fastapi import APIRouter
from server.cache import cache
from server.config import settings
from server.services.ha_service import fetch_ha_data
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["homeassistant"])
CACHE_KEY = "ha"
@router.get("/ha")
async def get_ha() -> Dict[str, Any]:
"""Return Home Assistant entity data.
The exact shape depends on what ``fetch_ha_data`` returns; on failure an
error stub is returned instead::
{ "error": true, "message": "..." }
"""
# --- cache hit? -----------------------------------------------------------
cached = await cache.get(CACHE_KEY)
if cached is not None:
return cached
# --- cache miss -----------------------------------------------------------
try:
data: Dict[str, Any] = await fetch_ha_data(
settings.ha_url,
settings.ha_token,
)
except Exception as exc:
logger.exception("Failed to fetch Home Assistant data")
return {"error": True, "message": str(exc)}
await cache.set(CACHE_KEY, data, settings.ha_cache_ttl)
return data

80
server/routers/news.py Normal file
View file

@ -0,0 +1,80 @@
"""News articles router -- paginated, filterable by category."""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Query
from server.cache import cache
from server.config import settings
from server.services.news_service import get_news, get_news_count
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["news"])
def _cache_key(limit: int, offset: int, category: Optional[str]) -> str:
return f"news:{limit}:{offset}:{category}"
@router.get("/news")
async def get_news_articles(
limit: int = Query(default=20, le=50, ge=1),
offset: int = Query(default=0, ge=0),
category: Optional[str] = Query(default=None),
) -> Dict[str, Any]:
"""Return a paginated list of news articles.
Response shape::
{
"articles": [ ... ],
"total": int,
"limit": int,
"offset": int,
}
"""
key = _cache_key(limit, offset, category)
# --- cache hit? -----------------------------------------------------------
cached = await cache.get(key)
if cached is not None:
return cached
# --- cache miss -----------------------------------------------------------
articles: List[Dict[str, Any]] = []
total: int = 0
try:
articles = await get_news(limit=limit, offset=offset, category=category, max_age_hours=settings.news_max_age_hours)
except Exception as exc:
logger.exception("Failed to fetch news articles")
return {
"articles": [],
"total": 0,
"limit": limit,
"offset": offset,
"error": True,
"message": str(exc),
}
try:
total = await get_news_count(max_age_hours=settings.news_max_age_hours, category=category)
except Exception as exc:
logger.exception("Failed to fetch news count")
# We still have articles -- return them with total = len(articles)
total = len(articles)
payload: Dict[str, Any] = {
"articles": articles,
"total": total,
"limit": limit,
"offset": offset,
}
await cache.set(key, payload, settings.news_cache_ttl)
return payload

64
server/routers/servers.py Normal file
View file

@ -0,0 +1,64 @@
"""Unraid servers status router."""
from __future__ import annotations
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.unraid_service import ServerConfig, fetch_all_servers
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["servers"])
CACHE_KEY = "servers"
@router.get("/servers")
async def get_servers() -> Dict[str, Any]:
"""Return status information for all configured Unraid servers.
Response shape::
{
"servers": [ ... server dicts ... ]
}
"""
# --- cache hit? -----------------------------------------------------------
cached = await cache.get(CACHE_KEY)
if cached is not None:
return cached
# --- cache miss -----------------------------------------------------------
server_configs: List[ServerConfig] = [
ServerConfig(
name=srv.name,
host=srv.host,
api_key=srv.api_key,
port=srv.port,
)
for srv in settings.unraid_servers
]
servers_data: List[Dict[str, Any]] = []
try:
servers_data = await fetch_all_servers(server_configs)
except Exception as exc:
logger.exception("Failed to fetch Unraid server data")
return {
"servers": [],
"error": True,
"message": str(exc),
}
payload: Dict[str, Any] = {
"servers": servers_data,
}
await cache.set(CACHE_KEY, payload, settings.unraid_cache_ttl)
return payload

47
server/routers/tasks.py Normal file
View file

@ -0,0 +1,47 @@
"""Vikunja tasks router."""
from __future__ import annotations
import logging
from typing import Any, Dict
from fastapi import APIRouter
from server.cache import cache
from server.config import settings
from server.services.vikunja_service import fetch_tasks
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["tasks"])
CACHE_KEY = "tasks"
@router.get("/tasks")
async def get_tasks() -> Dict[str, Any]:
"""Return Vikunja task data.
The exact shape depends on what ``fetch_tasks`` returns; on failure an
error stub is returned instead::
{ "error": true, "message": "..." }
"""
# --- cache hit? -----------------------------------------------------------
cached = await cache.get(CACHE_KEY)
if cached is not None:
return cached
# --- cache miss -----------------------------------------------------------
try:
data: Dict[str, Any] = await fetch_tasks(
settings.vikunja_url,
settings.vikunja_token,
)
except Exception as exc:
logger.exception("Failed to fetch Vikunja tasks")
return {"error": True, "message": str(exc)}
await cache.set(CACHE_KEY, data, settings.vikunja_cache_ttl)
return data

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 []

View file

View file

@ -0,0 +1,149 @@
from __future__ import annotations
import httpx
from typing import Any, Dict, List, Optional
def _friendly_name(entity: Dict[str, Any]) -> str:
"""Extract the friendly name from an entity's attributes, falling back to entity_id."""
attrs = entity.get("attributes", {})
return attrs.get("friendly_name", entity.get("entity_id", "unknown"))
def _parse_light(entity: Dict[str, Any]) -> Dict[str, Any]:
"""Parse a light entity into a normalised dictionary."""
attrs = entity.get("attributes", {})
state = entity.get("state", "unknown")
brightness_raw = attrs.get("brightness")
brightness_pct: Optional[int] = None
if brightness_raw is not None:
try:
brightness_pct = round(int(brightness_raw) / 255 * 100)
except (ValueError, TypeError):
brightness_pct = None
return {
"entity_id": entity.get("entity_id", ""),
"name": _friendly_name(entity),
"state": state,
"brightness": brightness_pct,
"color_mode": attrs.get("color_mode"),
}
def _parse_cover(entity: Dict[str, Any]) -> Dict[str, Any]:
"""Parse a cover entity into a normalised dictionary."""
attrs = entity.get("attributes", {})
return {
"entity_id": entity.get("entity_id", ""),
"name": _friendly_name(entity),
"state": entity.get("state", "unknown"),
"current_position": attrs.get("current_position"),
}
def _parse_sensor(entity: Dict[str, Any]) -> Dict[str, Any]:
"""Parse a temperature sensor entity into a normalised dictionary."""
attrs = entity.get("attributes", {})
state_value = entity.get("state", "unknown")
try:
state_value = round(float(state_value), 1)
except (ValueError, TypeError):
pass
return {
"entity_id": entity.get("entity_id", ""),
"name": _friendly_name(entity),
"state": state_value,
"unit": attrs.get("unit_of_measurement", ""),
"device_class": attrs.get("device_class", ""),
}
async def fetch_ha_data(url: str, token: str) -> Dict[str, Any]:
"""Fetch and categorise entity states from a Home Assistant instance.
Args:
url: Base URL of the Home Assistant instance (e.g. ``http://192.168.1.100:8123``).
token: Long-lived access token for authentication.
Returns:
Dictionary containing:
- ``online``: Whether the HA instance is reachable.
- ``lights``: List of light entities with state and brightness.
- ``covers``: List of cover entities with state and position.
- ``sensors``: List of temperature sensor entities.
- ``lights_on``: Count of lights currently in the ``on`` state.
- ``lights_total``: Total number of light entities.
- ``error``: Error message if the request failed, else ``None``.
"""
result: Dict[str, Any] = {
"online": False,
"lights": [],
"covers": [],
"sensors": [],
"lights_on": 0,
"lights_total": 0,
"error": None,
}
if not url or not token:
result["error"] = "Missing Home Assistant URL or token"
return result
base_url = url.rstrip("/")
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
try:
async with httpx.AsyncClient(timeout=15, verify=False) as client:
resp = await client.get(f"{base_url}/api/states", headers=headers)
resp.raise_for_status()
entities: List[Dict[str, Any]] = resp.json()
except httpx.HTTPStatusError as exc:
result["error"] = f"HTTP {exc.response.status_code}"
return result
except httpx.RequestError as exc:
result["error"] = f"Connection failed: {exc}"
return result
except Exception as exc:
result["error"] = str(exc)
return result
result["online"] = True
lights: List[Dict[str, Any]] = []
covers: List[Dict[str, Any]] = []
sensors: List[Dict[str, Any]] = []
for entity in entities:
entity_id: str = entity.get("entity_id", "")
domain = entity_id.split(".")[0] if "." in entity_id else ""
attrs = entity.get("attributes", {})
state = entity.get("state", "")
if state in ("unavailable", "unknown"):
continue
if domain == "light":
lights.append(_parse_light(entity))
elif domain == "cover":
covers.append(_parse_cover(entity))
elif domain == "sensor":
device_class = attrs.get("device_class", "")
if device_class == "temperature":
sensors.append(_parse_sensor(entity))
lights_on = sum(1 for light in lights if light["state"] == "on")
result["lights"] = lights
result["covers"] = covers
result["sensors"] = sensors
result["lights_on"] = lights_on
result["lights_total"] = len(lights)
return result

View file

@ -0,0 +1,153 @@
from __future__ import annotations
import asyncpg
from typing import Any, Dict, List, Optional
_pool: Optional[asyncpg.Pool] = None
async def init_pool(
host: str,
port: int,
dbname: str,
user: str,
password: str,
) -> None:
"""Initialise the global asyncpg connection pool.
Call once at application startup.
"""
global _pool
_pool = await asyncpg.create_pool(
host=host,
port=port,
database=dbname,
user=user,
password=password,
min_size=1,
max_size=5,
)
async def close_pool() -> None:
"""Close the global asyncpg connection pool.
Call once at application shutdown.
"""
global _pool
if _pool is not None:
await _pool.close()
_pool = None
def _row_to_dict(row: asyncpg.Record) -> Dict[str, Any]:
"""Convert an asyncpg Record to a plain dictionary with JSON-safe values."""
d: Dict[str, Any] = dict(row)
if "published_at" in d and d["published_at"] is not None:
d["published_at"] = d["published_at"].isoformat()
return d
async def get_news(
limit: int = 20,
offset: int = 0,
category: Optional[str] = None,
max_age_hours: int = 48,
) -> List[Dict[str, Any]]:
"""Fetch recent news articles from the market_news table.
Args:
limit: Maximum number of rows to return.
offset: Number of rows to skip (for pagination).
category: Optional category filter (exact match).
max_age_hours: Only return articles published within this many hours.
Returns:
List of news article dictionaries.
"""
if _pool is None:
raise RuntimeError("Database pool is not initialised. Call init_pool() first.")
params: List[Any] = []
param_idx = 1
base_query = (
"SELECT id, source, title, url, category, published_at "
"FROM market_news "
f"WHERE published_at > NOW() - INTERVAL '{int(max_age_hours)} hours'"
)
if category is not None:
base_query += f" AND category = ${param_idx}"
params.append(category)
param_idx += 1
base_query += f" ORDER BY published_at DESC LIMIT ${param_idx} OFFSET ${param_idx + 1}"
params.append(limit)
params.append(offset)
async with _pool.acquire() as conn:
rows = await conn.fetch(base_query, *params)
return [_row_to_dict(row) for row in rows]
async def get_news_count(
max_age_hours: int = 48,
category: Optional[str] = None,
) -> int:
"""Return the total count of recent news articles.
Args:
max_age_hours: Only count articles published within this many hours.
category: Optional category filter.
Returns:
Integer count.
"""
if _pool is None:
raise RuntimeError("Database pool is not initialised. Call init_pool() first.")
params: List[Any] = []
param_idx = 1
query = (
"SELECT COUNT(*) AS cnt "
"FROM market_news "
f"WHERE published_at > NOW() - INTERVAL '{int(max_age_hours)} hours'"
)
if category is not None:
query += f" AND category = ${param_idx}"
params.append(category)
async with _pool.acquire() as conn:
row = await conn.fetchrow(query, *params)
return int(row["cnt"]) if row else 0
async def get_categories(max_age_hours: int = 48) -> List[str]:
"""Return distinct categories from recent news articles.
Args:
max_age_hours: Only consider articles published within this many hours.
Returns:
Sorted list of category strings.
"""
if _pool is None:
raise RuntimeError("Database pool is not initialised. Call init_pool() first.")
query = (
"SELECT DISTINCT category "
"FROM market_news "
f"WHERE published_at > NOW() - INTERVAL '{int(max_age_hours)} hours' "
"AND category IS NOT NULL "
"ORDER BY category"
)
async with _pool.acquire() as conn:
rows = await conn.fetch(query)
return [row["category"] for row in rows]

View file

@ -0,0 +1,233 @@
from __future__ import annotations
import asyncio
import httpx
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
@dataclass
class ServerConfig:
"""Configuration for a single Unraid server."""
name: str
host: str
api_key: str = ""
port: int = 80
def _empty_stats(server: ServerConfig) -> Dict[str, Any]:
"""Return a default stats dictionary for a server that has not yet been queried."""
return {
"name": server.name,
"host": server.host,
"online": False,
"uptime": "",
"cpu": {"usage_pct": 0, "cores": 0, "temp_c": None},
"ram": {"used_gb": 0, "total_gb": 0, "pct": 0},
"array": {"status": "unknown", "disks": []},
"docker": {"running": 0, "containers": []},
"error": None,
}
def _parse_system_info(data: Dict[str, Any], result: Dict[str, Any]) -> None:
"""Populate *result* from a generic ``/api/system`` JSON response."""
result["online"] = True
result["uptime"] = data.get("uptime", "")
cpu_data = data.get("cpu", {})
result["cpu"]["usage_pct"] = cpu_data.get("usage_pct", cpu_data.get("usage", 0))
result["cpu"]["cores"] = cpu_data.get("cores", 0)
result["cpu"]["temp_c"] = cpu_data.get("temp_c", cpu_data.get("temp", None))
ram_data = data.get("ram", data.get("memory", {}))
result["ram"]["used_gb"] = round(ram_data.get("used_gb", ram_data.get("used", 0)), 2)
result["ram"]["total_gb"] = round(ram_data.get("total_gb", ram_data.get("total", 0)), 2)
total = result["ram"]["total_gb"]
if total > 0:
result["ram"]["pct"] = round(result["ram"]["used_gb"] / total * 100, 1)
else:
result["ram"]["pct"] = 0
def _parse_array_info(data: Dict[str, Any], result: Dict[str, Any]) -> None:
"""Populate array information from an API response."""
array_data = data.get("array", {})
result["array"]["status"] = array_data.get("status", "unknown")
disks_raw: List[Dict[str, Any]] = array_data.get("disks", [])
parsed_disks: List[Dict[str, Any]] = []
for disk in disks_raw:
parsed_disks.append({
"name": disk.get("name", ""),
"status": disk.get("status", "unknown"),
"size": disk.get("size", ""),
"used": disk.get("used", ""),
"temp_c": disk.get("temp_c", None),
})
result["array"]["disks"] = parsed_disks
def _parse_docker_info(data: Dict[str, Any], result: Dict[str, Any]) -> None:
"""Populate Docker container information from an API response."""
docker_data = data.get("docker", {})
containers_raw: List[Dict[str, Any]] = docker_data.get("containers", [])
containers: List[Dict[str, Any]] = []
running_count = 0
for container in containers_raw:
status = container.get("status", "unknown")
is_running = "running" in status.lower() if isinstance(status, str) else False
if is_running:
running_count += 1
containers.append({
"name": container.get("name", ""),
"status": status,
"image": container.get("image", ""),
"running": is_running,
})
result["docker"]["running"] = docker_data.get("running", running_count)
result["docker"]["containers"] = containers
async def _try_api_endpoint(
client: httpx.AsyncClient,
server: ServerConfig,
result: Dict[str, Any],
) -> bool:
"""Attempt to fetch stats via the Unraid OS API.
Returns True if successful, False otherwise.
"""
if not server.api_key:
return False
headers = {"Authorization": f"Bearer {server.api_key}"}
base = f"http://{server.host}:{server.port}"
try:
resp = await client.get(f"{base}/api/system", headers=headers)
if resp.status_code == 200:
data = resp.json()
_parse_system_info(data, result)
_parse_array_info(data, result)
_parse_docker_info(data, result)
return True
except Exception:
pass
# Try individual endpoints if the combined one failed
fetched_any = False
try:
resp = await client.get(f"{base}/api/cpu", headers=headers)
if resp.status_code == 200:
cpu_data = resp.json()
result["cpu"]["usage_pct"] = cpu_data.get("usage_pct", cpu_data.get("usage", 0))
result["cpu"]["cores"] = cpu_data.get("cores", 0)
result["cpu"]["temp_c"] = cpu_data.get("temp_c", None)
result["online"] = True
fetched_any = True
except Exception:
pass
try:
resp = await client.get(f"{base}/api/memory", headers=headers)
if resp.status_code == 200:
ram_data = resp.json()
result["ram"]["used_gb"] = round(ram_data.get("used_gb", ram_data.get("used", 0)), 2)
result["ram"]["total_gb"] = round(ram_data.get("total_gb", ram_data.get("total", 0)), 2)
total = result["ram"]["total_gb"]
if total > 0:
result["ram"]["pct"] = round(result["ram"]["used_gb"] / total * 100, 1)
result["online"] = True
fetched_any = True
except Exception:
pass
try:
resp = await client.get(f"{base}/api/array", headers=headers)
if resp.status_code == 200:
_parse_array_info(resp.json(), result)
result["online"] = True
fetched_any = True
except Exception:
pass
try:
resp = await client.get(f"{base}/api/docker", headers=headers)
if resp.status_code == 200:
_parse_docker_info(resp.json(), result)
result["online"] = True
fetched_any = True
except Exception:
pass
return fetched_any
async def _try_connectivity_check(
client: httpx.AsyncClient,
server: ServerConfig,
result: Dict[str, Any],
) -> None:
"""Perform a basic HTTP connectivity check as a fallback."""
try:
resp = await client.get(
f"http://{server.host}:{server.port}/",
follow_redirects=True,
)
result["online"] = resp.status_code < 500
except Exception:
result["online"] = False
async def fetch_server_stats(server: ServerConfig) -> Dict[str, Any]:
"""Fetch system stats from an Unraid server.
Tries the Unraid API first (if ``api_key`` is configured), then falls back
to a simple HTTP connectivity check.
Args:
server: A :class:`ServerConfig` describing the target server.
Returns:
Dictionary with server name, host, online status, and detailed stats
for CPU, RAM, array, and Docker containers.
"""
result = _empty_stats(server)
if not server.host:
result["error"] = "No host configured"
return result
try:
async with httpx.AsyncClient(timeout=10, verify=False) as client:
api_ok = await _try_api_endpoint(client, server, result)
if not api_ok and not result["online"]:
await _try_connectivity_check(client, server, result)
except Exception as exc:
result["online"] = False
result["error"] = str(exc)
return result
async def fetch_all_servers(servers: List[ServerConfig]) -> List[Dict[str, Any]]:
"""Fetch stats from all configured Unraid servers in parallel.
Args:
servers: List of :class:`ServerConfig` instances.
Returns:
List of stats dictionaries, one per server.
"""
if not servers:
return []
tasks = [fetch_server_stats(srv) for srv in servers]
return list(await asyncio.gather(*tasks))

View file

@ -0,0 +1,215 @@
from __future__ import annotations
import asyncio
import httpx
from typing import Any, Dict, List, Optional
# Project ID groupings
PRIVATE_PROJECTS: List[int] = [3, 4] # Haus & Garten, Jugendeinrichtung
SAMS_PROJECTS: List[int] = [2, 5] # OpenClaw AI, Sam's Wunderwelt
# Readable names for known project IDs
PROJECT_NAMES: Dict[int, str] = {
2: "OpenClaw AI",
3: "Haus & Garten",
4: "Jugendeinrichtung",
5: "Sam's Wunderwelt",
}
def _parse_task(task: Dict[str, Any], project_id: int) -> Dict[str, Any]:
"""Normalise a raw Vikunja task into a simplified dictionary."""
return {
"id": task.get("id", 0),
"title": task.get("title", ""),
"done": bool(task.get("done", False)),
"priority": task.get("priority", 0),
"project_id": project_id,
"project_name": PROJECT_NAMES.get(project_id, f"Project {project_id}"),
"due_date": task.get("due_date") or None,
"created": task.get("created") or None,
"updated": task.get("updated") or None,
"labels": [
label.get("title", "")
for label in (task.get("labels") or [])
if label.get("title")
],
}
async def _fetch_project_tasks(
client: httpx.AsyncClient,
base_url: str,
project_id: int,
) -> List[Dict[str, Any]]:
"""Fetch all tasks for a single Vikunja project.
Args:
client: An authenticated httpx.AsyncClient.
base_url: Vikunja API base URL.
project_id: The project ID to query.
Returns:
List of parsed task dictionaries.
"""
all_tasks: List[Dict[str, Any]] = []
page = 1
per_page = 50
while True:
try:
resp = await client.get(
f"{base_url}/projects/{project_id}/tasks",
params={"page": page, "per_page": per_page},
)
resp.raise_for_status()
tasks_page: List[Dict[str, Any]] = resp.json()
except Exception:
break
if not tasks_page:
break
for raw_task in tasks_page:
all_tasks.append(_parse_task(raw_task, project_id))
if len(tasks_page) < per_page:
break
page += 1
return all_tasks
def _sort_and_split(
tasks: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""Split tasks into open/done buckets and sort by priority descending."""
open_tasks = sorted(
[t for t in tasks if not t["done"]],
key=lambda t: t["priority"],
reverse=True,
)
done_tasks = sorted(
[t for t in tasks if t["done"]],
key=lambda t: t["priority"],
reverse=True,
)
return {
"open": open_tasks,
"done": done_tasks,
"open_count": len(open_tasks),
"done_count": len(done_tasks),
}
async def fetch_tasks(base_url: str, token: str) -> Dict[str, Any]:
"""Fetch tasks from all configured Vikunja projects.
Groups tasks into ``private`` (PRIVATE_PROJECTS) and ``sams`` (SAMS_PROJECTS).
Args:
base_url: Vikunja instance base URL (e.g. ``https://tasks.example.com``).
token: API token for Vikunja authentication.
Returns:
Dictionary with ``private`` and ``sams`` keys, each containing
``open``, ``done``, ``open_count``, and ``done_count``.
"""
result: Dict[str, Any] = {
"private": {"open": [], "done": [], "open_count": 0, "done_count": 0},
"sams": {"open": [], "done": [], "open_count": 0, "done_count": 0},
"error": None,
}
if not base_url or not token:
result["error"] = "Missing Vikunja base URL or token"
return result
clean_url = base_url.rstrip("/")
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
try:
async with httpx.AsyncClient(
timeout=15,
headers=headers,
) as client:
all_project_ids = list(set(PRIVATE_PROJECTS + SAMS_PROJECTS))
coros = [
_fetch_project_tasks(client, clean_url, pid)
for pid in all_project_ids
]
results_by_project = await asyncio.gather(*coros, return_exceptions=True)
project_tasks_map: Dict[int, List[Dict[str, Any]]] = {}
for pid, tasks_or_exc in zip(all_project_ids, results_by_project):
if isinstance(tasks_or_exc, Exception):
project_tasks_map[pid] = []
else:
project_tasks_map[pid] = tasks_or_exc
private_tasks: List[Dict[str, Any]] = []
for pid in PRIVATE_PROJECTS:
private_tasks.extend(project_tasks_map.get(pid, []))
sams_tasks: List[Dict[str, Any]] = []
for pid in SAMS_PROJECTS:
sams_tasks.extend(project_tasks_map.get(pid, []))
result["private"] = _sort_and_split(private_tasks)
result["sams"] = _sort_and_split(sams_tasks)
except httpx.HTTPStatusError as exc:
result["error"] = f"HTTP {exc.response.status_code}"
except httpx.RequestError as exc:
result["error"] = f"Connection failed: {exc}"
except Exception as exc:
result["error"] = str(exc)
return result
async def fetch_single_project(
base_url: str,
token: str,
project_id: int,
) -> Dict[str, Any]:
"""Fetch tasks for a single Vikunja project.
Args:
base_url: Vikunja instance base URL.
token: API token for authentication.
project_id: The project ID to query.
Returns:
Dictionary with ``open``, ``done``, ``open_count``, ``done_count``, and ``error``.
"""
result: Dict[str, Any] = {
"open": [],
"done": [],
"open_count": 0,
"done_count": 0,
"error": None,
}
if not base_url or not token:
result["error"] = "Missing Vikunja base URL or token"
return result
clean_url = base_url.rstrip("/")
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
try:
async with httpx.AsyncClient(timeout=15, headers=headers) as client:
tasks = await _fetch_project_tasks(client, clean_url, project_id)
split = _sort_and_split(tasks)
result.update(split)
except Exception as exc:
result["error"] = str(exc)
return result

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

View file

@ -1,141 +0,0 @@
"""
Discord Bridge for Dashboard Chat
Sends messages from Dashboard to Discord and relays responses back
"""
import asyncio
import httpx
import os
import time
from datetime import datetime
from typing import Optional, Dict, Any, Callable
DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL", "")
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN", "")
DISCORD_CHANNEL_ID = os.getenv("DISCORD_CHANNEL_ID", "")
# Store pending messages waiting for responses
pending_messages: Dict[str, Any] = {}
message_callbacks: Dict[str, Callable] = {}
async def send_to_discord(message: str, username: str = "Dashboard", msg_id: str = "") -> bool:
"""Send message to Discord via Webhook"""
if not DISCORD_WEBHOOK_URL:
print("No DISCORD_WEBHOOK_URL configured")
return False
try:
async with httpx.AsyncClient() as client:
response = await client.post(
DISCORD_WEBHOOK_URL,
json={
"content": f"📱 **Dashboard:** {message}\n\n[MsgID:{msg_id}]",
"username": username,
"avatar_url": "https://cdn.discordapp.com/emojis/1064969270828195921.webp"
},
timeout=10.0
)
if response.status_code == 204:
# Store pending message
pending_messages[msg_id] = {
"timestamp": time.time(),
"content": message,
"responded": False
}
return True
else:
print(f"Discord webhook returned {response.status_code}: {response.text}")
return False
except Exception as e:
print(f"Discord webhook error: {e}")
return False
async def check_discord_responses() -> Optional[Dict[str, str]]:
"""Check for new responses in Discord channel (requires bot token)"""
if not DISCORD_BOT_TOKEN or not DISCORD_CHANNEL_ID:
return None
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://discord.com/api/v10/channels/{DISCORD_CHANNEL_ID}/messages?limit=10",
headers={"Authorization": f"Bot {DISCORD_BOT_TOKEN}"},
timeout=10.0
)
if response.status_code == 200:
messages = response.json()
for msg in messages:
# Skip if it's a dashboard message itself
if msg.get("author", {}).get("username") == "Dashboard":
continue
# Check if this is a reply
referenced = msg.get("referenced_message")
if referenced:
ref_content = referenced.get("content", "")
# Extract MsgID from referenced message
if "[MsgID:" in ref_content:
import re
match = re.search(r'\[MsgID:([^\]]+)\]', ref_content)
if match:
msg_id = match.group(1)
if msg_id in pending_messages and not pending_messages[msg_id]["responded"]:
pending_messages[msg_id]["responded"] = True
return {
"msg_id": msg_id,
"content": msg.get("content", ""),
"author": msg.get("author", {}).get("username", "Unknown")
}
return None
except Exception as e:
print(f"Discord check error: {e}")
return None
async def poll_discord_responses(callback: Callable[[str, str], None], interval: int = 5):
"""Continuously poll for Discord responses"""
while True:
await asyncio.sleep(interval)
# Cleanup old messages periodically
cleanup_old_messages()
response = await check_discord_responses()
if response:
msg_id = response["msg_id"]
content = response["content"]
# Call the callback with response
if msg_id in message_callbacks:
try:
await message_callbacks[msg_id](content)
except Exception as e:
print(f"Callback error for {msg_id}: {e}")
finally:
if msg_id in message_callbacks:
del message_callbacks[msg_id]
def register_callback(msg_id: str, callback: Callable):
"""Register a callback for a message response"""
message_callbacks[msg_id] = callback
def cleanup_old_messages(max_age: int = 3600):
"""Remove old pending messages"""
current_time = time.time()
to_remove = [
msg_id for msg_id, data in pending_messages.items()
if current_time - data["timestamp"] > max_age
]
for msg_id in to_remove:
del pending_messages[msg_id]
if msg_id in message_callbacks:
del message_callbacks[msg_id]

View file

@ -1,803 +0,0 @@
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import JSONResponse
from pydantic import BaseModel
import httpx
import os
import json
import asyncio
import psutil
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List
import time
# Import Discord Bridge
try:
from discord_bridge import send_to_discord, poll_discord_responses, register_callback, cleanup_old_messages
DISCORD_AVAILABLE = True
except ImportError:
DISCORD_AVAILABLE = False
print("Warning: discord_bridge not available")
# Chat models
class ChatMessage(BaseModel):
message: str
app = FastAPI(title="Daily Briefing")
# Static files and templates
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
# Config
VIKUNJA_URL = os.getenv("VIKUNJA_URL", "http://10.10.10.10:3456/api/v1")
VIKUNJA_TOKEN = os.getenv("VIKUNJA_TOKEN", "")
HA_URL = os.getenv("HA_URL", "https://homeassistant.daddelolymp.de")
HA_TOKEN = os.getenv("HA_TOKEN", "")
WEATHER_LOCATION = os.getenv("WEATHER_LOCATION", "Leverkusen")
WEATHER_LOCATION_SECONDARY = os.getenv("WEATHER_LOCATION_SECONDARY", "Rab,Croatia")
DASHBOARD_DATA_PATH = os.getenv("DASHBOARD_DATA_PATH", "/app/data/dashboard_data.json")
# Caching
class Cache:
def __init__(self):
self.data: Dict[str, Any] = {}
self.timestamps: Dict[str, float] = {}
self.ttl: Dict[str, int] = {
"weather": 3600,
"weather_secondary": 3600,
"ha": 30,
"vikunja": 30,
"system": 10,
"briefing_data": 300, # 5 minutes for news/hourly weather
}
def get(self, key: str) -> Optional[Any]:
if key in self.data:
age = time.time() - self.timestamps.get(key, 0)
if age < self.ttl.get(key, 0):
return self.data[key]
return None
def set(self, key: str, value: Any):
self.data[key] = value
self.timestamps[key] = time.time()
cache = Cache()
# WebSocket connections
class ConnectionManager:
def __init__(self):
self.active_connections: list[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
async def broadcast(self, message: dict):
for connection in self.active_connections.copy():
try:
await connection.send_json(message)
except:
pass
manager = ConnectionManager()
# Simple in-memory chat storage (resets on restart)
chat_messages: List[Dict[str, Any]] = []
MAX_CHAT_HISTORY = 50
@app.get("/")
async def dashboard(request: Request):
"""Main dashboard view"""
briefing_data = await get_briefing_data()
data = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
"weather": await get_weather(),
"weather_secondary": await get_weather_secondary(),
"ha_status": await get_homeassistant_status(),
"vikunja_all": await get_vikunja_all_tasks(),
"system_status": await get_system_status(),
"news": briefing_data.get("news", []),
"hourly_weather": briefing_data.get("hourly_weather", {}),
}
return templates.TemplateResponse("dashboard.html", {
"request": request,
**data
})
@app.get("/api/all")
async def api_all():
"""Get all data at once"""
weather, weather_secondary, ha, vikunja, system, briefing_data = await asyncio.gather(
get_weather(),
get_weather_secondary(),
get_homeassistant_status(),
get_vikunja_all_tasks(),
get_system_status(),
get_briefing_data()
)
return {
"timestamp": datetime.now().isoformat(),
"weather": weather,
"weather_secondary": weather_secondary,
"ha_status": ha,
"vikunja_all": vikunja,
"system_status": system,
"news": briefing_data.get("news", []),
"hourly_weather": briefing_data.get("hourly_weather", {})
}
async def get_briefing_data() -> dict:
"""Read briefing data (news, hourly weather) from JSON file"""
cached = cache.get("briefing_data")
if cached:
return cached
try:
if os.path.exists(DASHBOARD_DATA_PATH):
with open(DASHBOARD_DATA_PATH, "r") as f:
data = json.load(f)
cache.set("briefing_data", data)
return data
except Exception as e:
print(f"Error reading briefing data: {e}")
return {"news": [], "hourly_weather": {}}
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await manager.connect(websocket)
try:
while True:
data = await websocket.receive_text()
if data == "ping":
fresh_data = await api_all()
await websocket.send_json(fresh_data)
await asyncio.sleep(1)
except WebSocketDisconnect:
manager.disconnect(websocket)
def parse_forecast(weather_data: list) -> list:
"""Parse 3-day forecast from wttr.in data"""
forecast = []
days = ["Heute", "Morgen", "Übermorgen"]
for i, day_data in enumerate(weather_data[:3]):
hourly = day_data.get("hourly", [])
if hourly:
# Use midday (12:00) or first available
midday = hourly[min(4, len(hourly)-1)] if len(hourly) > 4 else hourly[0]
forecast.append({
"day": days[i] if i < len(days) else day_data.get("date", ""),
"temp_max": day_data.get("maxtempC", "--"),
"temp_min": day_data.get("mintempC", "--"),
"icon": get_weather_icon(midday.get("weatherDesc", [{}])[0].get("value", "")),
"description": midday.get("weatherDesc", [{}])[0].get("value", "")
})
return forecast
async def get_weather() -> dict:
"""Fetch weather for primary location (Leverkusen) with forecast"""
cached = cache.get("weather")
if cached:
cached["cached"] = True
return cached
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(
f"https://wttr.in/{WEATHER_LOCATION}?format=j1",
headers={"User-Agent": "curl/7.68.0"}
)
if response.status_code == 200:
data = response.json()
current = data["current_condition"][0]
# Parse forecast
forecast = parse_forecast(data.get("weather", []))
result = {
"temp": current["temp_C"],
"feels_like": current["FeelsLikeC"],
"description": current["weatherDesc"][0]["value"],
"humidity": current["humidity"],
"wind": current["windspeedKmph"],
"icon": get_weather_icon(current["weatherDesc"][0]["value"]),
"location": WEATHER_LOCATION,
"forecast": forecast,
"cached": False
}
cache.set("weather", result)
return result
except Exception as e:
print(f"Weather error: {e}")
return {"error": "Weather unavailable", "location": WEATHER_LOCATION}
async def get_weather_secondary() -> dict:
"""Fetch weather for secondary location (Rab/Banjol) with forecast"""
cached = cache.get("weather_secondary")
if cached:
cached["cached"] = True
return cached
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(
f"https://wttr.in/{WEATHER_LOCATION_SECONDARY}?format=j1",
headers={"User-Agent": "curl/7.68.0"}
)
if response.status_code == 200:
data = response.json()
current = data["current_condition"][0]
# Parse forecast
forecast = parse_forecast(data.get("weather", []))
result = {
"temp": current["temp_C"],
"feels_like": current["FeelsLikeC"],
"description": current["weatherDesc"][0]["value"],
"humidity": current["humidity"],
"wind": current["windspeedKmph"],
"icon": get_weather_icon(current["weatherDesc"][0]["value"]),
"location": "Rab/Banjol",
"forecast": forecast,
"cached": False
}
cache.set("weather_secondary", result)
return result
except Exception as e:
print(f"Weather secondary error: {e}")
return {"error": "Weather unavailable", "location": "Rab/Banjol"}
def get_weather_icon(description: str) -> str:
"""Map weather description to emoji"""
desc = description.lower()
if "sun" in desc or "clear" in desc:
return "☀️"
elif "cloud" in desc:
return "☁️"
elif "rain" in desc or "drizzle" in desc:
return "🌧️"
elif "snow" in desc:
return "🌨️"
elif "thunder" in desc:
return "⛈️"
elif "fog" in desc or "mist" in desc:
return "🌫️"
return "🌤️"
async def get_homeassistant_status() -> dict:
"""Fetch Home Assistant status"""
cached = cache.get("ha")
if cached:
cached["cached"] = True
return cached
try:
async with httpx.AsyncClient(timeout=5) as client:
lights_resp = await client.get(
f"{HA_URL}/api/states",
headers={"Authorization": f"Bearer {HA_TOKEN}"}
)
if lights_resp.status_code == 200:
states = lights_resp.json()
lights = []
for state in states:
if state["entity_id"].startswith("light."):
lights.append({
"name": state["attributes"].get("friendly_name", state["entity_id"]),
"state": state["state"],
"brightness": state["attributes"].get("brightness", 0)
})
covers = []
for state in states:
if state["entity_id"].startswith("cover."):
covers.append({
"name": state["attributes"].get("friendly_name", state["entity_id"]),
"state": state["state"]
})
result = {
"online": True,
"lights_on": len([l for l in lights if l["state"] == "on"]),
"lights_total": len(lights),
"lights": lights[:5],
"covers": covers[:3],
"cached": False
}
cache.set("ha", result)
return result
except Exception as e:
print(f"HA error: {e}")
return {"online": False, "error": "Home Assistant unavailable"}
async def get_vikunja_all_tasks() -> dict:
"""Fetch ALL tasks from ALL projects - separated by owner (private vs Sam's)"""
cached = cache.get("vikunja_all")
if cached:
cached["cached"] = True
return cached
# Project mapping
PRIVATE_PROJECT_IDS = [3, 4] # Haus & Garten, Jugendeinrichtung Arbeit
SAM_PROJECT_IDS = [2, 5] # OpenClaw AI Tasks, Sam's Wunderwelt
try:
async with httpx.AsyncClient(timeout=15) as client:
# Get all projects first
proj_resp = await client.get(
f"{VIKUNJA_URL}/projects",
headers={"Authorization": f"Bearer {VIKUNJA_TOKEN}"}
)
if proj_resp.status_code != 200:
return {"error": "Could not fetch projects", "private": {"open": [], "done": []}, "sam": {"open": [], "done": []}}
projects = proj_resp.json()
# Separate task lists
private_open = []
private_done = []
sam_open = []
sam_done = []
for project in projects:
project_id = project["id"]
project_name = project["title"]
# Skip if not relevant project
if project_id not in PRIVATE_PROJECT_IDS and project_id not in SAM_PROJECT_IDS:
continue
# Get views for this project
views_resp = await client.get(
f"{VIKUNJA_URL}/projects/{project_id}/views",
headers={"Authorization": f"Bearer {VIKUNJA_TOKEN}"}
)
if views_resp.status_code == 200:
views = views_resp.json()
if views:
view_id = views[0]["id"]
# Get ALL tasks
tasks_resp = await client.get(
f"{VIKUNJA_URL}/projects/{project_id}/views/{view_id}/tasks",
headers={"Authorization": f"Bearer {VIKUNJA_TOKEN}"}
)
if tasks_resp.status_code == 200:
tasks = tasks_resp.json()
for task in tasks:
task_info = {
"id": task["id"],
"title": task["title"],
"project": project_name,
"due": task.get("due_date", ""),
"priority": task.get("priority", 0),
"project_id": project_id
}
# Sort into correct bucket
if project_id in PRIVATE_PROJECT_IDS:
if task.get("done", False):
private_done.append(task_info)
else:
private_open.append(task_info)
elif project_id in SAM_PROJECT_IDS:
if task.get("done", False):
sam_done.append(task_info)
else:
sam_open.append(task_info)
# Sort by priority
for task_list in [private_open, private_done, sam_open, sam_done]:
task_list.sort(key=lambda x: x["priority"], reverse=True)
result = {
"private": {
"open": private_open,
"done": private_done,
"open_count": len(private_open),
"done_count": len(private_done)
},
"sam": {
"open": sam_open,
"done": sam_done,
"open_count": len(sam_open),
"done_count": len(sam_done)
},
"cached": False
}
cache.set("vikunja_all", result)
return result
except Exception as e:
import traceback
print(f"Vikunja error: {e}")
print(traceback.format_exc())
return {"error": "Vikunja unavailable", "private": {"open": [], "done": [], "open_count": 0, "done_count": 0}, "sam": {"open": [], "done": [], "open_count": 0, "done_count": 0}}
def read_meminfo():
"""Read memory info from /proc/meminfo"""
try:
with open('/host/proc/meminfo', 'r') as f:
lines = f.readlines()
meminfo = {}
for line in lines:
parts = line.split(':')
if len(parts) == 2:
key = parts[0].strip()
value = parts[1].strip().split()[0] # Get number
meminfo[key] = int(value)
return meminfo
except:
try:
with open('/proc/meminfo', 'r') as f:
lines = f.readlines()
meminfo = {}
for line in lines:
parts = line.split(':')
if len(parts) == 2:
key = parts[0].strip()
value = parts[1].strip().split()[0]
meminfo[key] = int(value)
return meminfo
except:
return None
def read_loadavg():
"""Read load average from /proc/loadavg"""
try:
with open('/host/proc/loadavg', 'r') as f:
return f.read().strip()
except:
try:
with open('/proc/loadavg', 'r') as f:
return f.read().strip()
except:
return None
def get_system_status_sync() -> dict:
"""Get real system status with CPU/RAM (synchronous)"""
try:
# Check processes by looking at /proc
openclaw_running = False
docker_running = False
try:
# Check if openclaw gateway is listening
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2)
result = sock.connect_ex(('localhost', 8080))
openclaw_running = result == 0
sock.close()
except:
pass
try:
# Check docker socket
import os
docker_running = os.path.exists('/var/run/docker.sock')
# If socket exists, try a light ping
if docker_running:
import socket
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client.settimeout(2)
try:
client.connect('/var/run/docker.sock')
docker_running = True
except:
docker_running = False
finally:
client.close()
except:
pass
# Get CPU cores
cpu_cores = psutil.cpu_count() or 2
# Get Load Average
loadavg_str = read_loadavg()
if loadavg_str:
load1 = float(loadavg_str.split()[0])
# Estimate CPU % from load (simplified)
cpu_percent = min(100, round((load1 / cpu_cores) * 100, 1))
else:
cpu_percent = 0
# Get RAM from /proc/meminfo
meminfo = read_meminfo()
if meminfo:
total_kb = meminfo.get('MemTotal', 0)
available_kb = meminfo.get('MemAvailable', meminfo.get('MemFree', 0))
used_kb = total_kb - available_kb
total_gb = round(total_kb / (1024 * 1024), 1)
used_gb = round(used_kb / (1024 * 1024), 1)
ram_percent = round((used_kb / total_kb) * 100, 1) if total_kb > 0 else 0
else:
total_gb = 0
used_gb = 0
ram_percent = 0
return {
"openclaw": {"running": True, "status": "running"},
"docker": {"running": True, "status": "running"},
"cpu": {
"percent": cpu_percent,
"cores": cpu_cores,
"load1": round(load1, 2) if loadavg_str else 0
},
"ram": {
"percent": ram_percent,
"used_gb": used_gb,
"total_gb": total_gb
},
"briefing_version": "1.2.0-live",
"cached": False
}
except Exception as e:
print(f"System status error: {e}")
import traceback
print(traceback.format_exc())
return {
"openclaw": {"running": True, "status": "running"},
"docker": {"running": True, "status": "running"},
"cpu": {"percent": 0, "cores": 2, "load1": 0},
"ram": {"percent": 0, "used_gb": 0, "total_gb": 0},
"error": str(e),
"briefing_version": "1.2.0-live"
}
async def get_system_status() -> dict:
"""Get real system status with CPU/RAM"""
cached = cache.get("system")
if cached:
cached["cached"] = True
return cached
# Run synchronous psutil operations in thread pool
result = await asyncio.to_thread(get_system_status_sync)
cache.set("system", result)
return result
# Chat WebSocket connections
class ChatConnectionManager:
def __init__(self):
self.active_connections: list[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
async def send_to_client(self, websocket: WebSocket, message: dict):
try:
await websocket.send_json(message)
except:
pass
async def broadcast(self, message: dict):
for connection in self.active_connections.copy():
try:
await connection.send_json(message)
except:
pass
chat_manager = ChatConnectionManager()
pending_chat_responses: Dict[str, Any] = {}
@app.websocket("/ws/chat")
async def chat_websocket_endpoint(websocket: WebSocket):
"""WebSocket for real-time chat"""
await chat_manager.connect(websocket)
try:
# Send chat history
await websocket.send_json({"type": "history", "messages": chat_messages})
while True:
data = await websocket.receive_json()
if data.get("type") == "message":
user_msg = data.get("content", "")
# Store user message
msg_entry = {
"id": str(int(time.time() * 1000)),
"role": "user",
"content": user_msg,
"timestamp": datetime.now().isoformat()
}
chat_messages.append(msg_entry)
# Keep only last N messages
if len(chat_messages) > MAX_CHAT_HISTORY:
chat_messages.pop(0)
# Broadcast to all connected clients
await chat_manager.broadcast({"type": "message", "message": msg_entry})
# Forward to OpenClaw Gateway
asyncio.create_task(forward_to_openclaw(msg_entry["id"], user_msg))
except WebSocketDisconnect:
chat_manager.disconnect(websocket)
except Exception as e:
print(f"Chat WebSocket error: {e}")
chat_manager.disconnect(websocket)
async def forward_to_openclaw(msg_id: str, message: str):
"""Forward message to OpenClaw Gateway and/or Discord"""
gateway_url = os.getenv("OPENCLAW_GATEWAY_URL", "http://host.docker.internal:18789")
discord_sent = False
openclaw_sent = False
# Try OpenClaw Gateway first
try:
async with httpx.AsyncClient(timeout=60) as client:
# Option 1: Try OpenClaw Gateway API
try:
response = await client.post(
f"{gateway_url}/api/inject",
json={
"text": message,
"source": "dashboard",
"reply_to": f"dashboard:{msg_id}"
},
timeout=60.0
)
if response.status_code == 200:
openclaw_sent = True
except Exception as e:
print(f"OpenClaw inject failed: {e}")
except Exception as e:
print(f"OpenClaw connection failed: {e}")
# Send to Discord as backup/alternative
if DISCORD_AVAILABLE:
try:
discord_sent = await send_to_discord(message, "Dashboard", msg_id)
if discord_sent:
# Register callback for Discord response
async def on_discord_response(content: str):
await add_assistant_response(msg_id, content)
register_callback(msg_id, on_discord_response)
except Exception as e:
print(f"Discord send failed: {e}")
# If neither worked, show error
if not openclaw_sent and not discord_sent:
await add_assistant_response(msg_id, "⚠️ Konnte keine Verbindung herstellen. Bitte versuch es später nochmal.")
elif discord_sent and not openclaw_sent:
# Discord works but OpenClaw doesn't - show pending message
pending_chat_responses[msg_id] = {
"status": "pending_discord",
"message": message,
"timestamp": datetime.now().isoformat()
}
async def add_assistant_response(reply_to_id: str, content: str):
"""Add assistant response to chat history"""
msg_entry = {
"id": str(int(time.time() * 1000)),
"role": "assistant",
"content": content,
"reply_to": reply_to_id,
"timestamp": datetime.now().isoformat()
}
chat_messages.append(msg_entry)
# Keep only last N messages
if len(chat_messages) > MAX_CHAT_HISTORY:
chat_messages.pop(0)
# Remove from pending
if reply_to_id in pending_chat_responses:
del pending_chat_responses[reply_to_id]
# Broadcast to all connected clients
await chat_manager.broadcast({"type": "message", "message": msg_entry})
@app.post("/api/chat/webhook")
async def chat_webhook(request: Request):
"""Webhook for OpenClaw to send responses back"""
data = await request.json()
reply_to = data.get("reply_to", "")
content = data.get("text", "")
if reply_to.startswith("dashboard:"):
msg_id = reply_to.replace("dashboard:", "")
await add_assistant_response(msg_id, content)
return {"status": "ok"}
# General message without reply_to
await add_assistant_response("general", content)
return {"status": "ok"}
@app.post("/api/chat")
async def api_chat(msg: ChatMessage):
"""HTTP fallback for chat messages"""
msg_id = str(int(time.time() * 1000))
# Store user message
chat_messages.append({
"id": msg_id,
"role": "user",
"content": msg.message,
"timestamp": datetime.now().isoformat()
})
# Keep only last N messages
if len(chat_messages) > MAX_CHAT_HISTORY:
chat_messages.pop(0)
# Forward to OpenClaw (non-blocking)
asyncio.create_task(forward_to_openclaw(msg_id, msg.message))
return {"status": "accepted", "message_id": msg_id}
@app.get("/api/chat/history")
async def api_chat_history():
"""Get chat history"""
return {"messages": chat_messages}
@app.get("/api/chat/pending/{msg_id}")
async def api_chat_pending(msg_id: str):
"""Check if a response is pending"""
if msg_id in pending_chat_responses:
return {"status": "pending"}
# Check if response exists in history
for msg in chat_messages:
if msg.get("reply_to") == msg_id:
return {"status": "completed", "message": msg}
return {"status": "not_found"}
# Background task to broadcast updates
@app.on_event("startup")
async def startup_event():
asyncio.create_task(broadcast_updates())
# Start Discord polling if available
if DISCORD_AVAILABLE:
asyncio.create_task(start_discord_polling())
async def start_discord_polling():
"""Start polling for Discord responses"""
try:
await poll_discord_responses(lambda msg_id, content: None, interval=5)
except Exception as e:
print(f"Discord polling failed: {e}")
async def broadcast_updates():
"""Broadcast updates every 30 seconds"""
while True:
await asyncio.sleep(30)
if manager.active_connections:
fresh_data = await api_all()
await manager.broadcast(fresh_data)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8080)

View file

@ -1 +0,0 @@

View file

@ -1,469 +0,0 @@
<!DOCTYPE html>
<html lang="de" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Daily Dashboard | Live</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
'hal-accent': '#3b82f6',
'hal-bg': '#0f172a',
'hal-card': '#1e293b',
}
}
}
}
</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body { font-family: 'Inter', sans-serif; }
.glass-card {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.3s ease;
}
.glass-card:hover {
border-color: rgba(59, 130, 246, 0.5);
}
.pulse-dot {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: .5; }
}
.fade-in {
animation: fadeIn 0.5s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.live-indicator {
display: inline-flex;
align-items: center;
font-size: 0.75rem;
color: #10b981;
}
.live-indicator::before {
content: '';
width: 6px;
height: 6px;
background: #10b981;
border-radius: 50%;
margin-right: 6px;
animation: pulse 1.5s infinite;
}
.cached-badge {
font-size: 0.65rem;
padding: 2px 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: #9ca3af;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-online { background-color: #10b981; }
.status-offline { background-color: #ef4444; }
.progress-bar {
height: 4px;
background: rgba(255,255,255,0.1);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 2px;
transition: width 0.5s ease;
}
.tab-active {
border-bottom: 2px solid #3b82f6;
color: #3b82f6;
}
.task-done {
text-decoration: line-through;
opacity: 0.5;
}
</style>
</head>
<body class="bg-hal-bg text-gray-100 min-h-screen">
<!-- Header -->
<header class="border-b border-gray-800 bg-hal-card/50 backdrop-blur sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 py-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center text-xl">
</div>
<div>
<h1 class="text-xl font-bold text-white">Dashboard</h1>
<div class="flex items-center space-x-3 text-xs">
<span class="live-indicator">LIVE</span>
<span id="connection-status" class="text-green-400"></span>
</div>
</div>
</div>
<nav class="hidden md:flex items-center space-x-8">
<a href="#weather-section" class="text-sm font-medium text-gray-400 hover:text-white transition-colors">Wetter</a>
<a href="#news-section" class="text-sm font-medium text-gray-400 hover:text-white transition-colors">News</a>
<a href="#tasks-section" class="text-sm font-medium text-gray-400 hover:text-white transition-colors">Tasks</a>
</nav>
<!-- Clock & Date moved to the right (replacing refresh button) -->
<div class="flex flex-col items-end min-w-[100px]">
<div class="text-xl font-bold text-white font-mono tracking-wider leading-none" id="live-clock">--:--:--</div>
<div class="text-[10px] text-gray-400 font-medium uppercase tracking-widest mt-1" id="live-date">--. --. ----</div>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8 space-y-6">
<!-- Row 1: Weather Cards & Hourly -->
<div id="weather-section" class="space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
<!-- Weather Leverkusen -->
<div class="glass-card rounded-xl p-5 fade-in" id="weather-card">
<div class="flex items-center justify-between mb-3">
<h2 class="text-base font-semibold text-gray-200 flex items-center">
<svg class="w-5 h-5 mr-2 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"></path>
</svg>
Leverkusen
</h2>
{% if weather.cached %}<span class="cached-badge">cached</span>{% endif %}
</div>
{% if weather.error %}
<div class="text-red-400 text-sm">{{ weather.error }}</div>
{% else %}
<div class="flex items-center justify-between mb-4">
<div>
<div class="text-4xl font-bold text-white" id="weather-temp">{{ weather.temp }}°</div>
<div class="text-gray-400 text-sm mt-1">Gefühlt {{ weather.feels_like }}°</div>
</div>
<div class="text-5xl" id="weather-icon">{{ weather.icon }}</div>
</div>
<div class="flex items-center justify-between text-sm mb-4">
<span class="text-gray-400" id="weather-desc">{{ weather.description }}</span>
<span class="text-gray-500">💧 {{ weather.humidity }}%</span>
</div>
{% endif %}
</div>
<!-- Weather Rab/Banjol -->
<div class="glass-card rounded-xl p-5 fade-in" id="weather-secondary-card">
<div class="flex items-center justify-between mb-3">
<h2 class="text-base font-semibold text-gray-200 flex items-center">
<svg class="w-5 h-5 mr-2 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Rab/Banjol 🇭🇷
</h2>
{% if weather_secondary.cached %}<span class="cached-badge">cached</span>{% endif %}
</div>
{% if weather_secondary.error %}
<div class="text-red-400 text-sm">{{ weather_secondary.error }}</div>
{% else %}
<div class="flex items-center justify-between mb-4">
<div>
<div class="text-4xl font-bold text-white" id="weather-secondary-temp">{{ weather_secondary.temp }}°</div>
<div class="text-gray-400 text-sm mt-1">Gefühlt {{ weather_secondary.feels_like }}°</div>
</div>
<div class="text-5xl" id="weather-secondary-icon">{{ weather_secondary.icon }}</div>
</div>
<div class="flex items-center justify-between text-sm mb-4">
<span class="text-gray-400" id="weather-secondary-desc">{{ weather_secondary.description }}</span>
<span class="text-gray-500">💧 {{ weather_secondary.humidity }}%</span>
</div>
{% endif %}
</div>
<!-- Hourly Forecast (Leverkusen) -->
<div class="glass-card rounded-xl p-5 fade-in lg:col-span-1" id="hourly-weather-card">
<h2 class="text-base font-semibold text-gray-200 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Nächste Stunden
</h2>
<div class="flex space-x-4 overflow-x-auto pb-2 scrollbar-hide" id="hourly-container">
{% if hourly_weather and hourly_weather.Leverkusen %}
{% for hour in hourly_weather.Leverkusen[:8] %}
<div class="flex-shrink-0 text-center p-2 bg-gray-800/40 rounded-lg min-w-[70px]">
<div class="text-[10px] text-gray-500 mb-1">{{ hour.time }}</div>
<div class="text-xl mb-1">{{ hour.icon }}</div>
<div class="text-sm font-bold">{{ hour.temp }}°</div>
<div class="text-[9px] text-gray-400">{{ hour.precip }}%</div>
</div>
{% endfor %}
{% else %}
<div class="text-gray-500 text-xs py-4">Keine Stundendaten verfügbar.</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Row 2: News Headlines -->
<div id="news-section" class="space-y-4">
<h2 class="text-lg font-bold text-white flex items-center">
<svg class="w-6 h-6 mr-2 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"></path>
</svg>
Aktuelle Schlagzeilen
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4" id="news-container">
{% if news %}
{% for item in news[:12] %}
<div class="glass-card rounded-xl p-4 flex flex-col h-full hover:scale-[1.02] transition-transform">
<div class="flex items-center justify-between mb-2">
<span class="text-[10px] font-bold uppercase tracking-wider text-gray-500">{{ item.source }}</span>
<span class="text-[9px] text-gray-600">{{ item.time }}</span>
</div>
<h3 class="text-sm font-medium text-gray-200 line-clamp-3 mb-3 flex-grow">
{{ item.title }}
</h3>
<a href="{{ item.url }}" target="_blank" class="text-blue-400 text-xs font-semibold flex items-center hover:text-blue-300">
Mehr lesen
<svg class="w-3 h-3 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"></path>
</svg>
</a>
</div>
{% endfor %}
{% else %}
<div class="col-span-full glass-card rounded-xl p-8 text-center text-gray-500">
Keine aktuellen Nachrichten geladen.
</div>
{% endif %}
</div>
</div>
<div id="tasks-section" class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- System Status with CPU/RAM -->
<div class="glass-card rounded-xl p-5 fade-in" id="system-card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold text-gray-200 flex items-center">
<svg class="w-5 h-5 mr-2 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"></path>
</svg>
System Status
</h2>
{% if system_status.cached %}<span class="cached-badge">cached</span>{% endif %}
</div>
<!-- CPU & RAM -->
<div class="border-t border-gray-700 pt-4 space-y-3">
<!-- CPU -->
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-400">CPU ({{ system_status.cpu.cores }} cores)</span>
<span class="text-white font-mono" id="cpu-percent">{{ system_status.cpu.percent }}%</span>
</div>
<div class="progress-bar">
<div class="progress-fill {{ 'bg-green-500' if system_status.cpu.percent < 50 else 'bg-yellow-500' if system_status.cpu.percent < 80 else 'bg-red-500' }}"
id="cpu-bar" style="width: {{ system_status.cpu.percent }}%"></div>
</div>
</div>
<!-- RAM -->
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-400">RAM</span>
<span class="text-white font-mono" id="ram-percent">
{{ system_status.ram.used_gb }}/{{ system_status.ram.total_gb }} GB ({{ system_status.ram.percent }}%)
</span>
</div>
<div class="progress-bar">
<div class="progress-fill {{ 'bg-green-500' if system_status.ram.percent < 50 else 'bg-yellow-500' if system_status.ram.percent < 80 else 'bg-red-500' }}"
id="ram-bar" style="width: {{ system_status.ram.percent }}%"></div>
</div>
</div>
</div>
<div class="mt-4 pt-3 border-t border-gray-700 text-xs text-gray-500">
v{{ system_status.briefing_version }}
</div>
</div>
<!-- Home Assistant -->
<div class="glass-card rounded-xl p-5 fade-in" id="ha-card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold text-gray-200 flex items-center">
<svg class="w-5 h-5 mr-2 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg>
Home Assistant
</h2>
{% if ha_status.cached %}<span class="cached-badge">cached</span>{% endif %}
</div>
{% if ha_status.online %}
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-gray-400 text-sm">Lampen an</span>
<span class="text-2xl font-bold text-yellow-400">{{ ha_status.lights_on }}/{{ ha_status.lights_total }}</span>
</div>
<div class="space-y-1.5 max-h-40 overflow-y-auto">
{% for light in ha_status.lights %}
<div class="flex items-center justify-between text-sm py-1 border-b border-gray-700/50 last:border-0">
<span class="text-gray-300 truncate">{{ light.name }}</span>
<span class="{{ 'text-yellow-400' if light.state == 'on' else 'text-gray-600' }}">
{{ "●" if light.state == 'on' else "○" }}
</span>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="flex items-center space-x-2 text-red-400">
<span class="status-dot status-offline"></span>
<span>Offline</span>
</div>
<div class="text-red-400/70 text-sm mt-2">{{ ha_status.error }}</div>
{% endif %}
</div>
</div>
<!-- Row 3: Tasks Sections -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Private Tasks -->
<div class="glass-card rounded-xl p-5 fade-in" id="tasks-private-card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold text-gray-200 flex items-center">
<svg class="w-5 h-5 mr-2 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg>
Private Aufgaben
</h2>
<div class="flex items-center space-x-4">
<span class="text-blue-400 font-bold">{{ vikunja_all.private.open_count }}</span>
{% if vikunja_all.cached %}<span class="cached-badge">cached</span>{% endif %}
</div>
</div>
<div id="tasks-private-open" class="space-y-2 max-h-64 overflow-y-auto">
{% if vikunja_all.private.open %}
{% for task in vikunja_all.private.open %}
<div class="flex items-start space-x-3 p-3 bg-gray-800/50 rounded-lg">
<span class="text-blue-400 mt-0.5"></span>
<div class="flex-1 min-w-0">
<a href="http://10.10.10.10:3456/tasks/{{ task.id }}" target="_blank" class="text-gray-200 hover:text-blue-400 transition-colors cursor-pointer">{{ task.title }}</a>
<div class="flex items-center space-x-2 text-xs text-gray-500 mt-1">
<span class="px-2 py-0.5 bg-gray-700 rounded">{{ task.project }}</span>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-gray-500 text-center py-8 text-sm">Keine offenen Aufgaben</div>
{% endif %}
</div>
</div>
<!-- Sam's Tasks -->
<div class="glass-card rounded-xl p-5 fade-in" id="tasks-sam-card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold text-gray-200 flex items-center">
<svg class="w-5 h-5 mr-2 text-pink-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"></path>
</svg>
Sam's Aufgaben
</h2>
<div class="flex items-center space-x-4">
<span class="text-pink-400 font-bold">{{ vikunja_all.sam.open_count }}</span>
{% if vikunja_all.cached %}<span class="cached-badge">cached</span>{% endif %}
</div>
</div>
<div id="tasks-sam-open" class="space-y-2 max-h-64 overflow-y-auto">
{% if vikunja_all.sam.open %}
{% for task in vikunja_all.sam.open %}
<div class="flex items-start space-x-3 p-3 bg-gray-800/50 rounded-lg">
<span class="text-pink-400 mt-0.5"></span>
<div class="flex-1 min-w-0">
<a href="http://10.10.10.10:3456/tasks/{{ task.id }}" target="_blank" class="text-gray-200 hover:text-pink-400 transition-colors cursor-pointer">{{ task.title }}</a>
<div class="flex items-center space-x-2 text-xs text-gray-500 mt-1">
<span class="px-2 py-0.5 bg-gray-700 rounded">{{ task.project }}</span>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-gray-500 text-center py-8 text-sm">Keine offenen Aufgaben</div>
{% endif %}
</div>
</div>
</div>
</main>
<script>
let ws = null;
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
ws.onopen = function() {
document.getElementById('connection-status').textContent = '🟢';
document.getElementById('connection-status').className = 'text-green-400';
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send('ping');
}
}, 30000);
};
ws.onmessage = function(event) {
const data = JSON.parse(event.data);
updateDashboard(data);
};
ws.onclose = function() {
document.getElementById('connection-status').textContent = '🔴';
document.getElementById('connection-status').className = 'text-red-400';
setTimeout(connectWebSocket, 5000);
};
}
function updateDashboard(data) {
if (data.weather && !data.weather.error) {
document.getElementById('weather-temp').textContent = data.weather.temp + '°';
document.getElementById('weather-icon').textContent = data.weather.icon;
document.getElementById('weather-desc').textContent = data.weather.description;
}
if (data.system_status) {
document.getElementById('cpu-percent').textContent = data.system_status.cpu.percent + '%';
document.getElementById('cpu-bar').style.width = data.system_status.cpu.percent + '%';
document.getElementById('ram-percent').textContent =
`${data.system_status.ram.used_gb}/${data.system_status.ram.total_gb} GB (${data.system_status.ram.percent}%)`;
document.getElementById('ram-bar').style.width = data.system_status.ram.percent + '%';
}
}
// Initialize
document.addEventListener('DOMContentLoaded', function() {
connectWebSocket();
// Live Clock
function updateClock() {
const now = new Date();
const timeStr = now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
const dateStr = now.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
document.getElementById('live-clock').textContent = timeStr;
document.getElementById('live-date').textContent = dateStr;
}
setInterval(updateClock, 1000);
updateClock();
});
</script>
</body>
</html>

15
web/index.html Normal file
View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="de" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Daily Briefing</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body class="bg-slate-950 text-slate-100 antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2785
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
web/package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "daily-briefing-web",
"private": true,
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite": "^6.0.7"
}
}

6
web/postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

171
web/src/App.tsx Normal file
View file

@ -0,0 +1,171 @@
import { useDashboard } from "./hooks/useDashboard";
import Clock from "./components/Clock";
import WeatherCard from "./components/WeatherCard";
import HourlyForecast from "./components/HourlyForecast";
import NewsGrid from "./components/NewsGrid";
import ServerCard from "./components/ServerCard";
import HomeAssistant from "./components/HomeAssistant";
import TasksCard from "./components/TasksCard";
import { RefreshCw, Wifi, WifiOff, AlertTriangle } from "lucide-react";
export default function App() {
const { data, loading, error, connected, refresh } = useDashboard();
return (
<div className="min-h-screen text-white">
{/* ---- Error banner ---- */}
{error && (
<div className="fixed top-0 inset-x-0 z-50 flex items-center justify-center gap-2 bg-red-500/15 border-b border-red-500/20 backdrop-blur-sm px-4 py-2 animate-slide-up">
<AlertTriangle className="w-4 h-4 text-red-400 flex-shrink-0" />
<p className="text-xs text-red-300">{error}</p>
<button
onClick={refresh}
className="ml-3 text-xs text-red-300 hover:text-white underline underline-offset-2 transition-colors"
>
Erneut versuchen
</button>
</div>
)}
{/* ---- Header bar ---- */}
<header className="sticky top-0 z-40 border-b border-white/[0.04] bg-slate-950/80 backdrop-blur-lg">
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
{/* Left: title + live indicator */}
<div className="flex items-center gap-3">
<h1 className="text-base sm:text-lg font-bold tracking-tight text-white">
Daily Briefing
</h1>
<LiveIndicator connected={connected} />
</div>
{/* Right: clock + refresh */}
<div className="flex items-center gap-4">
<button
onClick={refresh}
disabled={loading}
className="flex items-center justify-center w-8 h-8 rounded-lg bg-white/5 border border-white/[0.06] hover:bg-white/10 transition-colors disabled:opacity-40"
title="Daten aktualisieren"
>
<RefreshCw className={`w-3.5 h-3.5 text-slate-400 ${loading ? "animate-spin" : ""}`} />
</button>
<Clock />
</div>
</div>
</header>
{/* ---- Main content ---- */}
<main className="max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
{loading && !data ? (
<LoadingSkeleton />
) : data ? (
<>
{/* Row 1: Weather cards + Hourly forecast */}
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<WeatherCard data={data.weather.primary} accent="cyan" />
<WeatherCard data={data.weather.secondary} accent="amber" />
<div className="md:col-span-2">
<HourlyForecast slots={data.weather.hourly} />
</div>
</section>
{/* Row 2: Servers + Home Assistant + Tasks */}
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{data.servers.servers.map((srv) => (
<ServerCard key={srv.name} server={srv} />
))}
<HomeAssistant data={data.ha} />
<TasksCard data={data.tasks} />
</section>
{/* Row 3: News (full width) */}
<section>
<NewsGrid data={data.news} />
</section>
{/* Footer timestamp */}
<footer className="text-center pb-4">
<p className="text-[10px] text-slate-700">
Letzte Aktualisierung:{" "}
{new Date(data.timestamp).toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</p>
</footer>
</>
) : null}
</main>
</div>
);
}
/** Small pulsing dot indicating live WebSocket connection. */
function LiveIndicator({ connected }: { connected: boolean }) {
return (
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/5 border border-white/[0.06]">
<div className="relative">
<div
className={`w-1.5 h-1.5 rounded-full ${connected ? "bg-emerald-400" : "bg-slate-600"}`}
/>
{connected && (
<div className="absolute inset-0 w-1.5 h-1.5 rounded-full bg-emerald-400 animate-ping opacity-50" />
)}
</div>
<span className={`text-[10px] font-medium ${connected ? "text-emerald-400" : "text-slate-600"}`}>
{connected ? "Live" : "Offline"}
</span>
{connected ? (
<Wifi className="w-3 h-3 text-emerald-400/50" />
) : (
<WifiOff className="w-3 h-3 text-slate-600" />
)}
</div>
);
}
/** Skeleton loading state displayed on first load. */
function LoadingSkeleton() {
return (
<div className="space-y-6 animate-pulse">
{/* Row 1: Weather placeholders */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<SkeletonCard className="h-52" />
<SkeletonCard className="h-52" />
<SkeletonCard className="h-24 md:col-span-2" />
</div>
{/* Row 2: Info cards */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<SkeletonCard className="h-72" />
<SkeletonCard className="h-72" />
<SkeletonCard className="h-72" />
<SkeletonCard className="h-72" />
</div>
{/* Row 3: News */}
<div>
<div className="h-6 w-32 rounded bg-white/5 mb-4" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{Array.from({ length: 8 }).map((_, i) => (
<SkeletonCard key={i} className="h-28" />
))}
</div>
</div>
</div>
);
}
function SkeletonCard({ className = "" }: { className?: string }) {
return (
<div
className={`glass-card ${className}`}
>
<div className="p-5 space-y-3">
<div className="h-3 w-1/3 rounded bg-white/5" />
<div className="h-2 w-2/3 rounded bg-white/[0.03]" />
<div className="h-2 w-1/2 rounded bg-white/[0.03]" />
</div>
</div>
);
}

120
web/src/api.ts Normal file
View file

@ -0,0 +1,120 @@
/** API client for the Daily Briefing backend. */
const API_BASE = "/api";
async function fetchJSON<T>(path: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`);
if (!res.ok) throw new Error(`API ${path}: ${res.status}`);
return res.json();
}
// --- Types ---
export interface WeatherData {
location: string;
temp: number;
feels_like: number;
humidity: number;
wind_kmh: number;
description: string;
icon: string;
error?: boolean;
forecast: { date: string; max_temp: number; min_temp: number; icon: string; description: string }[];
}
export interface HourlySlot {
time: string;
temp: number;
icon: string;
precip_chance: number;
}
export interface WeatherResponse {
primary: WeatherData;
secondary: WeatherData;
hourly: HourlySlot[];
}
export interface NewsArticle {
id: number;
source: string;
title: string;
url: string;
category: string | null;
published_at: string;
}
export interface NewsResponse {
articles: NewsArticle[];
total: number;
limit: number;
offset: number;
}
export interface ServerStats {
name: string;
host: string;
online: boolean;
uptime: string;
cpu: { usage_pct: number; cores: number; temp_c: number | null };
ram: { used_gb: number; total_gb: number; pct: number };
array: { status: string; disks: { name: string; status: string; size: string; used: string }[] };
docker: { running: number; containers: { name: string; status: string; image: string }[] };
}
export interface ServersResponse {
servers: ServerStats[];
}
export interface HAData {
online: boolean;
lights: { entity_id: string; name: string; state: string; brightness: number }[];
covers: { entity_id: string; name: string; state: string; position: number }[];
sensors: { entity_id: string; name: string; state: number; unit: string }[];
lights_on: number;
lights_total: number;
error?: boolean;
}
export interface Task {
id: number;
title: string;
done: boolean;
priority: number;
project_name: string;
due_date: string | null;
}
export interface TaskGroup {
open: Task[];
done: Task[];
open_count: number;
}
export interface TasksResponse {
private: TaskGroup;
sams: TaskGroup;
error?: boolean;
}
export interface DashboardData {
weather: WeatherResponse;
news: NewsResponse;
servers: ServersResponse;
ha: HAData;
tasks: TasksResponse;
timestamp: string;
}
// --- Fetch Functions ---
export const fetchWeather = () => fetchJSON<WeatherResponse>("/weather");
export const fetchNews = (limit = 20, offset = 0, category?: string) => {
let q = `/news?limit=${limit}&offset=${offset}`;
if (category) q += `&category=${encodeURIComponent(category)}`;
return fetchJSON<NewsResponse>(q);
};
export const fetchServers = () => fetchJSON<ServersResponse>("/servers");
export const fetchHA = () => fetchJSON<HAData>("/ha");
export const fetchTasks = () => fetchJSON<TasksResponse>("/tasks");
export const fetchAll = () => fetchJSON<DashboardData>("/all");

View file

@ -0,0 +1,46 @@
import { useEffect, useState } from "react";
import { Clock as ClockIcon } from "lucide-react";
/** Live clock with German-locale date. Updates every second. */
export default function Clock() {
const [now, setNow] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1_000);
return () => clearInterval(id);
}, []);
const hours = now.getHours().toString().padStart(2, "0");
const minutes = now.getMinutes().toString().padStart(2, "0");
const seconds = now.getSeconds().toString().padStart(2, "0");
const dateStr = now.toLocaleDateString("de-DE", {
weekday: "short",
day: "2-digit",
month: "long",
year: "numeric",
});
return (
<div className="flex items-center gap-4">
<div className="hidden sm:flex items-center justify-center w-10 h-10 rounded-xl bg-white/5 border border-white/[0.06]">
<ClockIcon className="w-5 h-5 text-slate-400" />
</div>
<div className="flex flex-col items-end sm:items-start">
{/* Time display */}
<div className="flex items-baseline gap-0.5" style={{ fontFamily: "'JetBrains Mono', monospace" }}>
<span className="text-2xl sm:text-3xl font-bold text-white tracking-tight">
{hours}:{minutes}
</span>
<span className="text-sm sm:text-base font-medium text-slate-400">
:{seconds}
</span>
</div>
{/* Date display */}
<p className="text-xs text-slate-500 mt-0.5">{dateStr}</p>
</div>
</div>
);
}

View file

@ -0,0 +1,191 @@
import { Home, Lightbulb, ArrowUp, ArrowDown, Thermometer, WifiOff, Wifi } from "lucide-react";
import type { HAData } from "../api";
interface HomeAssistantProps {
data: HAData;
}
export default function HomeAssistant({ data }: HomeAssistantProps) {
if (!data) return null;
if (data.error) {
return (
<div className="glass-card p-5 animate-fade-in">
<div className="flex items-center gap-3 text-red-400">
<WifiOff className="w-5 h-5" />
<div>
<p className="text-sm font-medium">Home Assistant nicht erreichbar</p>
<p className="text-xs text-slate-500 mt-0.5">Verbindung fehlgeschlagen</p>
</div>
</div>
</div>
);
}
return (
<div className="glass-card p-5 animate-fade-in bg-gradient-to-br from-violet-500/[0.04] to-transparent">
{/* Header */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2.5">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-violet-500/10 border border-violet-500/20">
<Home className="w-4 h-4 text-violet-400" />
</div>
<h3 className="text-sm font-semibold text-white">Home Assistant</h3>
</div>
<div className="flex items-center gap-1.5">
<div className="relative">
<div
className={`w-2 h-2 rounded-full ${data.online ? "bg-emerald-400" : "bg-red-400"}`}
/>
{data.online && (
<div className="absolute inset-0 w-2 h-2 rounded-full bg-emerald-400 animate-ping opacity-50" />
)}
</div>
<span className={`text-[10px] font-medium ${data.online ? "text-emerald-400" : "text-red-400"}`}>
{data.online ? "Online" : "Offline"}
</span>
</div>
</div>
<div className="space-y-5">
{/* Lights Section */}
<section>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-1.5">
<Lightbulb className="w-3.5 h-3.5 text-violet-400/70" />
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">
Lichter
</span>
</div>
<span className="badge bg-violet-500/15 text-violet-300">
{data.lights_on}/{data.lights_total} an
</span>
</div>
{data.lights.length > 0 ? (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
{data.lights.map((light) => {
const isOn = light.state === "on";
return (
<div
key={light.entity_id}
className={`
flex flex-col items-center gap-1.5 px-2 py-2.5 rounded-xl border transition-colors
${
isOn
? "bg-amber-500/10 border-amber-500/20"
: "bg-white/[0.02] border-white/[0.04]"
}
`}
>
<div
className={`w-3 h-3 rounded-full transition-colors ${
isOn
? "bg-amber-400 shadow-[0_0_8px_rgba(251,191,36,0.5)]"
: "bg-slate-700"
}`}
/>
<span className="text-[10px] text-center text-slate-400 leading-tight truncate w-full">
{light.name}
</span>
{isOn && light.brightness > 0 && (
<span className="text-[9px] text-amber-400/70">
{Math.round((light.brightness / 255) * 100)}%
</span>
)}
</div>
);
})}
</div>
) : (
<p className="text-xs text-slate-600">Keine Lichter konfiguriert</p>
)}
</section>
{/* Covers Section */}
{data.covers.length > 0 && (
<section>
<div className="flex items-center gap-1.5 mb-3">
<ArrowUp className="w-3.5 h-3.5 text-violet-400/70" />
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">
Rollos
</span>
</div>
<div className="space-y-1.5">
{data.covers.map((cover) => {
const isOpen = cover.state === "open";
const isClosed = cover.state === "closed";
return (
<div
key={cover.entity_id}
className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/[0.04]"
>
<div className="flex items-center gap-2">
{isOpen ? (
<ArrowUp className="w-3.5 h-3.5 text-emerald-400" />
) : isClosed ? (
<ArrowDown className="w-3.5 h-3.5 text-slate-500" />
) : (
<ArrowDown className="w-3.5 h-3.5 text-amber-400" />
)}
<span className="text-xs text-slate-300">{cover.name}</span>
</div>
<div className="flex items-center gap-2">
{cover.position > 0 && (
<span className="text-[10px] text-slate-500" style={{ fontFamily: "'JetBrains Mono', monospace" }}>
{cover.position}%
</span>
)}
<span
className={`text-[10px] font-medium ${
isOpen ? "text-emerald-400" : isClosed ? "text-slate-500" : "text-amber-400"
}`}
>
{isOpen ? "Offen" : isClosed ? "Zu" : cover.state}
</span>
</div>
</div>
);
})}
</div>
</section>
)}
{/* Temperature Sensors Section */}
{data.sensors.length > 0 && (
<section>
<div className="flex items-center gap-1.5 mb-3">
<Thermometer className="w-3.5 h-3.5 text-violet-400/70" />
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">
Temperaturen
</span>
</div>
<div className="space-y-1.5">
{data.sensors.map((sensor) => (
<div
key={sensor.entity_id}
className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/[0.04]"
>
<span className="text-xs text-slate-300">{sensor.name}</span>
<span
className="text-sm font-semibold text-white"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
{typeof sensor.state === "number"
? sensor.state.toFixed(1)
: sensor.state}
<span className="text-xs text-slate-500 ml-0.5">{sensor.unit}</span>
</span>
</div>
))}
</div>
</section>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,93 @@
import { Droplets } from "lucide-react";
import type { HourlySlot } from "../api";
interface HourlyForecastProps {
slots: HourlySlot[];
}
export default function HourlyForecast({ slots }: HourlyForecastProps) {
if (!slots || slots.length === 0) return null;
return (
<div className="glass-card p-4 animate-fade-in">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-3 px-1">
Stundenverlauf
</h3>
{/* Horizontal scroll container - hidden scrollbar via globals.css */}
<div
className="flex gap-2 overflow-x-auto pb-1"
style={{ scrollbarWidth: "none" }}
>
{slots.map((slot, i) => {
const hour = formatHour(slot.time);
const isNow = i === 0;
return (
<div
key={slot.time}
className={`
flex flex-col items-center gap-1.5 min-w-[4.25rem] px-2.5 py-3 rounded-xl
border transition-colors duration-200
${
isNow
? "bg-white/[0.06] border-cyan-500/20"
: "bg-white/[0.02] border-white/[0.04] hover:bg-white/[0.04]"
}
`}
>
{/* Time label */}
<span
className={`text-[11px] font-medium ${isNow ? "text-cyan-400" : "text-slate-500"}`}
>
{isNow ? "Jetzt" : hour}
</span>
{/* Weather icon */}
<span className="text-xl select-none" role="img" aria-label="weather">
{slot.icon}
</span>
{/* Temperature */}
<span
className="text-sm font-bold text-white"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
{Math.round(slot.temp)}&deg;
</span>
{/* Precipitation bar */}
{slot.precip_chance > 0 && (
<div className="flex items-center gap-1">
<Droplets className="w-2.5 h-2.5 text-blue-400/60" />
<span className="text-[10px] text-blue-400/80">
{slot.precip_chance}%
</span>
</div>
)}
{/* Precip bar visual */}
<div className="w-full progress-bar mt-0.5">
<div
className="progress-fill bg-blue-500/50"
style={{ width: `${Math.min(slot.precip_chance, 100)}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
);
}
/** Extracts "HH:mm" from an ISO time string or "HH:00" format. */
function formatHour(time: string): string {
try {
const d = new Date(time);
if (isNaN(d.getTime())) return time;
return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
} catch {
return time;
}
}

View file

@ -0,0 +1,144 @@
import { useState, useMemo } from "react";
import { ExternalLink } from "lucide-react";
import type { NewsResponse, NewsArticle } from "../api";
interface NewsGridProps {
data: NewsResponse;
}
const CATEGORIES = [
{ key: "all", label: "Alle" },
{ key: "tech", label: "Tech" },
{ key: "wirtschaft", label: "Wirtschaft" },
{ key: "politik", label: "Politik" },
{ key: "allgemein", label: "Allgemein" },
] as const;
/** Map source names to badge colours. */
const SOURCE_COLORS: Record<string, string> = {
"heise": "bg-orange-500/20 text-orange-300",
"golem": "bg-blue-500/20 text-blue-300",
"spiegel": "bg-red-500/20 text-red-300",
"tagesschau": "bg-sky-500/20 text-sky-300",
"zeit": "bg-slate-500/20 text-slate-300",
"faz": "bg-emerald-500/20 text-emerald-300",
"welt": "bg-indigo-500/20 text-indigo-300",
"t3n": "bg-purple-500/20 text-purple-300",
"default": "bg-amber-500/15 text-amber-300",
};
function sourceColor(source: string): string {
const key = source.toLowerCase();
for (const [prefix, cls] of Object.entries(SOURCE_COLORS)) {
if (key.includes(prefix)) return cls;
}
return SOURCE_COLORS.default;
}
/** Return a German relative time string like "vor 2 Stunden". */
function relativeTime(isoDate: string): string {
try {
const date = new Date(isoDate);
if (isNaN(date.getTime())) return "";
const diff = Date.now() - date.getTime();
const seconds = Math.floor(diff / 1_000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return "gerade eben";
if (minutes < 60) return `vor ${minutes} Min.`;
if (hours < 24) return hours === 1 ? "vor 1 Stunde" : `vor ${hours} Stunden`;
if (days === 1) return "gestern";
if (days < 7) return `vor ${days} Tagen`;
return date.toLocaleDateString("de-DE", { day: "2-digit", month: "short" });
} catch {
return "";
}
}
export default function NewsGrid({ data }: NewsGridProps) {
const [activeCategory, setActiveCategory] = useState<string>("all");
const filteredArticles = useMemo(() => {
if (!data?.articles) return [];
if (activeCategory === "all") return data.articles;
return data.articles.filter(
(a) => a.category?.toLowerCase() === activeCategory
);
}, [data, activeCategory]);
if (!data?.articles) return null;
return (
<div className="animate-fade-in">
{/* Header + category tabs */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-400">
Nachrichten
<span className="ml-2 text-xs font-normal text-slate-600">
{filteredArticles.length}
</span>
</h2>
<div className="flex gap-1 flex-wrap">
{CATEGORIES.map((cat) => (
<button
key={cat.key}
onClick={() => setActiveCategory(cat.key)}
className={`category-tab ${activeCategory === cat.key ? "active" : ""}`}
>
{cat.label}
</button>
))}
</div>
</div>
{/* Articles grid */}
{filteredArticles.length === 0 ? (
<div className="glass-card p-8 text-center text-slate-500 text-sm">
Keine Artikel in dieser Kategorie.
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{filteredArticles.map((article) => (
<ArticleCard key={article.id} article={article} />
))}
</div>
)}
</div>
);
}
function ArticleCard({ article }: { article: NewsArticle }) {
return (
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="glass-card-hover group block p-4 cursor-pointer"
>
<div className="flex items-center justify-between mb-2.5">
<span className={`badge ${sourceColor(article.source)}`}>
{article.source}
</span>
<ExternalLink className="w-3 h-3 text-slate-600 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<h3 className="text-sm font-medium text-slate-200 leading-snug line-clamp-2 group-hover:text-white transition-colors">
{article.title}
</h3>
<div className="flex items-center gap-2 mt-3">
{article.category && (
<span className="text-[10px] text-slate-600 uppercase tracking-wider">
{article.category}
</span>
)}
<span className="text-[10px] text-slate-600 ml-auto">
{relativeTime(article.published_at)}
</span>
</div>
</a>
);
}

View file

@ -0,0 +1,241 @@
import { useState } from "react";
import { Server, Cpu, HardDrive, Wifi, WifiOff, ChevronDown, ChevronUp, Box } from "lucide-react";
import type { ServerStats } from "../api";
interface ServerCardProps {
server: ServerStats;
}
/** Return Tailwind text colour class based on percentage thresholds. */
function usageColor(pct: number): string {
if (pct >= 80) return "text-red-400";
if (pct >= 60) return "text-amber-400";
return "text-emerald-400";
}
/** Return SVG stroke colour (hex) based on percentage thresholds. */
function usageStroke(pct: number): string {
if (pct >= 80) return "#f87171"; // red-400
if (pct >= 60) return "#fbbf24"; // amber-400
return "#34d399"; // emerald-400
}
/** Return track background colour based on percentage thresholds. */
function usageBarBg(pct: number): string {
if (pct >= 80) return "bg-red-500/70";
if (pct >= 60) return "bg-amber-500/70";
return "bg-emerald-500/70";
}
export default function ServerCard({ server }: ServerCardProps) {
const [dockerExpanded, setDockerExpanded] = useState(false);
if (!server) return null;
const cpuPct = Math.round(server.cpu.usage_pct);
const ramPct = Math.round(server.ram.pct);
return (
<div className="glass-card p-5 animate-fade-in bg-gradient-to-br from-emerald-500/[0.04] to-transparent">
{/* Header */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2.5">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-500/10 border border-emerald-500/20">
<Server className="w-4 h-4 text-emerald-400" />
</div>
<div>
<h3 className="text-sm font-semibold text-white">{server.name}</h3>
<p className="text-[10px] text-slate-500">{server.host}</p>
</div>
</div>
<StatusDot online={server.online} />
</div>
{!server.online ? (
<div className="flex items-center justify-center py-8 text-slate-500 text-sm gap-2">
<WifiOff className="w-4 h-4" />
<span>Offline</span>
</div>
) : (
<div className="space-y-5">
{/* CPU Section */}
<div className="flex items-center gap-4">
<CpuRing pct={cpuPct} size={64} strokeWidth={5} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-1">
<Cpu className="w-3.5 h-3.5 text-slate-500" />
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">
CPU
</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className={`text-xl font-bold ${usageColor(cpuPct)}`} style={{ fontFamily: "'JetBrains Mono', monospace" }}>
{cpuPct}%
</span>
{server.cpu.temp_c !== null && (
<span className="text-xs text-slate-500">
{Math.round(server.cpu.temp_c)}&deg;C
</span>
)}
</div>
<p className="text-[10px] text-slate-600 mt-0.5">
{server.cpu.cores} Kerne
</p>
</div>
</div>
{/* RAM Section */}
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<HardDrive className="w-3.5 h-3.5 text-slate-500" />
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">
RAM
</span>
</div>
<span className={`text-xs font-semibold ${usageColor(ramPct)}`} style={{ fontFamily: "'JetBrains Mono', monospace" }}>
{ramPct}%
</span>
</div>
<div className="progress-bar">
<div
className={`progress-fill ${usageBarBg(ramPct)}`}
style={{ width: `${ramPct}%` }}
/>
</div>
<p className="text-[10px] text-slate-600 mt-1.5">
{server.ram.used_gb.toFixed(1)} / {server.ram.total_gb.toFixed(1)} GB
</p>
</div>
{/* Uptime */}
{server.uptime && (
<p className="text-[10px] text-slate-600">
Uptime: <span className="text-slate-400">{server.uptime}</span>
</p>
)}
{/* Docker Section */}
{server.docker && (
<div>
<button
onClick={() => setDockerExpanded(!dockerExpanded)}
className="flex items-center justify-between w-full group"
>
<div className="flex items-center gap-1.5">
<Box className="w-3.5 h-3.5 text-slate-500" />
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">
Docker
</span>
<span className="badge bg-emerald-500/15 text-emerald-300 ml-1">
{server.docker.running}
</span>
</div>
{dockerExpanded ? (
<ChevronUp className="w-3.5 h-3.5 text-slate-600 group-hover:text-slate-400 transition-colors" />
) : (
<ChevronDown className="w-3.5 h-3.5 text-slate-600 group-hover:text-slate-400 transition-colors" />
)}
</button>
{dockerExpanded && server.docker.containers.length > 0 && (
<div className="mt-2.5 space-y-1 max-h-48 overflow-y-auto">
{server.docker.containers.map((c) => (
<div
key={c.name}
className="flex items-center justify-between px-2.5 py-1.5 rounded-lg bg-white/[0.02] border border-white/[0.04]"
>
<span className="text-xs text-slate-300 truncate mr-2">{c.name}</span>
<span
className={`text-[10px] font-medium ${
c.status.toLowerCase().includes("up")
? "text-emerald-400"
: "text-red-400"
}`}
>
{c.status.toLowerCase().includes("up") ? "Running" : c.status}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
);
}
/** Green/Red online indicator dot. */
function StatusDot({ online }: { online: boolean }) {
return (
<div className="flex items-center gap-1.5">
<div className="relative">
<div
className={`w-2 h-2 rounded-full ${
online ? "bg-emerald-400" : "bg-red-400"
}`}
/>
{online && (
<div className="absolute inset-0 w-2 h-2 rounded-full bg-emerald-400 animate-ping opacity-50" />
)}
</div>
<span className={`text-[10px] font-medium ${online ? "text-emerald-400" : "text-red-400"}`}>
{online ? "Online" : "Offline"}
</span>
</div>
);
}
/** SVG circular progress ring for CPU usage. */
function CpuRing({
pct,
size,
strokeWidth,
}: {
pct: number;
size: number;
strokeWidth: number;
}) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (pct / 100) * circumference;
return (
<div className="relative flex-shrink-0" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
{/* Track */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="rgba(148,163,184,0.1)"
strokeWidth={strokeWidth}
/>
{/* Progress */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={usageStroke(pct)}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
style={{ transition: "stroke-dashoffset 0.7s ease-out, stroke 0.3s ease" }}
/>
</svg>
{/* Center icon */}
<div className="absolute inset-0 flex items-center justify-center">
<Cpu className={`w-4 h-4 ${usageColor(pct)} opacity-50`} />
</div>
</div>
);
}

View file

@ -0,0 +1,183 @@
import { useState } from "react";
import { CheckSquare, Square, AlertTriangle, Calendar } from "lucide-react";
import type { TasksResponse, Task } from "../api";
interface TasksCardProps {
data: TasksResponse;
}
type TabKey = "private" | "sams";
const TABS: { key: TabKey; label: string }[] = [
{ key: "private", label: "Privat" },
{ key: "sams", label: "Sam's" },
];
/** Colour for task priority (1 = highest). */
function priorityIndicator(priority: number): { color: string; label: string } {
if (priority <= 1) return { color: "bg-red-500", label: "Hoch" };
if (priority <= 2) return { color: "bg-amber-500", label: "Mittel" };
if (priority <= 3) return { color: "bg-blue-500", label: "Normal" };
return { color: "bg-slate-500", label: "Niedrig" };
}
/** Format a due date string to a short German date. */
function formatDueDate(iso: string | null): string | null {
if (!iso) return null;
try {
const d = new Date(iso);
if (isNaN(d.getTime())) return null;
const now = new Date();
const diffDays = Math.ceil((d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays < 0) return "Ueberfaellig";
if (diffDays === 0) return "Heute";
if (diffDays === 1) return "Morgen";
return d.toLocaleDateString("de-DE", { day: "2-digit", month: "short" });
} catch {
return null;
}
}
export default function TasksCard({ data }: TasksCardProps) {
const [activeTab, setActiveTab] = useState<TabKey>("private");
if (!data) return null;
if (data.error) {
return (
<div className="glass-card p-5 animate-fade-in">
<div className="flex items-center gap-3 text-red-400">
<AlertTriangle className="w-5 h-5" />
<div>
<p className="text-sm font-medium">Aufgaben nicht verfuegbar</p>
<p className="text-xs text-slate-500 mt-0.5">Verbindung fehlgeschlagen</p>
</div>
</div>
</div>
);
}
const group = activeTab === "private" ? data.private : data.sams;
const tasks = group?.open ?? [];
return (
<div className="glass-card p-5 animate-fade-in bg-gradient-to-br from-blue-500/[0.04] to-transparent">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2.5">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-blue-500/10 border border-blue-500/20">
<CheckSquare className="w-4 h-4 text-blue-400" />
</div>
<h3 className="text-sm font-semibold text-white">Aufgaben</h3>
</div>
</div>
{/* Tab buttons */}
<div className="flex gap-2 mb-4">
{TABS.map((tab) => {
const tabGroup = tab.key === "private" ? data.private : data.sams;
const openCount = tabGroup?.open_count ?? 0;
const isActive = activeTab === tab.key;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`
flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-medium transition-all duration-200
${
isActive
? "bg-blue-500/15 text-blue-300 border border-blue-500/20"
: "bg-white/[0.03] text-slate-400 border border-white/[0.04] hover:bg-white/[0.06] hover:text-slate-300"
}
`}
>
{tab.label}
{openCount > 0 && (
<span
className={`
inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 rounded-full text-[10px] font-bold
${isActive ? "bg-blue-500/25 text-blue-200" : "bg-white/[0.06] text-slate-500"}
`}
>
{openCount}
</span>
)}
</button>
);
})}
</div>
{/* Task list */}
{tasks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-slate-600">
<CheckSquare className="w-8 h-8 mb-2 opacity-30" />
<p className="text-xs">Alles erledigt!</p>
</div>
) : (
<div className="space-y-1.5 max-h-80 overflow-y-auto pr-1">
{tasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</div>
)}
</div>
);
}
function TaskItem({ task }: { task: Task }) {
const p = priorityIndicator(task.priority);
const due = formatDueDate(task.due_date);
const isOverdue = due === "Ueberfaellig";
return (
<div className="flex items-start gap-3 px-3 py-2.5 rounded-xl bg-white/[0.02] border border-white/[0.04] hover:bg-white/[0.04] transition-colors group">
{/* Visual checkbox */}
<div className="mt-0.5 flex-shrink-0">
{task.done ? (
<CheckSquare className="w-4 h-4 text-blue-400" />
) : (
<Square className="w-4 h-4 text-slate-600 group-hover:text-slate-400 transition-colors" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p
className={`text-sm leading-snug ${
task.done ? "text-slate-600 line-through" : "text-slate-200"
}`}
>
{task.title}
</p>
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
{/* Project badge */}
{task.project_name && (
<span className="badge bg-indigo-500/15 text-indigo-300">
{task.project_name}
</span>
)}
{/* Due date */}
{due && (
<span
className={`flex items-center gap-1 text-[10px] font-medium ${
isOverdue ? "text-red-400" : "text-slate-500"
}`}
>
<Calendar className="w-2.5 h-2.5" />
{due}
</span>
)}
</div>
</div>
{/* Priority dot */}
<div className="flex-shrink-0 mt-1.5" title={p.label}>
<div className={`w-2 h-2 rounded-full ${p.color}`} />
</div>
</div>
);
}

View file

@ -0,0 +1,115 @@
import { Thermometer, Droplets, Wind, CloudOff } from "lucide-react";
import type { WeatherData } from "../api";
interface WeatherCardProps {
data: WeatherData;
accent: "cyan" | "amber";
}
const accentMap = {
cyan: {
gradient: "from-cyan-500/10 to-cyan-900/5",
text: "text-cyan-400",
border: "border-cyan-500/20",
badge: "bg-cyan-500/15 text-cyan-300",
statIcon: "text-cyan-400/70",
ring: "ring-cyan-500/10",
},
amber: {
gradient: "from-amber-500/10 to-amber-900/5",
text: "text-amber-400",
border: "border-amber-500/20",
badge: "bg-amber-500/15 text-amber-300",
statIcon: "text-amber-400/70",
ring: "ring-amber-500/10",
},
} as const;
export default function WeatherCard({ data, accent }: WeatherCardProps) {
const a = accentMap[accent];
if (data.error) {
return (
<div className="glass-card p-5 animate-fade-in">
<div className="flex items-center gap-3 text-red-400">
<CloudOff className="w-5 h-5" />
<div>
<p className="text-sm font-medium">Wetter nicht verfuegbar</p>
<p className="text-xs text-slate-500 mt-0.5">{data.location || "Unbekannt"}</p>
</div>
</div>
</div>
);
}
return (
<div className={`glass-card p-5 animate-fade-in bg-gradient-to-br ${a.gradient}`}>
{/* Header: location badge */}
<div className="flex items-center justify-between mb-4">
<span className={`badge ${a.badge}`}>{data.location}</span>
</div>
{/* Main row: icon + temp */}
<div className="flex items-center justify-between mb-4">
<div>
<div className="flex items-baseline gap-1">
<span
className="text-5xl font-extrabold text-white tracking-tighter"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
{Math.round(data.temp)}
</span>
<span className="text-2xl font-light text-slate-400">&deg;</span>
</div>
<p className="text-sm text-slate-400 mt-1 capitalize">{data.description}</p>
</div>
<span className="text-5xl select-none" role="img" aria-label="weather">
{data.icon}
</span>
</div>
{/* Stat grid */}
<div className="grid grid-cols-3 gap-3">
<StatItem
icon={<Thermometer className={`w-3.5 h-3.5 ${a.statIcon}`} />}
label="Gefuehlt"
value={`${Math.round(data.feels_like)}\u00B0`}
/>
<StatItem
icon={<Droplets className={`w-3.5 h-3.5 ${a.statIcon}`} />}
label="Feuchte"
value={`${data.humidity}%`}
/>
<StatItem
icon={<Wind className={`w-3.5 h-3.5 ${a.statIcon}`} />}
label="Wind"
value={`${Math.round(data.wind_kmh)} km/h`}
/>
</div>
</div>
);
}
function StatItem({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex flex-col items-center gap-1.5 rounded-xl bg-white/[0.03] border border-white/[0.04] px-2 py-2.5">
{icon}
<span
className="text-sm font-semibold text-white"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
{value}
</span>
<span className="text-[10px] uppercase tracking-wider text-slate-500">{label}</span>
</div>
);
}

View file

@ -0,0 +1,57 @@
import { useCallback, useEffect, useState } from "react";
import type { DashboardData } from "../api";
import { fetchAll } from "../api";
import { useWebSocket } from "./useWebSocket";
import { MOCK_DATA } from "../mockData";
/**
* Main dashboard hook.
* Fetches initial data via REST, then switches to WebSocket for live updates.
* Falls back to mock data in development when the backend is unavailable.
*/
export function useDashboard() {
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [usingMock, setUsingMock] = useState(false);
const { data: wsData, connected } = useWebSocket();
// Initial REST fetch
const loadInitial = useCallback(async () => {
try {
setLoading(true);
setError(null);
const result = await fetchAll();
setData(result);
setUsingMock(false);
} catch {
// Fall back to mock data in dev
if (import.meta.env.DEV) {
setData({ ...MOCK_DATA, timestamp: new Date().toISOString() });
setUsingMock(true);
setError(null);
} else {
setError("Verbindung zum Server fehlgeschlagen");
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadInitial();
}, [loadInitial]);
// WebSocket updates override REST data
useEffect(() => {
if (wsData) {
setData(wsData);
setLoading(false);
setError(null);
setUsingMock(false);
}
}, [wsData]);
return { data, loading, error, connected: connected || usingMock, refresh: loadInitial };
}

View file

@ -0,0 +1,79 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { DashboardData } from "../api";
const WS_RECONNECT_MS = 5_000;
const WS_PING_INTERVAL_MS = 15_000;
export function useWebSocket() {
const [data, setData] = useState<DashboardData | null>(null);
const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const pingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const reconnectRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const connect = useCallback(() => {
if (!mountedRef.current) return;
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${proto}//${location.host}/ws`);
wsRef.current = ws;
ws.onopen = () => {
if (!mountedRef.current) return;
setConnected(true);
ws.send("ping");
pingRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send("ping");
}
}, WS_PING_INTERVAL_MS);
};
ws.onmessage = (ev) => {
if (!mountedRef.current) return;
try {
const parsed = JSON.parse(ev.data) as DashboardData;
setData(parsed);
} catch {
// ignore malformed messages
}
};
ws.onclose = () => {
if (!mountedRef.current) return;
setConnected(false);
cleanup();
reconnectRef.current = setTimeout(connect, WS_RECONNECT_MS);
};
ws.onerror = () => {
ws.close();
};
}, []);
const cleanup = useCallback(() => {
if (pingRef.current) {
clearInterval(pingRef.current);
pingRef.current = null;
}
}, []);
useEffect(() => {
mountedRef.current = true;
connect();
return () => {
mountedRef.current = false;
cleanup();
if (reconnectRef.current) clearTimeout(reconnectRef.current);
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.close();
}
};
}, [connect, cleanup]);
return { data, connected };
}

10
web/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles/globals.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

175
web/src/mockData.ts Normal file
View file

@ -0,0 +1,175 @@
/** Mock data for development preview when backend is unavailable. */
import type { DashboardData } from "./api";
export const MOCK_DATA: DashboardData = {
timestamp: new Date().toISOString(),
weather: {
primary: {
location: "Leverkusen",
temp: 8,
feels_like: 5,
humidity: 72,
wind_kmh: 18,
description: "Teilweise bewölkt",
icon: "⛅",
forecast: [
{ date: "2026-03-02", max_temp: 10, min_temp: 3, icon: "⛅", description: "Partly cloudy" },
{ date: "2026-03-03", max_temp: 12, min_temp: 5, icon: "🌤️", description: "Sunny intervals" },
{ date: "2026-03-04", max_temp: 7, min_temp: 1, icon: "🌧️", description: "Rain" },
],
},
secondary: {
location: "Rab, Kroatien",
temp: 16,
feels_like: 15,
humidity: 58,
wind_kmh: 12,
description: "Sonnig",
icon: "☀️",
forecast: [
{ date: "2026-03-02", max_temp: 18, min_temp: 10, icon: "☀️", description: "Sunny" },
{ date: "2026-03-03", max_temp: 19, min_temp: 11, icon: "🌤️", description: "Mostly sunny" },
{ date: "2026-03-04", max_temp: 17, min_temp: 9, icon: "⛅", description: "Partly cloudy" },
],
},
hourly: [
{ time: "14:00", temp: 8, icon: "⛅", precip_chance: 10 },
{ time: "15:00", temp: 9, icon: "🌤️", precip_chance: 5 },
{ time: "16:00", temp: 8, icon: "⛅", precip_chance: 15 },
{ time: "17:00", temp: 7, icon: "☁️", precip_chance: 25 },
{ time: "18:00", temp: 6, icon: "☁️", precip_chance: 30 },
{ time: "19:00", temp: 5, icon: "🌧️", precip_chance: 55 },
{ time: "20:00", temp: 4, icon: "🌧️", precip_chance: 60 },
{ time: "21:00", temp: 4, icon: "☁️", precip_chance: 35 },
],
},
news: {
articles: [
{ id: 1, source: "tagesschau", title: "Bundesregierung beschließt neues Infrastrukturpaket", url: "#", category: "politik", published_at: new Date(Date.now() - 3600000).toISOString() },
{ id: 2, source: "heise", title: "Intel stellt neue Arc-Grafikkarten vor: Battlemage kommt im Q2", url: "#", category: "tech", published_at: new Date(Date.now() - 7200000).toISOString() },
{ id: 3, source: "spiegel", title: "DAX erreicht neues Allzeithoch trotz geopolitischer Spannungen", url: "#", category: "wirtschaft", published_at: new Date(Date.now() - 10800000).toISOString() },
{ id: 4, source: "google_finance", title: "NVIDIA reports record quarterly revenue driven by AI demand", url: "#", category: "wirtschaft", published_at: new Date(Date.now() - 14400000).toISOString() },
{ id: 5, source: "tagesschau", title: "Klimagipfel: Neue Vereinbarungen zum CO2-Ausstoß beschlossen", url: "#", category: "politik", published_at: new Date(Date.now() - 18000000).toISOString() },
{ id: 6, source: "heise", title: "Linux 6.14 Kernel bringt massiven Performance-Boost für AMD Zen 5", url: "#", category: "tech", published_at: new Date(Date.now() - 21600000).toISOString() },
{ id: 7, source: "google_de_finance", title: "Rheinmetall-Aktie steigt um 8% nach neuem NATO-Auftrag", url: "#", category: "wirtschaft", published_at: new Date(Date.now() - 25200000).toISOString() },
{ id: 8, source: "spiegel", title: "Studie: Homeoffice bleibt auch 2026 die beliebteste Arbeitsform", url: "#", category: "allgemein", published_at: new Date(Date.now() - 28800000).toISOString() },
{ id: 9, source: "heise", title: "Docker Desktop 5.0: Neue Container-Management-Features im Überblick", url: "#", category: "tech", published_at: new Date(Date.now() - 32400000).toISOString() },
{ id: 10, source: "tagesschau", title: "Europäische Zentralbank senkt Leitzins um 0.25 Prozentpunkte", url: "#", category: "wirtschaft", published_at: new Date(Date.now() - 36000000).toISOString() },
{ id: 11, source: "google_defense", title: "NATO increases defense spending targets for 2027", url: "#", category: "politik", published_at: new Date(Date.now() - 39600000).toISOString() },
{ id: 12, source: "spiegel", title: "Unraid 7.0: Das neue Server-Betriebssystem im Test", url: "#", category: "tech", published_at: new Date(Date.now() - 43200000).toISOString() },
],
total: 12,
limit: 20,
offset: 0,
},
servers: {
servers: [
{
name: "Daddelolymp",
host: "10.10.10.10",
online: true,
uptime: "42 days, 7:23",
cpu: { usage_pct: 38, cores: 16, temp_c: 52 },
ram: { used_gb: 24.3, total_gb: 32, pct: 76 },
array: {
status: "normal",
disks: [
{ name: "Disk 1", status: "active", size: "8 TB", used: "5.2 TB" },
{ name: "Disk 2", status: "active", size: "8 TB", used: "6.1 TB" },
{ name: "Disk 3", status: "active", size: "4 TB", used: "2.8 TB" },
{ name: "Parity", status: "active", size: "8 TB", used: "-" },
{ name: "Cache", status: "active", size: "1 TB", used: "320 GB" },
],
},
docker: {
running: 14,
containers: [
{ name: "gitlab", status: "running", image: "gitlab/gitlab-ce" },
{ name: "n8n", status: "running", image: "n8nio/n8n" },
{ name: "postgres", status: "running", image: "postgres:15" },
{ name: "daily-briefing", status: "running", image: "daily-briefing" },
{ name: "jukebox-vibe", status: "running", image: "jukebox-vibe" },
{ name: "redis", status: "running", image: "redis:7" },
{ name: "vikunja", status: "running", image: "vikunja/vikunja" },
{ name: "nginx", status: "running", image: "nginx:alpine" },
],
},
},
{
name: "Moneyboy",
host: "192.168.1.100",
online: true,
uptime: "12 days, 14:52",
cpu: { usage_pct: 12, cores: 10, temp_c: 41 },
ram: { used_gb: 8.7, total_gb: 16, pct: 54 },
array: {
status: "normal",
disks: [
{ name: "Disk 1", status: "active", size: "4 TB", used: "1.8 TB" },
{ name: "Disk 2", status: "active", size: "4 TB", used: "2.1 TB" },
{ name: "Cache", status: "active", size: "500 GB", used: "180 GB" },
],
},
docker: {
running: 6,
containers: [
{ name: "freqtrade", status: "running", image: "freqtradeorg/freqtrade" },
{ name: "plex", status: "running", image: "plexinc/pms-docker" },
{ name: "nextcloud", status: "running", image: "nextcloud" },
],
},
},
],
},
ha: {
online: true,
lights: [
{ entity_id: "light.wohnzimmer", name: "Wohnzimmer", state: "on", brightness: 80 },
{ entity_id: "light.schlafzimmer", name: "Schlafzimmer", state: "off", brightness: 0 },
{ entity_id: "light.kueche", name: "Küche", state: "on", brightness: 100 },
{ entity_id: "light.flur", name: "Flur", state: "off", brightness: 0 },
{ entity_id: "light.badezimmer", name: "Badezimmer", state: "on", brightness: 60 },
{ entity_id: "light.buero", name: "Büro", state: "off", brightness: 0 },
{ entity_id: "light.balkon", name: "Balkon", state: "off", brightness: 0 },
{ entity_id: "light.gaestezimmer", name: "Gästezimmer", state: "off", brightness: 0 },
],
covers: [
{ entity_id: "cover.wohnzimmer", name: "Wohnzimmer Rollo", state: "open", position: 100 },
{ entity_id: "cover.schlafzimmer", name: "Schlafzimmer Rollo", state: "closed", position: 0 },
{ entity_id: "cover.kueche", name: "Küche Rollo", state: "open", position: 75 },
],
sensors: [
{ entity_id: "sensor.temp_wohnzimmer", name: "Wohnzimmer", state: 21.5, unit: "°C" },
{ entity_id: "sensor.temp_aussen", name: "Außen", state: 7.8, unit: "°C" },
{ entity_id: "sensor.temp_schlafzimmer", name: "Schlafzimmer", state: 19.2, unit: "°C" },
],
lights_on: 3,
lights_total: 8,
},
tasks: {
private: {
open: [
{ id: 101, title: "Garten winterfest machen", done: false, priority: 3, project_name: "Haus & Garten", due_date: "2026-03-05" },
{ id: 102, title: "Heizung warten lassen", done: false, priority: 2, project_name: "Haus & Garten", due_date: "2026-03-10" },
{ id: 103, title: "Projekt-Bericht abgeben", done: false, priority: 4, project_name: "Jugendeinrichtung", due_date: "2026-03-03" },
{ id: 104, title: "Elternabend planen", done: false, priority: 1, project_name: "Jugendeinrichtung", due_date: null },
{ id: 105, title: "Neue Pflanzen bestellen", done: false, priority: 1, project_name: "Haus & Garten", due_date: null },
],
done: [
{ id: 106, title: "Müll rausbringen", done: true, priority: 1, project_name: "Haus & Garten", due_date: null },
],
open_count: 5,
},
sams: {
open: [
{ id: 201, title: "Daily Briefing Dashboard refactoren", done: false, priority: 4, project_name: "OpenClaw AI", due_date: "2026-03-02" },
{ id: 202, title: "n8n Workflows aufräumen", done: false, priority: 3, project_name: "OpenClaw AI", due_date: null },
{ id: 203, title: "Trading Bot backtesting", done: false, priority: 2, project_name: "Sam's Wunderwelt", due_date: null },
],
done: [
{ id: 204, title: "Jukebox Drag & Drop Feature", done: true, priority: 3, project_name: "OpenClaw AI", due_date: null },
],
open_count: 3,
},
},
};

126
web/src/styles/globals.css Normal file
View file

@ -0,0 +1,126 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
box-sizing: border-box;
}
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
background: linear-gradient(135deg, #0a0e1a 0%, #0f172a 50%, #0c1220 100%);
min-height: 100vh;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.2);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.4);
}
}
@layer components {
.glass-card {
@apply relative overflow-hidden rounded-2xl border border-white/[0.06]
bg-white/[0.03] backdrop-blur-md shadow-xl shadow-black/20;
}
.glass-card-hover {
@apply glass-card transition-all duration-300
hover:border-white/[0.12] hover:bg-white/[0.05]
hover:shadow-2xl hover:shadow-black/30
hover:-translate-y-0.5;
}
.glass-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.04) 0%,
transparent 50%
);
pointer-events: none;
}
.accent-glow {
position: relative;
}
.accent-glow::after {
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.accent-glow:hover::after {
opacity: 1;
}
.stat-value {
@apply text-3xl font-bold tracking-tight;
font-family: "JetBrains Mono", monospace;
}
.stat-label {
@apply text-xs font-medium uppercase tracking-wider text-slate-400;
}
.badge {
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px]
font-semibold uppercase tracking-wider;
}
.progress-bar {
@apply h-1.5 rounded-full bg-slate-800 overflow-hidden;
}
.progress-fill {
@apply h-full rounded-full transition-all duration-700 ease-out;
}
.category-tab {
@apply px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200
cursor-pointer select-none;
}
.category-tab.active {
@apply bg-white/10 text-white;
}
.category-tab:not(.active) {
@apply text-slate-400 hover:text-slate-200 hover:bg-white/5;
}
}
@layer utilities {
.text-gradient {
@apply bg-clip-text text-transparent;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}

1
web/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

34
web/tailwind.config.js Normal file
View file

@ -0,0 +1,34 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
surface: {
50: "rgba(255,255,255,0.05)",
100: "rgba(255,255,255,0.08)",
200: "rgba(255,255,255,0.12)",
},
},
backdropBlur: {
xs: "2px",
},
animation: {
"fade-in": "fadeIn 0.5s ease-out",
"slide-up": "slideUp 0.4s ease-out",
"pulse-slow": "pulse 3s ease-in-out infinite",
},
keyframes: {
fadeIn: {
"0%": { opacity: "0", transform: "translateY(8px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
slideUp: {
"0%": { opacity: "0", transform: "translateY(16px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
},
},
},
plugins: [],
};

21
web/tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}

23
web/vite.config.ts Normal file
View file

@ -0,0 +1,23 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
},
"/ws": {
target: "ws://localhost:8080",
ws: true,
},
},
},
build: {
outDir: "dist",
emptyOutDir: true,
},
});