refactor: complete rewrite as React+FastAPI dashboard
Replace monolithic Jinja2 template with modern stack: Backend (FastAPI): - Modular router/service architecture - Async PostgreSQL (asyncpg) for news from n8n pipeline - Live Unraid server stats (2 servers via API) - Home Assistant, Vikunja tasks, weather (wttr.in) - WebSocket broadcast for real-time updates (15s) - TTL cache per endpoint, all config via ENV vars Frontend (React + Vite + TypeScript): - Glassmorphism dark theme with Tailwind CSS - Responsive grid: mobile/tablet/desktop/ultrawide - Weather cards, hourly forecast, news with category tabs - Server stats (CPU ring, RAM bar, Docker list) - Home Assistant controls, task management - Live clock, WebSocket connection indicator Infrastructure: - Multi-stage Dockerfile (node:22-alpine + python:3.11-slim) - docker-compose with full ENV configuration - Kaniko CI/CD pipeline for GitLab registry Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4bbc125a67
commit
9f7330e217
48 changed files with 6390 additions and 1461 deletions
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal 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/
|
||||
|
|
@ -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:
|
||||
- |
|
||||
if [ "$CI_COMMIT_REF_NAME" = "main" ] || [ "$CI_COMMIT_REF_NAME" = "master" ]; then
|
||||
TAG="latest"
|
||||
else
|
||||
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"
|
||||
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" \
|
||||
|
|
|
|||
26
Dockerfile
26
Dockerfile
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
0
server/__init__.py
Normal file
41
server/cache.py
Normal file
41
server/cache.py
Normal 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
98
server/config.py
Normal 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
89
server/main.py
Normal 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"],
|
||||
}
|
||||
0
server/routers/__init__.py
Normal file
0
server/routers/__init__.py
Normal file
123
server/routers/dashboard.py
Normal file
123
server/routers/dashboard.py
Normal 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)}
|
||||
47
server/routers/homeassistant.py
Normal file
47
server/routers/homeassistant.py
Normal 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
80
server/routers/news.py
Normal 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
64
server/routers/servers.py
Normal 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
47
server/routers/tasks.py
Normal 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
85
server/routers/weather.py
Normal 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 []
|
||||
0
server/services/__init__.py
Normal file
0
server/services/__init__.py
Normal file
149
server/services/ha_service.py
Normal file
149
server/services/ha_service.py
Normal 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
|
||||
153
server/services/news_service.py
Normal file
153
server/services/news_service.py
Normal 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]
|
||||
233
server/services/unraid_service.py
Normal file
233
server/services/unraid_service.py
Normal 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))
|
||||
215
server/services/vikunja_service.py
Normal file
215
server/services/vikunja_service.py
Normal 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
|
||||
234
server/services/weather_service.py
Normal file
234
server/services/weather_service.py
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
WEATHER_ICONS: Dict[int, str] = {
|
||||
113: "\u2600\ufe0f", # Clear/Sunny
|
||||
116: "\u26c5", # Partly Cloudy
|
||||
119: "\u2601\ufe0f", # Cloudy
|
||||
122: "\u2601\ufe0f", # Overcast
|
||||
143: "\ud83c\udf2b\ufe0f", # Mist
|
||||
176: "\ud83c\udf26\ufe0f", # Patchy rain nearby
|
||||
179: "\ud83c\udf28\ufe0f", # Patchy snow nearby
|
||||
182: "\ud83c\udf28\ufe0f", # Patchy sleet nearby
|
||||
185: "\ud83c\udf28\ufe0f", # Patchy freezing drizzle nearby
|
||||
200: "\u26c8\ufe0f", # Thundery outbreaks nearby
|
||||
227: "\ud83c\udf28\ufe0f", # Blowing snow
|
||||
230: "\u2744\ufe0f", # Blizzard
|
||||
248: "\ud83c\udf2b\ufe0f", # Fog
|
||||
260: "\ud83c\udf2b\ufe0f", # Freezing fog
|
||||
263: "\ud83c\udf26\ufe0f", # Patchy light drizzle
|
||||
266: "\ud83c\udf27\ufe0f", # Light drizzle
|
||||
281: "\ud83c\udf28\ufe0f", # Freezing drizzle
|
||||
284: "\ud83c\udf28\ufe0f", # Heavy freezing drizzle
|
||||
293: "\ud83c\udf26\ufe0f", # Patchy light rain
|
||||
296: "\ud83c\udf27\ufe0f", # Light rain
|
||||
299: "\ud83c\udf27\ufe0f", # Moderate rain at times
|
||||
302: "\ud83c\udf27\ufe0f", # Moderate rain
|
||||
305: "\ud83c\udf27\ufe0f", # Heavy rain at times
|
||||
308: "\ud83c\udf27\ufe0f", # Heavy rain
|
||||
311: "\ud83c\udf28\ufe0f", # Light freezing rain
|
||||
314: "\ud83c\udf28\ufe0f", # Moderate or heavy freezing rain
|
||||
317: "\ud83c\udf28\ufe0f", # Light sleet
|
||||
320: "\ud83c\udf28\ufe0f", # Moderate or heavy sleet
|
||||
323: "\ud83c\udf28\ufe0f", # Patchy light snow
|
||||
326: "\u2744\ufe0f", # Light snow
|
||||
329: "\u2744\ufe0f", # Patchy moderate snow
|
||||
332: "\u2744\ufe0f", # Moderate snow
|
||||
335: "\u2744\ufe0f", # Patchy heavy snow
|
||||
338: "\u2744\ufe0f", # Heavy snow
|
||||
350: "\ud83c\udf28\ufe0f", # Ice pellets
|
||||
353: "\ud83c\udf26\ufe0f", # Light rain shower
|
||||
356: "\ud83c\udf27\ufe0f", # Moderate or heavy rain shower
|
||||
359: "\ud83c\udf27\ufe0f", # Torrential rain shower
|
||||
362: "\ud83c\udf28\ufe0f", # Light sleet showers
|
||||
365: "\ud83c\udf28\ufe0f", # Moderate or heavy sleet showers
|
||||
368: "\u2744\ufe0f", # Light snow showers
|
||||
371: "\u2744\ufe0f", # Moderate or heavy snow showers
|
||||
374: "\ud83c\udf28\ufe0f", # Light showers of ice pellets
|
||||
377: "\ud83c\udf28\ufe0f", # Moderate or heavy showers of ice pellets
|
||||
386: "\u26c8\ufe0f", # Patchy light rain with thunder
|
||||
389: "\u26c8\ufe0f", # Moderate or heavy rain with thunder
|
||||
392: "\u26c8\ufe0f", # Patchy light snow with thunder
|
||||
395: "\u26c8\ufe0f", # Moderate or heavy snow with thunder
|
||||
}
|
||||
|
||||
|
||||
def _get_weather_icon(code: int) -> str:
|
||||
"""Map a WWO weather code to an emoji icon."""
|
||||
return WEATHER_ICONS.get(code, "\ud83c\udf24\ufe0f")
|
||||
|
||||
|
||||
def _parse_current_condition(condition: Dict[str, Any], location: str) -> Dict[str, Any]:
|
||||
"""Parse a single current_condition entry from the wttr.in JSON."""
|
||||
weather_code = int(condition.get("weatherCode", 113))
|
||||
descriptions = condition.get("weatherDesc", [])
|
||||
description = descriptions[0].get("value", "Unknown") if descriptions else "Unknown"
|
||||
|
||||
return {
|
||||
"location": location,
|
||||
"temp": int(condition.get("temp_C", 0)),
|
||||
"feels_like": int(condition.get("FeelsLikeC", 0)),
|
||||
"humidity": int(condition.get("humidity", 0)),
|
||||
"wind_kmh": int(condition.get("windspeedKmph", 0)),
|
||||
"description": description,
|
||||
"icon": _get_weather_icon(weather_code),
|
||||
}
|
||||
|
||||
|
||||
def _parse_forecast_day(day: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Parse a single forecast day from the wttr.in weather array."""
|
||||
date = day.get("date", "")
|
||||
max_temp = int(day.get("maxtempC", 0))
|
||||
min_temp = int(day.get("mintempC", 0))
|
||||
|
||||
astronomy = day.get("astronomy", [])
|
||||
sunrise = astronomy[0].get("sunrise", "") if astronomy else ""
|
||||
sunset = astronomy[0].get("sunset", "") if astronomy else ""
|
||||
|
||||
hourly = day.get("hourly", [])
|
||||
if hourly:
|
||||
midday = hourly[len(hourly) // 2]
|
||||
weather_code = int(midday.get("weatherCode", 113))
|
||||
descs = midday.get("weatherDesc", [])
|
||||
description = descs[0].get("value", "Unknown") if descs else "Unknown"
|
||||
else:
|
||||
weather_code = 113
|
||||
description = "Unknown"
|
||||
|
||||
return {
|
||||
"date": date,
|
||||
"max_temp": max_temp,
|
||||
"min_temp": min_temp,
|
||||
"icon": _get_weather_icon(weather_code),
|
||||
"description": description,
|
||||
"sunrise": sunrise,
|
||||
"sunset": sunset,
|
||||
}
|
||||
|
||||
|
||||
async def fetch_weather(location: str) -> Dict[str, Any]:
|
||||
"""Fetch current weather and 3-day forecast from wttr.in.
|
||||
|
||||
Args:
|
||||
location: City name or coordinates (e.g. "Berlin" or "52.52,13.405").
|
||||
|
||||
Returns:
|
||||
Dictionary with current conditions and 3-day forecast.
|
||||
"""
|
||||
fallback: Dict[str, Any] = {
|
||||
"location": location,
|
||||
"temp": 0,
|
||||
"feels_like": 0,
|
||||
"humidity": 0,
|
||||
"wind_kmh": 0,
|
||||
"description": "Unavailable",
|
||||
"icon": "\u2753",
|
||||
"forecast_3day": [],
|
||||
"error": None,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"https://wttr.in/{location}",
|
||||
params={"format": "j1"},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
fallback["error"] = f"HTTP {exc.response.status_code}"
|
||||
return fallback
|
||||
except httpx.RequestError as exc:
|
||||
fallback["error"] = f"Request failed: {exc}"
|
||||
return fallback
|
||||
except Exception as exc:
|
||||
fallback["error"] = str(exc)
|
||||
return fallback
|
||||
|
||||
current_conditions = data.get("current_condition", [])
|
||||
if not current_conditions:
|
||||
fallback["error"] = "No current condition data"
|
||||
return fallback
|
||||
|
||||
result = _parse_current_condition(current_conditions[0], location)
|
||||
|
||||
weather_days = data.get("weather", [])
|
||||
forecast_3day: List[Dict[str, Any]] = []
|
||||
for day in weather_days[:3]:
|
||||
forecast_3day.append(_parse_forecast_day(day))
|
||||
|
||||
result["forecast_3day"] = forecast_3day
|
||||
result["error"] = None
|
||||
return result
|
||||
|
||||
|
||||
async def fetch_hourly_forecast(location: str) -> List[Dict[str, Any]]:
|
||||
"""Fetch hourly forecast for the current day from wttr.in.
|
||||
|
||||
Returns the next 8 hourly slots from the current day's forecast.
|
||||
|
||||
Args:
|
||||
location: City name or coordinates.
|
||||
|
||||
Returns:
|
||||
List of hourly forecast dicts with time, temp, icon, and precip_chance.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"https://wttr.in/{location}",
|
||||
params={"format": "j1"},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
weather_days = data.get("weather", [])
|
||||
if not weather_days:
|
||||
return []
|
||||
|
||||
all_hourly: List[Dict[str, Any]] = []
|
||||
for day in weather_days[:2]:
|
||||
hourly_entries = day.get("hourly", [])
|
||||
for entry in hourly_entries:
|
||||
time_raw = entry.get("time", "0")
|
||||
time_value = int(time_raw)
|
||||
hours = time_value // 100
|
||||
minutes = time_value % 100
|
||||
time_str = f"{hours:02d}:{minutes:02d}"
|
||||
|
||||
weather_code = int(entry.get("weatherCode", 113))
|
||||
descs = entry.get("weatherDesc", [])
|
||||
description = descs[0].get("value", "Unknown") if descs else "Unknown"
|
||||
|
||||
all_hourly.append({
|
||||
"time": time_str,
|
||||
"temp": int(entry.get("tempC", 0)),
|
||||
"icon": _get_weather_icon(weather_code),
|
||||
"description": description,
|
||||
"precip_chance": int(entry.get("chanceofrain", 0)),
|
||||
"wind_kmh": int(entry.get("windspeedKmph", 0)),
|
||||
})
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
now_hour = datetime.now().hour
|
||||
upcoming: List[Dict[str, Any]] = []
|
||||
found_start = False
|
||||
for slot in all_hourly:
|
||||
slot_hour = int(slot["time"].split(":")[0])
|
||||
if not found_start:
|
||||
if slot_hour >= now_hour:
|
||||
found_start = True
|
||||
else:
|
||||
continue
|
||||
upcoming.append(slot)
|
||||
if len(upcoming) >= 8:
|
||||
break
|
||||
|
||||
return upcoming
|
||||
|
|
@ -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]
|
||||
803
src/main.py
803
src/main.py
|
|
@ -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)
|
||||
|
|
@ -1 +0,0 @@
|
|||
|
||||
|
|
@ -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
15
web/index.html
Normal 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
2785
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
web/package.json
Normal file
26
web/package.json
Normal 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
6
web/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
171
web/src/App.tsx
Normal file
171
web/src/App.tsx
Normal 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
120
web/src/api.ts
Normal 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");
|
||||
46
web/src/components/Clock.tsx
Normal file
46
web/src/components/Clock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
web/src/components/HomeAssistant.tsx
Normal file
191
web/src/components/HomeAssistant.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
web/src/components/HourlyForecast.tsx
Normal file
93
web/src/components/HourlyForecast.tsx
Normal 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)}°
|
||||
</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;
|
||||
}
|
||||
}
|
||||
144
web/src/components/NewsGrid.tsx
Normal file
144
web/src/components/NewsGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
web/src/components/ServerCard.tsx
Normal file
241
web/src/components/ServerCard.tsx
Normal 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)}°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>
|
||||
);
|
||||
}
|
||||
183
web/src/components/TasksCard.tsx
Normal file
183
web/src/components/TasksCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
web/src/components/WeatherCard.tsx
Normal file
115
web/src/components/WeatherCard.tsx
Normal 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">°</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>
|
||||
);
|
||||
}
|
||||
57
web/src/hooks/useDashboard.ts
Normal file
57
web/src/hooks/useDashboard.ts
Normal 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 };
|
||||
}
|
||||
79
web/src/hooks/useWebSocket.ts
Normal file
79
web/src/hooks/useWebSocket.ts
Normal 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
10
web/src/main.tsx
Normal 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
175
web/src/mockData.ts
Normal 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
126
web/src/styles/globals.css
Normal 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
1
web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
34
web/tailwind.config.js
Normal file
34
web/tailwind.config.js
Normal 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
21
web/tsconfig.json
Normal 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
23
web/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue