feat: add Admin Panel with JWT auth, DB settings, and integration management

Complete admin backend with login, where all integrations (weather, news,
Home Assistant, Vikunja, Unraid, MQTT) can be configured via web UI instead
of ENV variables. Two-layer config: ENV seeds DB on first start, then DB
is source of truth. Auto-migration system on startup.

Backend: db.py shared pool, auth.py JWT, settings_service CRUD, seed_service,
admin router (protected), test_connections per integration, config.py rewrite.

Frontend: react-router v6, login page, admin layout with sidebar, 8 settings
pages (General, Weather, News, HA, Vikunja, Unraid, MQTT, ChangePassword),
shared IntegrationForm + TestButton components.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sam 2026-03-02 10:37:30 +01:00
parent 89ed0c6d0a
commit f6a42c2dd2
40 changed files with 3487 additions and 311 deletions

View file

@ -5,12 +5,18 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
environment: environment:
# Database (PostgreSQL) # ── Required: Database (PostgreSQL) ──
- DB_HOST=10.10.10.10 - DB_HOST=10.10.10.10
- DB_PORT=5433 - DB_PORT=5433
- DB_NAME=openclaw - DB_NAME=openclaw
- DB_USER=sam - DB_USER=sam
- DB_PASSWORD=sam - DB_PASSWORD=sam
# ── Required: Admin Panel ──
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin}
- JWT_SECRET=${JWT_SECRET:-}
# ── Seed Values (used on first start only, then DB takes over) ──
# Weather # Weather
- WEATHER_LOCATION=Leverkusen - WEATHER_LOCATION=Leverkusen
- WEATHER_LOCATION_SECONDARY=Rab,Croatia - WEATHER_LOCATION_SECONDARY=Rab,Croatia

View file

@ -5,3 +5,5 @@ asyncpg==0.30.0
jinja2==3.1.5 jinja2==3.1.5
websockets==14.2 websockets==14.2
aiomqtt==2.3.0 aiomqtt==2.3.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4

72
server/auth.py Normal file
View file

@ -0,0 +1,72 @@
"""JWT authentication for admin routes."""
from __future__ import annotations
import logging
import os
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
logger = logging.getLogger(__name__)
JWT_SECRET = os.getenv("JWT_SECRET") or secrets.token_urlsafe(32)
JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = 24
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
bearer_scheme = HTTPBearer(auto_error=False)
def hash_password(password: str) -> str:
"""Hash a plain-text password with bcrypt."""
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
"""Verify a plain-text password against its bcrypt hash."""
return pwd_context.verify(plain, hashed)
def create_access_token(subject: str) -> str:
"""Create a JWT access token for the given subject (username)."""
expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
return jwt.encode(
{"sub": subject, "exp": expire},
JWT_SECRET,
algorithm=JWT_ALGORITHM,
)
async def require_admin(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
) -> str:
"""FastAPI dependency that validates the JWT and returns the username.
Use as: ``admin_user: str = Depends(require_admin)``
"""
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
token = credentials.credentials
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
username: Optional[str] = payload.get("sub")
if username is None:
raise HTTPException(status_code=401, detail="Invalid token payload")
return username
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)

View file

@ -1,11 +1,19 @@
"""Centralized configuration via environment variables.""" """Centralized configuration — two-layer system (ENV bootstrap + DB runtime).
On first start, ENV values seed the database.
After that, the database is the source of truth for all integration configs.
ENV is only needed for: DB connection, ADMIN_PASSWORD, JWT_SECRET.
"""
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import os import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import List from typing import Any, Dict, List
logger = logging.getLogger(__name__)
@dataclass @dataclass
@ -18,7 +26,7 @@ class UnraidServer:
@dataclass @dataclass
class Settings: class Settings:
# --- Database (PostgreSQL) --- # --- Bootstrap (always from ENV) ---
db_host: str = "10.10.10.10" db_host: str = "10.10.10.10"
db_port: int = 5433 db_port: int = 5433
db_name: str = "openclaw" db_name: str = "openclaw"
@ -28,25 +36,31 @@ class Settings:
# --- Weather --- # --- Weather ---
weather_location: str = "Leverkusen" weather_location: str = "Leverkusen"
weather_location_secondary: str = "Rab,Croatia" weather_location_secondary: str = "Rab,Croatia"
weather_cache_ttl: int = 1800 # 30 min weather_cache_ttl: int = 1800
# --- Home Assistant --- # --- Home Assistant ---
ha_url: str = "https://homeassistant.daddelolymp.de" ha_url: str = ""
ha_token: str = "" ha_token: str = ""
ha_cache_ttl: int = 30 ha_cache_ttl: int = 30
ha_enabled: bool = False
# --- Vikunja Tasks --- # --- Vikunja Tasks ---
vikunja_url: str = "http://10.10.10.10:3456/api/v1" vikunja_url: str = ""
vikunja_token: str = "" vikunja_token: str = ""
vikunja_cache_ttl: int = 60 vikunja_cache_ttl: int = 60
vikunja_enabled: bool = False
vikunja_private_projects: List[int] = field(default_factory=lambda: [3, 4])
vikunja_sams_projects: List[int] = field(default_factory=lambda: [2, 5])
# --- Unraid Servers --- # --- Unraid Servers ---
unraid_servers: List[UnraidServer] = field(default_factory=list) unraid_servers: List[UnraidServer] = field(default_factory=list)
unraid_cache_ttl: int = 15 unraid_cache_ttl: int = 15
unraid_enabled: bool = False
# --- News --- # --- News ---
news_cache_ttl: int = 300 # 5 min news_cache_ttl: int = 300
news_max_age_hours: int = 48 news_max_age_hours: int = 48
news_enabled: bool = True
# --- MQTT --- # --- MQTT ---
mqtt_host: str = "" mqtt_host: str = ""
@ -55,39 +69,44 @@ class Settings:
mqtt_password: str = "" mqtt_password: str = ""
mqtt_topics: List[str] = field(default_factory=lambda: ["#"]) mqtt_topics: List[str] = field(default_factory=lambda: ["#"])
mqtt_client_id: str = "daily-briefing" mqtt_client_id: str = "daily-briefing"
mqtt_enabled: bool = False
# --- Server --- # --- Server ---
host: str = "0.0.0.0" host: str = "0.0.0.0"
port: int = 8080 port: int = 8080
debug: bool = False debug: bool = False
# --- WebSocket ---
ws_interval: int = 15
@classmethod @classmethod
def from_env(cls) -> "Settings": def from_env(cls) -> "Settings":
"""Load bootstrap config from environment variables."""
s = cls() s = cls()
s.db_host = os.getenv("DB_HOST", s.db_host) s.db_host = os.getenv("DB_HOST", s.db_host)
s.db_port = int(os.getenv("DB_PORT", str(s.db_port))) s.db_port = int(os.getenv("DB_PORT", str(s.db_port)))
s.db_name = os.getenv("DB_NAME", s.db_name) s.db_name = os.getenv("DB_NAME", s.db_name)
s.db_user = os.getenv("DB_USER", s.db_user) s.db_user = os.getenv("DB_USER", s.db_user)
s.db_password = os.getenv("DB_PASSWORD", s.db_password) s.db_password = os.getenv("DB_PASSWORD", s.db_password)
s.debug = os.getenv("DEBUG", "").lower() in ("1", "true", "yes")
# Legacy ENV support — used for first-run seeding
s.weather_location = os.getenv("WEATHER_LOCATION", s.weather_location) s.weather_location = os.getenv("WEATHER_LOCATION", s.weather_location)
s.weather_location_secondary = os.getenv( s.weather_location_secondary = os.getenv("WEATHER_LOCATION_SECONDARY", s.weather_location_secondary)
"WEATHER_LOCATION_SECONDARY", s.weather_location_secondary
)
s.ha_url = os.getenv("HA_URL", s.ha_url) s.ha_url = os.getenv("HA_URL", s.ha_url)
s.ha_token = os.getenv("HA_TOKEN", s.ha_token) s.ha_token = os.getenv("HA_TOKEN", s.ha_token)
s.ha_enabled = bool(s.ha_url)
s.vikunja_url = os.getenv("VIKUNJA_URL", s.vikunja_url) s.vikunja_url = os.getenv("VIKUNJA_URL", s.vikunja_url)
s.vikunja_token = os.getenv("VIKUNJA_TOKEN", s.vikunja_token) s.vikunja_token = os.getenv("VIKUNJA_TOKEN", s.vikunja_token)
s.vikunja_enabled = bool(s.vikunja_url)
s.mqtt_host = os.getenv("MQTT_HOST", s.mqtt_host) s.mqtt_host = os.getenv("MQTT_HOST", s.mqtt_host)
s.mqtt_port = int(os.getenv("MQTT_PORT", str(s.mqtt_port))) s.mqtt_port = int(os.getenv("MQTT_PORT", str(s.mqtt_port)))
s.mqtt_username = os.getenv("MQTT_USERNAME", s.mqtt_username) s.mqtt_username = os.getenv("MQTT_USERNAME", s.mqtt_username)
s.mqtt_password = os.getenv("MQTT_PASSWORD", s.mqtt_password) s.mqtt_password = os.getenv("MQTT_PASSWORD", s.mqtt_password)
s.mqtt_client_id = os.getenv("MQTT_CLIENT_ID", s.mqtt_client_id) s.mqtt_client_id = os.getenv("MQTT_CLIENT_ID", s.mqtt_client_id)
s.mqtt_enabled = bool(s.mqtt_host)
# Parse MQTT_TOPICS (comma-separated or JSON array) # Parse MQTT_TOPICS
raw_topics = os.getenv("MQTT_TOPICS", "") raw_topics = os.getenv("MQTT_TOPICS", "")
if raw_topics: if raw_topics:
try: try:
@ -95,8 +114,6 @@ class Settings:
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
s.mqtt_topics = [t.strip() for t in raw_topics.split(",") if t.strip()] s.mqtt_topics = [t.strip() for t in raw_topics.split(",") if t.strip()]
s.debug = os.getenv("DEBUG", "").lower() in ("1", "true", "yes")
# Parse UNRAID_SERVERS JSON # Parse UNRAID_SERVERS JSON
raw = os.getenv("UNRAID_SERVERS", "[]") raw = os.getenv("UNRAID_SERVERS", "[]")
try: try:
@ -111,10 +128,103 @@ class Settings:
for i, srv in enumerate(servers_data) for i, srv in enumerate(servers_data)
if srv.get("host") if srv.get("host")
] ]
s.unraid_enabled = len(s.unraid_servers) > 0
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
s.unraid_servers = [] s.unraid_servers = []
return s return s
async def load_from_db(self) -> None:
"""Override fields with values from the database."""
try:
from server.services import settings_service
# Load integrations
integrations = await settings_service.get_integrations()
for integ in integrations:
cfg = integ.get("config", {})
enabled = integ.get("enabled", True)
itype = integ["type"]
if itype == "weather":
self.weather_location = cfg.get("location", self.weather_location)
self.weather_location_secondary = cfg.get("location_secondary", self.weather_location_secondary)
elif itype == "news":
self.news_max_age_hours = int(cfg.get("max_age_hours", self.news_max_age_hours))
self.news_enabled = enabled
elif itype == "ha":
self.ha_url = cfg.get("url", self.ha_url)
self.ha_token = cfg.get("token", self.ha_token)
self.ha_enabled = enabled
elif itype == "vikunja":
self.vikunja_url = cfg.get("url", self.vikunja_url)
self.vikunja_token = cfg.get("token", self.vikunja_token)
self.vikunja_private_projects = cfg.get("private_projects", self.vikunja_private_projects)
self.vikunja_sams_projects = cfg.get("sams_projects", self.vikunja_sams_projects)
self.vikunja_enabled = enabled
elif itype == "unraid":
servers = cfg.get("servers", [])
self.unraid_servers = [
UnraidServer(
name=s.get("name", ""),
host=s.get("host", ""),
api_key=s.get("api_key", ""),
port=int(s.get("port", 80)),
)
for s in servers
if s.get("host")
]
self.unraid_enabled = enabled
elif itype == "mqtt":
self.mqtt_host = cfg.get("host", self.mqtt_host)
self.mqtt_port = int(cfg.get("port", self.mqtt_port))
self.mqtt_username = cfg.get("username", self.mqtt_username)
self.mqtt_password = cfg.get("password", self.mqtt_password)
self.mqtt_client_id = cfg.get("client_id", self.mqtt_client_id)
self.mqtt_topics = cfg.get("topics", self.mqtt_topics)
self.mqtt_enabled = enabled
# Load app_settings (cache TTLs, etc.)
all_settings = await settings_service.get_all_settings()
for key, data in all_settings.items():
val = data["value"]
if key == "weather_cache_ttl":
self.weather_cache_ttl = int(val)
elif key == "ha_cache_ttl":
self.ha_cache_ttl = int(val)
elif key == "vikunja_cache_ttl":
self.vikunja_cache_ttl = int(val)
elif key == "unraid_cache_ttl":
self.unraid_cache_ttl = int(val)
elif key == "news_cache_ttl":
self.news_cache_ttl = int(val)
elif key == "ws_interval":
self.ws_interval = int(val)
logger.info("Settings loaded from database (%d integrations)", len(integrations))
except Exception:
logger.exception("Failed to load settings from DB — using ENV defaults")
# --- Module-level singleton ---
settings = Settings.from_env() settings = Settings.from_env()
def get_settings() -> Settings:
"""Return the current settings. Used by all routers."""
return settings
async def reload_settings() -> None:
"""Reload settings from DB. Called after admin changes."""
global settings
s = Settings.from_env()
await s.load_from_db()
settings = s
logger.info("Settings reloaded from database")

52
server/db.py Normal file
View file

@ -0,0 +1,52 @@
"""Shared asyncpg connection pool."""
from __future__ import annotations
import logging
from typing import Optional
import asyncpg
logger = logging.getLogger(__name__)
_pool: Optional[asyncpg.Pool] = None
async def init_pool(
host: str,
port: int,
dbname: str,
user: str,
password: str,
min_size: int = 1,
max_size: int = 5,
) -> asyncpg.Pool:
"""Create the shared connection pool. Call once during app startup."""
global _pool
_pool = await asyncpg.create_pool(
host=host,
port=port,
database=dbname,
user=user,
password=password,
min_size=min_size,
max_size=max_size,
)
logger.info("Database pool initialized (%s:%d/%s)", host, port, dbname)
return _pool
async def get_pool() -> asyncpg.Pool:
"""Return the shared pool. Raises if not yet initialized."""
if _pool is None:
raise RuntimeError("Database pool not initialized — call init_pool() first")
return _pool
async def close_pool() -> None:
"""Close the shared pool. Call during app shutdown."""
global _pool
if _pool is not None:
await _pool.close()
_pool = None
logger.info("Database pool closed")

View file

@ -8,10 +8,10 @@ from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from server.config import settings from server.config import get_settings, reload_settings, settings
from server.services import news_service
from server.services.mqtt_service import mqtt_service from server.services.mqtt_service import mqtt_service
logger = logging.getLogger("daily-briefing") logger = logging.getLogger("daily-briefing")
@ -24,15 +24,15 @@ logging.basicConfig(
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Startup / shutdown lifecycle.""" """Startup / shutdown lifecycle."""
logger.info("Starting Daily Briefing Dashboard...") from server import db
logger.info( from server.migrations.runner import run_migrations
"Unraid servers configured: %d", from server.services.seed_service import seed_if_empty
len(settings.unraid_servers),
)
# Initialize database pool logger.info("Starting Daily Briefing Dashboard v2.1...")
# 1. Initialize shared database pool (bootstrap from ENV)
try: try:
await news_service.init_pool( pool = await db.init_pool(
host=settings.db_host, host=settings.db_host,
port=settings.db_port, port=settings.db_port,
dbname=settings.db_name, dbname=settings.db_name,
@ -41,36 +41,64 @@ async def lifespan(app: FastAPI):
) )
logger.info("Database pool initialized") logger.info("Database pool initialized")
except Exception: except Exception:
logger.exception("Failed to initialize database pool — news will be unavailable") logger.exception("Failed to initialize database pool — admin + news will be unavailable")
yield
return
# Start MQTT service # 2. Run database migrations
if settings.mqtt_host: try:
await run_migrations(pool)
except Exception:
logger.exception("Migration error — some features may not work")
# 3. Seed database from ENV on first run
try:
await seed_if_empty()
except Exception:
logger.exception("Seeding error — admin panel may need manual setup")
# 4. Load settings from database (overrides ENV defaults)
try:
await reload_settings()
cfg = get_settings()
logger.info(
"Settings loaded from DB — %d Unraid servers, MQTT=%s, HA=%s",
len(cfg.unraid_servers),
"enabled" if cfg.mqtt_enabled else "disabled",
"enabled" if cfg.ha_enabled else "disabled",
)
except Exception:
logger.exception("Failed to load settings from DB — using ENV defaults")
cfg = settings
# 5. Start MQTT service if enabled
if cfg.mqtt_enabled and cfg.mqtt_host:
try: try:
await mqtt_service.start( await mqtt_service.start(
host=settings.mqtt_host, host=cfg.mqtt_host,
port=settings.mqtt_port, port=cfg.mqtt_port,
username=settings.mqtt_username or None, username=cfg.mqtt_username or None,
password=settings.mqtt_password or None, password=cfg.mqtt_password or None,
topics=settings.mqtt_topics, topics=cfg.mqtt_topics,
client_id=settings.mqtt_client_id, client_id=cfg.mqtt_client_id,
) )
logger.info("MQTT service started (broker %s:%d)", settings.mqtt_host, settings.mqtt_port) logger.info("MQTT service started (%s:%d)", cfg.mqtt_host, cfg.mqtt_port)
except Exception: except Exception:
logger.exception("Failed to start MQTT service — MQTT will be unavailable") logger.exception("Failed to start MQTT service")
else: else:
logger.info("MQTT disabled — set MQTT_HOST to enable") logger.info("MQTT disabled — configure via Admin Panel or MQTT_HOST env")
yield yield
# Shutdown # Shutdown
logger.info("Shutting down...") logger.info("Shutting down...")
await mqtt_service.stop() await mqtt_service.stop()
await news_service.close_pool() await db.close_pool()
app = FastAPI( app = FastAPI(
title="Daily Briefing", title="Daily Briefing",
version="2.0.0", version="2.1.0",
lifespan=lifespan, lifespan=lifespan,
) )
@ -84,8 +112,10 @@ app.add_middleware(
) )
# --- Register Routers --- # --- Register Routers ---
from server.routers import dashboard, homeassistant, mqtt, news, servers, tasks, weather # noqa: E402 from server.routers import admin, auth, dashboard, homeassistant, mqtt, news, servers, tasks, weather # noqa: E402
app.include_router(auth.router)
app.include_router(admin.router)
app.include_router(weather.router) app.include_router(weather.router)
app.include_router(news.router) app.include_router(news.router)
app.include_router(servers.router) app.include_router(servers.router)
@ -97,6 +127,14 @@ app.include_router(dashboard.router)
# --- Serve static frontend (production) --- # --- Serve static frontend (production) ---
static_dir = Path(__file__).parent.parent / "static" static_dir = Path(__file__).parent.parent / "static"
if static_dir.is_dir(): if static_dir.is_dir():
# SPA fallback: serve index.html for any non-API path
@app.get("/admin/{full_path:path}")
async def admin_spa_fallback(full_path: str):
index = static_dir / "index.html"
if index.exists():
return FileResponse(str(index))
return {"error": "Frontend not built"}
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static") app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
logger.info("Serving static frontend from %s", static_dir) logger.info("Serving static frontend from %s", static_dir)
else: else:
@ -104,6 +142,10 @@ else:
async def root(): async def root():
return { return {
"status": "ok", "status": "ok",
"message": "Daily Briefing API — Frontend not built yet", "message": "Daily Briefing API v2.1 — Frontend not built yet",
"endpoints": ["/api/all", "/api/weather", "/api/news", "/api/servers", "/api/ha", "/api/tasks"], "endpoints": [
"/api/all", "/api/weather", "/api/news", "/api/servers",
"/api/ha", "/api/tasks", "/api/mqtt",
"/api/auth/login", "/api/admin/integrations",
],
} }

View file

@ -0,0 +1,53 @@
-- Migration 001: Admin Backend Schema
-- Creates tables for admin user, settings, integrations, and MQTT subscriptions.
-- Single admin user
CREATE TABLE IF NOT EXISTS admin_user (
id SERIAL PRIMARY KEY,
username VARCHAR(100) NOT NULL DEFAULT 'admin',
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- General key/value settings (cache TTLs, preferences, etc.)
CREATE TABLE IF NOT EXISTS app_settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
value_type VARCHAR(20) NOT NULL DEFAULT 'string',
category VARCHAR(50) NOT NULL DEFAULT 'general',
label VARCHAR(200) NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Integration configurations (one row per integration type)
CREATE TABLE IF NOT EXISTS integrations (
id SERIAL PRIMARY KEY,
type VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
config JSONB NOT NULL DEFAULT '{}',
enabled BOOLEAN NOT NULL DEFAULT true,
display_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- MQTT subscription management
CREATE TABLE IF NOT EXISTS mqtt_subscriptions (
id SERIAL PRIMARY KEY,
topic_pattern VARCHAR(500) NOT NULL,
display_name VARCHAR(200) NOT NULL DEFAULT '',
category VARCHAR(100) NOT NULL DEFAULT 'other',
unit VARCHAR(50) NOT NULL DEFAULT '',
widget_type VARCHAR(50) NOT NULL DEFAULT 'value',
enabled BOOLEAN NOT NULL DEFAULT true,
display_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Record this migration
INSERT INTO schema_version (version, description)
VALUES (1, 'Admin backend: admin_user, app_settings, integrations, mqtt_subscriptions')
ON CONFLICT (version) DO NOTHING;

View file

View file

@ -0,0 +1,58 @@
"""Auto-migration runner. Applies pending SQL migrations on startup."""
from __future__ import annotations
import logging
from pathlib import Path
import asyncpg
logger = logging.getLogger(__name__)
MIGRATIONS_DIR = Path(__file__).parent
async def run_migrations(pool: asyncpg.Pool) -> None:
"""Check schema_version and apply any pending .sql migration files."""
async with pool.acquire() as conn:
# Ensure the version-tracking table exists
await conn.execute("""
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
description TEXT NOT NULL DEFAULT ''
)
""")
row = await conn.fetchrow(
"SELECT COALESCE(MAX(version), 0) AS v FROM schema_version"
)
current_version: int = row["v"]
logger.info("Current schema version: %d", current_version)
# Discover and sort SQL files by their numeric prefix
sql_files = sorted(
MIGRATIONS_DIR.glob("[0-9]*.sql"),
key=lambda p: int(p.stem.split("_")[0]),
)
applied = 0
for sql_file in sql_files:
version = int(sql_file.stem.split("_")[0])
if version <= current_version:
continue
logger.info("Applying migration %03d: %s", version, sql_file.name)
sql = sql_file.read_text(encoding="utf-8")
# Execute the entire migration in a transaction
async with conn.transaction():
await conn.execute(sql)
logger.info("Migration %03d applied successfully", version)
applied += 1
if applied == 0:
logger.info("No pending migrations")
else:
logger.info("Applied %d migration(s)", applied)

186
server/routers/admin.py Normal file
View file

@ -0,0 +1,186 @@
"""Admin router — protected CRUD for settings, integrations, and MQTT subscriptions."""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from server.auth import require_admin
from server.services import settings_service
from server.services.test_connections import TEST_FUNCTIONS
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/admin",
tags=["admin"],
dependencies=[Depends(require_admin)],
)
# ---------------------------------------------------------------------------
# Request/Response Models
# ---------------------------------------------------------------------------
class SettingsUpdate(BaseModel):
settings: Dict[str, Any]
class IntegrationUpdate(BaseModel):
name: Optional[str] = None
config: Optional[Dict[str, Any]] = None
enabled: Optional[bool] = None
display_order: Optional[int] = None
class MqttSubscriptionCreate(BaseModel):
topic_pattern: str
display_name: str = ""
category: str = "other"
unit: str = ""
widget_type: str = "value"
enabled: bool = True
display_order: int = 0
class MqttSubscriptionUpdate(BaseModel):
topic_pattern: Optional[str] = None
display_name: Optional[str] = None
category: Optional[str] = None
unit: Optional[str] = None
widget_type: Optional[str] = None
enabled: Optional[bool] = None
display_order: Optional[int] = None
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
@router.get("/settings")
async def get_settings() -> Dict[str, Any]:
"""Return all app settings."""
return await settings_service.get_all_settings()
@router.put("/settings")
async def update_settings(body: SettingsUpdate) -> Dict[str, str]:
"""Bulk update settings."""
await settings_service.bulk_set_settings(body.settings)
# Reload in-memory settings
await _reload_app_settings()
return {"status": "ok", "message": f"Updated {len(body.settings)} setting(s)"}
# ---------------------------------------------------------------------------
# Integrations
# ---------------------------------------------------------------------------
@router.get("/integrations")
async def list_integrations() -> List[Dict[str, Any]]:
"""List all integration configs."""
return await settings_service.get_integrations()
@router.get("/integrations/{type_name}")
async def get_integration(type_name: str) -> Dict[str, Any]:
"""Get a single integration config."""
result = await settings_service.get_integration(type_name)
if result is None:
raise HTTPException(status_code=404, detail=f"Integration '{type_name}' not found")
return result
@router.put("/integrations/{type_name}")
async def update_integration(type_name: str, body: IntegrationUpdate) -> Dict[str, Any]:
"""Update an integration config."""
existing = await settings_service.get_integration(type_name)
if existing is None:
raise HTTPException(status_code=404, detail=f"Integration '{type_name}' not found")
result = await settings_service.upsert_integration(
type_name=type_name,
name=body.name or existing["name"],
config=body.config if body.config is not None else existing["config"],
enabled=body.enabled if body.enabled is not None else existing["enabled"],
display_order=body.display_order if body.display_order is not None else existing["display_order"],
)
# Reload in-memory settings
await _reload_app_settings()
return result
@router.post("/integrations/{type_name}/test")
async def test_integration(type_name: str) -> Dict[str, Any]:
"""Test an integration connection."""
integration = await settings_service.get_integration(type_name)
if integration is None:
raise HTTPException(status_code=404, detail=f"Integration '{type_name}' not found")
test_fn = TEST_FUNCTIONS.get(type_name)
if test_fn is None:
return {"success": False, "message": f"No test available for '{type_name}'"}
return await test_fn(integration["config"])
# ---------------------------------------------------------------------------
# MQTT Subscriptions
# ---------------------------------------------------------------------------
@router.get("/mqtt/subscriptions")
async def list_mqtt_subscriptions() -> List[Dict[str, Any]]:
"""List all MQTT subscriptions."""
return await settings_service.get_mqtt_subscriptions()
@router.post("/mqtt/subscriptions")
async def create_mqtt_subscription(body: MqttSubscriptionCreate) -> Dict[str, Any]:
"""Create a new MQTT subscription."""
return await settings_service.create_mqtt_subscription(
topic_pattern=body.topic_pattern,
display_name=body.display_name,
category=body.category,
unit=body.unit,
widget_type=body.widget_type,
enabled=body.enabled,
display_order=body.display_order,
)
@router.put("/mqtt/subscriptions/{sub_id}")
async def update_mqtt_subscription(sub_id: int, body: MqttSubscriptionUpdate) -> Dict[str, Any]:
"""Update an MQTT subscription."""
fields = body.model_dump(exclude_none=True)
if not fields:
raise HTTPException(status_code=400, detail="No fields to update")
result = await settings_service.update_mqtt_subscription(sub_id, **fields)
if result is None:
raise HTTPException(status_code=404, detail="Subscription not found")
return result
@router.delete("/mqtt/subscriptions/{sub_id}")
async def delete_mqtt_subscription(sub_id: int) -> Dict[str, str]:
"""Delete an MQTT subscription."""
deleted = await settings_service.delete_mqtt_subscription(sub_id)
if not deleted:
raise HTTPException(status_code=404, detail="Subscription not found")
return {"status": "ok", "message": "Subscription deleted"}
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
async def _reload_app_settings() -> None:
"""Reload the in-memory Settings object from the database."""
try:
from server.config import reload_settings
await reload_settings()
except Exception:
logger.exception("Failed to reload settings after admin change")

79
server/routers/auth.py Normal file
View file

@ -0,0 +1,79 @@
"""Auth router — login and password management."""
from __future__ import annotations
import logging
from typing import Any, Dict
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from server.auth import (
create_access_token,
hash_password,
require_admin,
verify_password,
)
from server.services import settings_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["auth"])
class LoginRequest(BaseModel):
username: str
password: str
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
@router.post("/login")
async def login(body: LoginRequest) -> Dict[str, Any]:
"""Authenticate admin and return a JWT."""
user = await settings_service.get_admin_user()
if user is None:
raise HTTPException(status_code=503, detail="No admin user configured")
if body.username != user["username"]:
raise HTTPException(status_code=401, detail="Invalid credentials")
if not verify_password(body.password, user["password_hash"]):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token(user["username"])
return {
"token": token,
"username": user["username"],
}
@router.get("/me")
async def get_me(admin_user: str = Depends(require_admin)) -> Dict[str, str]:
"""Return the authenticated admin username. Used to verify token validity."""
return {"username": admin_user}
@router.put("/password")
async def change_password(
body: ChangePasswordRequest,
admin_user: str = Depends(require_admin),
) -> Dict[str, str]:
"""Change the admin password."""
user = await settings_service.get_admin_user()
if user is None:
raise HTTPException(status_code=500, detail="Admin user not found")
if not verify_password(body.current_password, user["password_hash"]):
raise HTTPException(status_code=400, detail="Current password is incorrect")
if len(body.new_password) < 6:
raise HTTPException(status_code=400, detail="New password must be at least 6 characters")
await settings_service.update_admin_password(
user["id"], hash_password(body.new_password)
)
return {"status": "ok", "message": "Password changed successfully"}

View file

@ -8,7 +8,7 @@ from typing import Any, Dict
from fastapi import APIRouter from fastapi import APIRouter
from server.cache import cache from server.cache import cache
from server.config import settings from server.config import get_settings
from server.services.ha_service import fetch_ha_data from server.services.ha_service import fetch_ha_data
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -36,12 +36,12 @@ async def get_ha() -> Dict[str, Any]:
# --- cache miss ----------------------------------------------------------- # --- cache miss -----------------------------------------------------------
try: try:
data: Dict[str, Any] = await fetch_ha_data( data: Dict[str, Any] = await fetch_ha_data(
settings.ha_url, get_settings().ha_url,
settings.ha_token, get_settings().ha_token,
) )
except Exception as exc: except Exception as exc:
logger.exception("Failed to fetch Home Assistant data") logger.exception("Failed to fetch Home Assistant data")
return {"error": True, "message": str(exc)} return {"error": True, "message": str(exc)}
await cache.set(CACHE_KEY, data, settings.ha_cache_ttl) await cache.set(CACHE_KEY, data, get_settings().ha_cache_ttl)
return data return data

View file

@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Query from fastapi import APIRouter, Query
from server.cache import cache from server.cache import cache
from server.config import settings from server.config import get_settings
from server.services.news_service import get_news, get_news_count from server.services.news_service import get_news, get_news_count
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -50,7 +50,7 @@ async def get_news_articles(
total: int = 0 total: int = 0
try: try:
articles = await get_news(limit=limit, offset=offset, category=category, max_age_hours=settings.news_max_age_hours) articles = await get_news(limit=limit, offset=offset, category=category, max_age_hours=get_settings().news_max_age_hours)
except Exception as exc: except Exception as exc:
logger.exception("Failed to fetch news articles") logger.exception("Failed to fetch news articles")
return { return {
@ -63,7 +63,7 @@ async def get_news_articles(
} }
try: try:
total = await get_news_count(max_age_hours=settings.news_max_age_hours, category=category) total = await get_news_count(max_age_hours=get_settings().news_max_age_hours, category=category)
except Exception as exc: except Exception as exc:
logger.exception("Failed to fetch news count") logger.exception("Failed to fetch news count")
# We still have articles -- return them with total = len(articles) # We still have articles -- return them with total = len(articles)
@ -76,5 +76,5 @@ async def get_news_articles(
"offset": offset, "offset": offset,
} }
await cache.set(key, payload, settings.news_cache_ttl) await cache.set(key, payload, get_settings().news_cache_ttl)
return payload return payload

View file

@ -8,7 +8,7 @@ from typing import Any, Dict, List
from fastapi import APIRouter from fastapi import APIRouter
from server.cache import cache from server.cache import cache
from server.config import settings from server.config import get_settings
from server.services.unraid_service import ServerConfig, fetch_all_servers from server.services.unraid_service import ServerConfig, fetch_all_servers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -42,7 +42,7 @@ async def get_servers() -> Dict[str, Any]:
api_key=srv.api_key, api_key=srv.api_key,
port=srv.port, port=srv.port,
) )
for srv in settings.unraid_servers for srv in get_settings().unraid_servers
] ]
servers_data: List[Dict[str, Any]] = [] servers_data: List[Dict[str, Any]] = []
@ -60,5 +60,5 @@ async def get_servers() -> Dict[str, Any]:
"servers": servers_data, "servers": servers_data,
} }
await cache.set(CACHE_KEY, payload, settings.unraid_cache_ttl) await cache.set(CACHE_KEY, payload, get_settings().unraid_cache_ttl)
return payload return payload

View file

@ -8,7 +8,7 @@ from typing import Any, Dict
from fastapi import APIRouter from fastapi import APIRouter
from server.cache import cache from server.cache import cache
from server.config import settings from server.config import get_settings
from server.services.vikunja_service import fetch_tasks from server.services.vikunja_service import fetch_tasks
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -36,12 +36,12 @@ async def get_tasks() -> Dict[str, Any]:
# --- cache miss ----------------------------------------------------------- # --- cache miss -----------------------------------------------------------
try: try:
data: Dict[str, Any] = await fetch_tasks( data: Dict[str, Any] = await fetch_tasks(
settings.vikunja_url, get_settings().vikunja_url,
settings.vikunja_token, get_settings().vikunja_token,
) )
except Exception as exc: except Exception as exc:
logger.exception("Failed to fetch Vikunja tasks") logger.exception("Failed to fetch Vikunja tasks")
return {"error": True, "message": str(exc)} return {"error": True, "message": str(exc)}
await cache.set(CACHE_KEY, data, settings.vikunja_cache_ttl) await cache.set(CACHE_KEY, data, get_settings().vikunja_cache_ttl)
return data return data

View file

@ -9,7 +9,7 @@ from typing import Any, Dict, List
from fastapi import APIRouter from fastapi import APIRouter
from server.cache import cache from server.cache import cache
from server.config import settings from server.config import get_settings
from server.services.weather_service import fetch_hourly_forecast, fetch_weather from server.services.weather_service import fetch_hourly_forecast, fetch_weather
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -43,9 +43,9 @@ async def get_weather() -> Dict[str, Any]:
hourly_data: List[Dict[str, Any]] = [] hourly_data: List[Dict[str, Any]] = []
results = await asyncio.gather( results = await asyncio.gather(
_safe_fetch_weather(settings.weather_location), _safe_fetch_weather(get_settings().weather_location),
_safe_fetch_weather(settings.weather_location_secondary), _safe_fetch_weather(get_settings().weather_location_secondary),
_safe_fetch_hourly(settings.weather_location), _safe_fetch_hourly(get_settings().weather_location),
return_exceptions=False, # we handle errors inside the helpers return_exceptions=False, # we handle errors inside the helpers
) )
@ -59,7 +59,7 @@ async def get_weather() -> Dict[str, Any]:
"hourly": hourly_data, "hourly": hourly_data,
} }
await cache.set(CACHE_KEY, payload, settings.weather_cache_ttl) await cache.set(CACHE_KEY, payload, get_settings().weather_cache_ttl)
return payload return payload

View file

@ -1,43 +1,11 @@
"""News service — queries market_news from PostgreSQL via shared pool."""
from __future__ import annotations from __future__ import annotations
import asyncpg import asyncpg
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
_pool: Optional[asyncpg.Pool] = None from server.db import get_pool
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]: def _row_to_dict(row: asyncpg.Record) -> Dict[str, Any]:
@ -54,19 +22,8 @@ async def get_news(
category: Optional[str] = None, category: Optional[str] = None,
max_age_hours: int = 48, max_age_hours: int = 48,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Fetch recent news articles from the market_news table. """Fetch recent news articles from the market_news table."""
pool = await get_pool()
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] = [] params: List[Any] = []
param_idx = 1 param_idx = 1
@ -86,7 +43,7 @@ async def get_news(
params.append(limit) params.append(limit)
params.append(offset) params.append(offset)
async with _pool.acquire() as conn: async with pool.acquire() as conn:
rows = await conn.fetch(base_query, *params) rows = await conn.fetch(base_query, *params)
return [_row_to_dict(row) for row in rows] return [_row_to_dict(row) for row in rows]
@ -96,17 +53,8 @@ async def get_news_count(
max_age_hours: int = 48, max_age_hours: int = 48,
category: Optional[str] = None, category: Optional[str] = None,
) -> int: ) -> int:
"""Return the total count of recent news articles. """Return the total count of recent news articles."""
pool = await get_pool()
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] = [] params: List[Any] = []
param_idx = 1 param_idx = 1
@ -121,23 +69,15 @@ async def get_news_count(
query += f" AND category = ${param_idx}" query += f" AND category = ${param_idx}"
params.append(category) params.append(category)
async with _pool.acquire() as conn: async with pool.acquire() as conn:
row = await conn.fetchrow(query, *params) row = await conn.fetchrow(query, *params)
return int(row["cnt"]) if row else 0 return int(row["cnt"]) if row else 0
async def get_categories(max_age_hours: int = 48) -> List[str]: async def get_categories(max_age_hours: int = 48) -> List[str]:
"""Return distinct categories from recent news articles. """Return distinct categories from recent news articles."""
pool = await get_pool()
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 = ( query = (
"SELECT DISTINCT category " "SELECT DISTINCT category "
@ -147,7 +87,7 @@ async def get_categories(max_age_hours: int = 48) -> List[str]:
"ORDER BY category" "ORDER BY category"
) )
async with _pool.acquire() as conn: async with pool.acquire() as conn:
rows = await conn.fetch(query) rows = await conn.fetch(query)
return [row["category"] for row in rows] return [row["category"] for row in rows]

View file

@ -0,0 +1,150 @@
"""First-run seeder: populates DB from ENV defaults when tables are empty."""
from __future__ import annotations
import json
import logging
import os
import secrets
from server.auth import hash_password
from server.db import get_pool
from server.services import settings_service
logger = logging.getLogger(__name__)
async def seed_if_empty() -> None:
"""Check if admin tables are empty and seed with ENV-derived values."""
pool = await get_pool()
# ---- Admin User ----
user = await settings_service.get_admin_user()
if user is None:
admin_pw = os.getenv("ADMIN_PASSWORD", "")
if not admin_pw:
admin_pw = secrets.token_urlsafe(16)
logger.warning(
"=" * 60 + "\n"
" No ADMIN_PASSWORD set — generated: %s\n"
" Set ADMIN_PASSWORD env to use your own.\n" +
"=" * 60,
admin_pw,
)
await settings_service.create_admin_user("admin", hash_password(admin_pw))
logger.info("Admin user seeded from ENV")
# ---- Integrations ----
existing = await settings_service.get_integrations()
existing_types = {i["type"] for i in existing}
seed_integrations = [
{
"type": "weather",
"name": "Wetter (wttr.in)",
"config": {
"location": os.getenv("WEATHER_LOCATION", "Leverkusen"),
"location_secondary": os.getenv("WEATHER_LOCATION_SECONDARY", "Rab,Croatia"),
},
"enabled": True,
"display_order": 0,
},
{
"type": "news",
"name": "News (PostgreSQL)",
"config": {
"max_age_hours": int(os.getenv("NEWS_MAX_AGE_HOURS", "48")),
},
"enabled": True,
"display_order": 1,
},
{
"type": "ha",
"name": "Home Assistant",
"config": {
"url": os.getenv("HA_URL", ""),
"token": os.getenv("HA_TOKEN", ""),
},
"enabled": bool(os.getenv("HA_URL")),
"display_order": 2,
},
{
"type": "vikunja",
"name": "Vikunja Tasks",
"config": {
"url": os.getenv("VIKUNJA_URL", ""),
"token": os.getenv("VIKUNJA_TOKEN", ""),
"private_projects": [3, 4],
"sams_projects": [2, 5],
},
"enabled": bool(os.getenv("VIKUNJA_URL")),
"display_order": 3,
},
{
"type": "unraid",
"name": "Unraid Server",
"config": {
"servers": _parse_unraid_env(),
},
"enabled": bool(os.getenv("UNRAID_SERVERS")),
"display_order": 4,
},
{
"type": "mqtt",
"name": "MQTT Broker",
"config": {
"host": os.getenv("MQTT_HOST", ""),
"port": int(os.getenv("MQTT_PORT", "1883")),
"username": os.getenv("MQTT_USERNAME", ""),
"password": os.getenv("MQTT_PASSWORD", ""),
"client_id": os.getenv("MQTT_CLIENT_ID", "daily-briefing"),
"topics": _parse_mqtt_topics(),
},
"enabled": bool(os.getenv("MQTT_HOST")),
"display_order": 5,
},
]
for seed in seed_integrations:
if seed["type"] not in existing_types:
await settings_service.upsert_integration(
type_name=seed["type"],
name=seed["name"],
config=seed["config"],
enabled=seed["enabled"],
display_order=seed["display_order"],
)
logger.info("Seeded integration: %s", seed["type"])
# ---- App Settings ----
existing_settings = await settings_service.get_all_settings()
if not existing_settings:
default_settings = [
("weather_cache_ttl", "1800", "int", "cache", "Wetter Cache TTL", "Sekunden"),
("ha_cache_ttl", "30", "int", "cache", "HA Cache TTL", "Sekunden"),
("vikunja_cache_ttl", "60", "int", "cache", "Vikunja Cache TTL", "Sekunden"),
("unraid_cache_ttl", "15", "int", "cache", "Unraid Cache TTL", "Sekunden"),
("news_cache_ttl", "300", "int", "cache", "News Cache TTL", "Sekunden"),
("ws_interval", "15", "int", "general", "WebSocket Intervall", "Sekunden"),
]
for key, value, vtype, cat, label, desc in default_settings:
await settings_service.set_setting(key, value, vtype, cat, label, desc)
logger.info("Seeded %d default settings", len(default_settings))
def _parse_unraid_env() -> list:
"""Parse UNRAID_SERVERS env var."""
raw = os.getenv("UNRAID_SERVERS", "[]")
try:
return json.loads(raw)
except (json.JSONDecodeError, TypeError):
return []
def _parse_mqtt_topics() -> list:
"""Parse MQTT_TOPICS env var."""
raw = os.getenv("MQTT_TOPICS", "#")
try:
return json.loads(raw)
except (json.JSONDecodeError, TypeError):
return [t.strip() for t in raw.split(",") if t.strip()]

View file

@ -0,0 +1,297 @@
"""Database-backed settings, integrations, and user management."""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from server.db import get_pool
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Admin User
# ---------------------------------------------------------------------------
async def get_admin_user() -> Optional[Dict[str, Any]]:
"""Return the admin user row, or None if not yet created."""
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM admin_user LIMIT 1")
return dict(row) if row else None
async def create_admin_user(username: str, password_hash: str) -> None:
"""Insert the initial admin user."""
pool = await get_pool()
async with pool.acquire() as conn:
await conn.execute(
"INSERT INTO admin_user (username, password_hash) VALUES ($1, $2)",
username,
password_hash,
)
logger.info("Admin user '%s' created", username)
async def update_admin_password(user_id: int, password_hash: str) -> None:
"""Update the admin user's password."""
pool = await get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE admin_user SET password_hash = $1, updated_at = NOW() WHERE id = $2",
password_hash,
user_id,
)
logger.info("Admin password updated (user_id=%d)", user_id)
# ---------------------------------------------------------------------------
# App Settings (key/value)
# ---------------------------------------------------------------------------
async def get_all_settings() -> Dict[str, Any]:
"""Return all settings as a dict, casting values to their declared type."""
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT * FROM app_settings ORDER BY category, key")
result: Dict[str, Any] = {}
for row in rows:
result[row["key"]] = {
"value": _cast_value(row["value"], row["value_type"]),
"value_type": row["value_type"],
"category": row["category"],
"label": row["label"],
"description": row["description"],
}
return result
async def get_setting(key: str) -> Optional[Any]:
"""Return a single setting's typed value, or None."""
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT value, value_type FROM app_settings WHERE key = $1", key)
if row is None:
return None
return _cast_value(row["value"], row["value_type"])
async def set_setting(
key: str,
value: Any,
value_type: str = "string",
category: str = "general",
label: str = "",
description: str = "",
) -> None:
"""Upsert a single setting."""
pool = await get_pool()
str_value = json.dumps(value) if value_type == "json" else str(value)
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO app_settings (key, value, value_type, category, label, description, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (key) DO UPDATE SET
value = EXCLUDED.value,
value_type = EXCLUDED.value_type,
category = EXCLUDED.category,
label = EXCLUDED.label,
description = EXCLUDED.description,
updated_at = NOW()
""",
key, str_value, value_type, category, label, description,
)
async def bulk_set_settings(settings_dict: Dict[str, Any]) -> None:
"""Bulk upsert settings from a flat key→value dict."""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
for key, val in settings_dict.items():
str_val = str(val)
await conn.execute(
"""
UPDATE app_settings SET value = $1, updated_at = NOW()
WHERE key = $2
""",
str_val, key,
)
def _cast_value(raw: str, value_type: str) -> Any:
"""Cast a stored string value to its declared type."""
if value_type == "int":
try:
return int(raw)
except (ValueError, TypeError):
return 0
elif value_type == "bool":
return raw.lower() in ("1", "true", "yes")
elif value_type == "json":
try:
return json.loads(raw)
except (json.JSONDecodeError, TypeError):
return raw
return raw
# ---------------------------------------------------------------------------
# Integrations
# ---------------------------------------------------------------------------
async def get_integrations() -> List[Dict[str, Any]]:
"""Return all integration configs."""
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT * FROM integrations ORDER BY display_order, type"
)
return [_integration_to_dict(row) for row in rows]
async def get_integration(type_name: str) -> Optional[Dict[str, Any]]:
"""Return a single integration by type name."""
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM integrations WHERE type = $1", type_name
)
return _integration_to_dict(row) if row else None
async def upsert_integration(
type_name: str,
name: str,
config: Dict[str, Any],
enabled: bool = True,
display_order: int = 0,
) -> Dict[str, Any]:
"""Insert or update an integration config."""
pool = await get_pool()
config_json = json.dumps(config)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO integrations (type, name, config, enabled, display_order, updated_at)
VALUES ($1, $2, $3::jsonb, $4, $5, NOW())
ON CONFLICT (type) DO UPDATE SET
name = EXCLUDED.name,
config = EXCLUDED.config,
enabled = EXCLUDED.enabled,
display_order = EXCLUDED.display_order,
updated_at = NOW()
RETURNING *
""",
type_name, name, config_json, enabled, display_order,
)
return _integration_to_dict(row)
async def toggle_integration(type_name: str, enabled: bool) -> None:
"""Enable or disable an integration."""
pool = await get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE integrations SET enabled = $1, updated_at = NOW() WHERE type = $2",
enabled, type_name,
)
def _integration_to_dict(row: Any) -> Dict[str, Any]:
"""Convert an integration row to a dict."""
d = dict(row)
# Ensure config is a dict (asyncpg returns JSONB as dict already)
if isinstance(d.get("config"), str):
d["config"] = json.loads(d["config"])
# Convert datetimes
for k in ("created_at", "updated_at"):
if k in d and d[k] is not None:
d[k] = d[k].isoformat()
return d
# ---------------------------------------------------------------------------
# MQTT Subscriptions
# ---------------------------------------------------------------------------
async def get_mqtt_subscriptions() -> List[Dict[str, Any]]:
"""Return all MQTT subscriptions."""
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT * FROM mqtt_subscriptions ORDER BY display_order, id"
)
return [_sub_to_dict(row) for row in rows]
async def create_mqtt_subscription(
topic_pattern: str,
display_name: str = "",
category: str = "other",
unit: str = "",
widget_type: str = "value",
enabled: bool = True,
display_order: int = 0,
) -> Dict[str, Any]:
"""Create a new MQTT subscription."""
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO mqtt_subscriptions
(topic_pattern, display_name, category, unit, widget_type, enabled, display_order)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
""",
topic_pattern, display_name, category, unit, widget_type, enabled, display_order,
)
return _sub_to_dict(row)
async def update_mqtt_subscription(sub_id: int, **fields: Any) -> Optional[Dict[str, Any]]:
"""Update specific fields of an MQTT subscription."""
pool = await get_pool()
allowed = {"topic_pattern", "display_name", "category", "unit", "widget_type", "enabled", "display_order"}
updates = {k: v for k, v in fields.items() if k in allowed}
if not updates:
return None
set_parts = []
params = []
for i, (k, v) in enumerate(updates.items(), start=1):
set_parts.append(f"{k} = ${i}")
params.append(v)
params.append(sub_id)
set_clause = ", ".join(set_parts)
async with pool.acquire() as conn:
row = await conn.fetchrow(
f"UPDATE mqtt_subscriptions SET {set_clause}, updated_at = NOW() "
f"WHERE id = ${len(params)} RETURNING *",
*params,
)
return _sub_to_dict(row) if row else None
async def delete_mqtt_subscription(sub_id: int) -> bool:
"""Delete an MQTT subscription. Returns True if deleted."""
pool = await get_pool()
async with pool.acquire() as conn:
result = await conn.execute(
"DELETE FROM mqtt_subscriptions WHERE id = $1", sub_id
)
return result == "DELETE 1"
def _sub_to_dict(row: Any) -> Dict[str, Any]:
d = dict(row)
for k in ("created_at", "updated_at"):
if k in d and d[k] is not None:
d[k] = d[k].isoformat()
return d

View file

@ -0,0 +1,147 @@
"""Integration connection testing functions."""
from __future__ import annotations
import asyncio
import logging
from typing import Any, Dict
import httpx
logger = logging.getLogger(__name__)
TIMEOUT = 10.0
async def test_weather(config: Dict[str, Any]) -> Dict[str, Any]:
"""Test weather service by fetching current conditions."""
location = config.get("location", "Leverkusen")
try:
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
r = await client.get(f"https://wttr.in/{location}?format=j1")
r.raise_for_status()
data = r.json()
temp = data["current_condition"][0]["temp_C"]
return {"success": True, "message": f"Verbunden — {location}: {temp}°C"}
except Exception as exc:
return {"success": False, "message": str(exc)}
async def test_ha(config: Dict[str, Any]) -> Dict[str, Any]:
"""Test Home Assistant connection."""
url = config.get("url", "")
token = config.get("token", "")
if not url or not token:
return {"success": False, "message": "URL und Token sind erforderlich"}
try:
async with httpx.AsyncClient(timeout=TIMEOUT, verify=False) as client:
r = await client.get(
f"{url.rstrip('/')}/api/",
headers={"Authorization": f"Bearer {token}"},
)
r.raise_for_status()
data = r.json()
return {"success": True, "message": f"Verbunden — {data.get('message', 'OK')}"}
except Exception as exc:
return {"success": False, "message": str(exc)}
async def test_vikunja(config: Dict[str, Any]) -> Dict[str, Any]:
"""Test Vikunja API connection."""
url = config.get("url", "")
token = config.get("token", "")
if not url or not token:
return {"success": False, "message": "URL und Token sind erforderlich"}
try:
base = url.rstrip("/")
# Try to reach the info or user endpoint
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
r = await client.get(
f"{base}/user",
headers={"Authorization": f"Bearer {token}"},
)
r.raise_for_status()
data = r.json()
return {"success": True, "message": f"Verbunden als {data.get('username', 'OK')}"}
except Exception as exc:
return {"success": False, "message": str(exc)}
async def test_unraid(config: Dict[str, Any]) -> Dict[str, Any]:
"""Test Unraid server connectivity."""
servers = config.get("servers", [])
if not servers:
return {"success": False, "message": "Keine Server konfiguriert"}
results = []
for srv in servers:
name = srv.get("name", srv.get("host", "?"))
host = srv.get("host", "")
port = srv.get("port", 80)
if not host:
results.append(f"{name}: Kein Host")
continue
try:
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.get(f"http://{host}:{port}/")
results.append(f"{name}: Online ({r.status_code})")
except Exception as exc:
results.append(f"{name}: Offline ({exc})")
all_ok = all("Online" in r for r in results)
return {
"success": all_ok,
"message": " | ".join(results),
}
async def test_mqtt(config: Dict[str, Any]) -> Dict[str, Any]:
"""Test MQTT broker connection."""
host = config.get("host", "")
port = int(config.get("port", 1883))
username = config.get("username") or None
password = config.get("password") or None
if not host:
return {"success": False, "message": "MQTT Host ist erforderlich"}
try:
import aiomqtt
async with aiomqtt.Client(
hostname=host,
port=port,
username=username,
password=password,
identifier="daily-briefing-test",
) as client:
# If we get here, connection succeeded
pass
return {"success": True, "message": f"Verbunden mit {host}:{port}"}
except Exception as exc:
return {"success": False, "message": str(exc)}
async def test_news_db(config: Dict[str, Any]) -> Dict[str, Any]:
"""Test that market_news table is accessible."""
try:
from server.db import get_pool
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT COUNT(*) AS cnt FROM market_news")
count = row["cnt"] if row else 0
return {"success": True, "message": f"Verbunden — {count} Artikel in der Datenbank"}
except Exception as exc:
return {"success": False, "message": str(exc)}
# Map integration type → test function
TEST_FUNCTIONS = {
"weather": test_weather,
"ha": test_ha,
"vikunja": test_vikunja,
"unraid": test_unraid,
"mqtt": test_mqtt,
"news": test_news_db,
}

61
web/package-lock.json generated
View file

@ -10,7 +10,8 @@
"dependencies": { "dependencies": {
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"react-router-dom": "^7.13.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
@ -1533,6 +1534,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cssesc": { "node_modules/cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -2277,6 +2291,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@ -2295,6 +2310,44 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-router": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
"license": "MIT",
"dependencies": {
"react-router": "7.13.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/read-cache": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@ -2438,6 +2491,12 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View file

@ -11,7 +11,8 @@
"dependencies": { "dependencies": {
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"react-router-dom": "^7.13.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.18", "@types/react": "^18.3.18",

View file

@ -1,181 +1,40 @@
import { useDashboard } from "./hooks/useDashboard"; import { Routes, Route, Navigate } from "react-router-dom";
import Clock from "./components/Clock"; import Dashboard from "./pages/Dashboard";
import WeatherCard from "./components/WeatherCard"; import AdminLayout from "./admin/AdminLayout";
import HourlyForecast from "./components/HourlyForecast"; import LoginPage from "./admin/LoginPage";
import NewsGrid from "./components/NewsGrid"; import GeneralSettings from "./admin/pages/GeneralSettings";
import ServerCard from "./components/ServerCard"; import WeatherSettings from "./admin/pages/WeatherSettings";
import HomeAssistant from "./components/HomeAssistant"; import NewsSettings from "./admin/pages/NewsSettings";
import TasksCard from "./components/TasksCard"; import HASettings from "./admin/pages/HASettings";
import MqttCard from "./components/MqttCard"; import VikunjaSettings from "./admin/pages/VikunjaSettings";
import { RefreshCw, Wifi, WifiOff, AlertTriangle } from "lucide-react"; import UnraidSettings from "./admin/pages/UnraidSettings";
import MqttSettings from "./admin/pages/MqttSettings";
import ChangePassword from "./admin/pages/ChangePassword";
export default function App() { export default function App() {
const { data, loading, error, connected, refresh } = useDashboard();
return ( return (
<div className="min-h-screen text-white"> <Routes>
{/* ---- Error banner ---- */} {/* Dashboard */}
{error && ( <Route path="/" element={<Dashboard />} />
<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 ---- */} {/* Admin Login */}
<header className="sticky top-0 z-40 border-b border-white/[0.04] bg-slate-950/80 backdrop-blur-lg"> <Route path="/admin/login" element={<LoginPage />} />
<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 */} {/* Admin Panel (protected) */}
<div className="flex items-center gap-4"> <Route path="/admin" element={<AdminLayout />}>
<button <Route index element={<Navigate to="general" replace />} />
onClick={refresh} <Route path="general" element={<GeneralSettings />} />
disabled={loading} <Route path="weather" element={<WeatherSettings />} />
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" <Route path="news" element={<NewsSettings />} />
title="Daten aktualisieren" <Route path="homeassistant" element={<HASettings />} />
> <Route path="vikunja" element={<VikunjaSettings />} />
<RefreshCw className={`w-3.5 h-3.5 text-slate-400 ${loading ? "animate-spin" : ""}`} /> <Route path="unraid" element={<UnraidSettings />} />
</button> <Route path="mqtt" element={<MqttSettings />} />
<Clock /> <Route path="password" element={<ChangePassword />} />
</div> </Route>
</div>
</header>
{/* ---- Main content ---- */} {/* Catch all */}
<main className="max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6"> <Route path="*" element={<Navigate to="/" replace />} />
{loading && !data ? ( </Routes>
<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 2.5: MQTT (only show if connected or has entities) */}
{(data.mqtt?.connected || (data.mqtt?.entities?.length ?? 0) > 0) && (
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<div className="md:col-span-2 xl:col-span-4">
<MqttCard data={data.mqtt} />
</div>
</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>
); );
} }

View file

@ -0,0 +1,147 @@
import { useEffect, useState } from "react";
import { Outlet, NavLink, useNavigate, Link } from "react-router-dom";
import {
Settings, Cloud, Newspaper, Home, ListTodo, Server, Radio,
Lock, ArrowLeft, Menu, X, LogOut, Loader2,
} from "lucide-react";
import { isAuthenticated, verifyToken, clearAuth, getUsername } from "./api";
const NAV_ITEMS = [
{ to: "general", label: "Allgemein", icon: Settings },
{ to: "weather", label: "Wetter", icon: Cloud },
{ to: "news", label: "News", icon: Newspaper },
{ to: "homeassistant", label: "Home Assistant", icon: Home },
{ to: "vikunja", label: "Vikunja", icon: ListTodo },
{ to: "unraid", label: "Unraid Server", icon: Server },
{ to: "mqtt", label: "MQTT", icon: Radio },
{ to: "password", label: "Passwort ändern", icon: Lock },
];
export default function AdminLayout() {
const navigate = useNavigate();
const [verified, setVerified] = useState(false);
const [checking, setChecking] = useState(true);
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
if (!isAuthenticated()) {
navigate("/admin/login", { replace: true });
return;
}
verifyToken().then((ok) => {
if (!ok) {
clearAuth();
navigate("/admin/login", { replace: true });
} else {
setVerified(true);
}
setChecking(false);
});
}, [navigate]);
const handleLogout = () => {
clearAuth();
navigate("/admin/login", { replace: true });
};
if (checking) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="w-6 h-6 text-slate-400 animate-spin" />
</div>
);
}
if (!verified) return null;
return (
<div className="min-h-screen text-white">
{/* Mobile header */}
<header className="lg:hidden sticky top-0 z-50 border-b border-white/[0.04] bg-slate-950/80 backdrop-blur-lg">
<div className="flex items-center justify-between px-4 h-14">
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="p-2 -ml-2 hover:bg-white/5 rounded-lg transition-colors">
{sidebarOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
<span className="text-sm font-semibold">Admin Panel</span>
<Link to="/" className="p-2 -mr-2 hover:bg-white/5 rounded-lg transition-colors">
<ArrowLeft className="w-5 h-5" />
</Link>
</div>
</header>
<div className="flex">
{/* Sidebar */}
<aside className={`
fixed inset-y-0 left-0 z-40 w-64 border-r border-white/[0.04] bg-slate-950/95 backdrop-blur-lg
transform transition-transform duration-200 ease-in-out
lg:relative lg:translate-x-0 lg:block
${sidebarOpen ? "translate-x-0" : "-translate-x-full"}
`}>
<div className="flex flex-col h-full">
{/* Sidebar header */}
<div className="hidden lg:flex items-center justify-between px-5 h-16 border-b border-white/[0.04]">
<h2 className="text-sm font-bold tracking-tight">Admin Panel</h2>
<Link
to="/"
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-white transition-colors"
>
<ArrowLeft className="w-3.5 h-3.5" />
Dashboard
</Link>
</div>
{/* Nav items */}
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-1">
{NAV_ITEMS.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150 ${
isActive
? "bg-white/10 text-white shadow-sm shadow-black/20"
: "text-slate-400 hover:text-white hover:bg-white/5"
}`
}
>
<Icon className="w-4 h-4 flex-shrink-0" />
{label}
</NavLink>
))}
</nav>
{/* Footer */}
<div className="p-4 border-t border-white/[0.04]">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500 truncate">{getUsername()}</span>
<button
onClick={handleLogout}
className="flex items-center gap-1.5 text-xs text-slate-500 hover:text-red-400 transition-colors"
>
<LogOut className="w-3.5 h-3.5" />
Logout
</button>
</div>
</div>
</div>
</aside>
{/* Overlay for mobile sidebar */}
{sidebarOpen && (
<div
className="fixed inset-0 z-30 bg-black/50 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Main content */}
<main className="flex-1 min-h-screen lg:min-h-[calc(100vh)]">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Outlet />
</div>
</main>
</div>
</div>
);
}

113
web/src/admin/LoginPage.tsx Normal file
View file

@ -0,0 +1,113 @@
import { useState, FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { Lock, User, AlertCircle, Loader2 } from "lucide-react";
import { login, isAuthenticated } from "./api";
export default function LoginPage() {
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
// Redirect if already logged in
if (isAuthenticated()) {
navigate("/admin", { replace: true });
return null;
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await login(username, password);
navigate("/admin", { replace: true });
} catch (err: any) {
setError(err.message || "Login fehlgeschlagen");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-sm">
{/* Logo / Title */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-gradient-to-br from-cyan-500/20 to-blue-500/20 border border-white/[0.08] mb-4">
<Lock className="w-6 h-6 text-cyan-400" />
</div>
<h1 className="text-xl font-bold text-white tracking-tight">Daily Briefing</h1>
<p className="text-sm text-slate-500 mt-1">Admin Panel</p>
</div>
{/* Login Card */}
<div className="glass-card p-6">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-500/10 border border-red-500/20">
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
<p className="text-xs text-red-300">{error}</p>
</div>
)}
<div>
<label htmlFor="username" className="block text-xs font-medium text-slate-400 mb-1.5">
Benutzername
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="admin"
required
autoFocus
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white/5 border border-white/[0.08] text-sm text-white placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-xs font-medium text-slate-400 mb-1.5">
Passwort
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white/5 border border-white/[0.08] text-sm text-white placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors"
/>
</div>
</div>
<button
type="submit"
disabled={loading || !username || !password}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-500 text-sm font-semibold text-white hover:from-cyan-400 hover:to-blue-400 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"Anmelden"
)}
</button>
</form>
</div>
<p className="text-center text-[10px] text-slate-700 mt-6">
Daily Briefing v2.1 Admin
</p>
</div>
</div>
);
}

218
web/src/admin/api.ts Normal file
View file

@ -0,0 +1,218 @@
/**
* Admin API client with JWT token management.
* Stores the token in localStorage and automatically attaches it to requests.
*/
const API_BASE = "/api";
const TOKEN_KEY = "admin_token";
const USERNAME_KEY = "admin_user";
// ---------------------------------------------------------------------------
// Token Management
// ---------------------------------------------------------------------------
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function getUsername(): string | null {
return localStorage.getItem(USERNAME_KEY);
}
export function setAuth(token: string, username: string): void {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USERNAME_KEY, username);
}
export function clearAuth(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USERNAME_KEY);
}
export function isAuthenticated(): boolean {
return !!getToken();
}
// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------
async function authFetch(path: string, options: RequestInit = {}): Promise<Response> {
const token = getToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string> || {}),
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (res.status === 401 || res.status === 403) {
clearAuth();
window.location.href = "/admin/login";
throw new Error("Sitzung abgelaufen");
}
return res;
}
async function fetchJSON<T>(path: string): Promise<T> {
const res = await authFetch(path);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Fehler: ${res.status}`);
}
return res.json();
}
async function putJSON<T>(path: string, data: unknown): Promise<T> {
const res = await authFetch(path, {
method: "PUT",
body: JSON.stringify(data),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Fehler: ${res.status}`);
}
return res.json();
}
async function postJSON<T>(path: string, data?: unknown): Promise<T> {
const res = await authFetch(path, {
method: "POST",
body: data ? JSON.stringify(data) : undefined,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Fehler: ${res.status}`);
}
return res.json();
}
async function deleteJSON<T>(path: string): Promise<T> {
const res = await authFetch(path, { method: "DELETE" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Fehler: ${res.status}`);
}
return res.json();
}
// ---------------------------------------------------------------------------
// Auth API
// ---------------------------------------------------------------------------
export async function login(username: string, password: string): Promise<{ token: string; username: string }> {
const res = await fetch(`${API_BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || "Login fehlgeschlagen");
}
const data = await res.json();
setAuth(data.token, data.username);
return data;
}
export async function verifyToken(): Promise<boolean> {
try {
await fetchJSON("/auth/me");
return true;
} catch {
return false;
}
}
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
await putJSON("/auth/password", {
current_password: currentPassword,
new_password: newPassword,
});
}
// ---------------------------------------------------------------------------
// Settings API
// ---------------------------------------------------------------------------
export interface AppSettings {
[key: string]: { value: string; value_type: string; category: string; label: string; description: string };
}
export async function getSettings(): Promise<AppSettings> {
return fetchJSON("/admin/settings");
}
export async function updateSettings(settings: Record<string, unknown>): Promise<void> {
await putJSON("/admin/settings", { settings });
}
// ---------------------------------------------------------------------------
// Integrations API
// ---------------------------------------------------------------------------
export interface Integration {
id: number;
type: string;
name: string;
config: Record<string, unknown>;
enabled: boolean;
display_order: number;
}
export async function getIntegrations(): Promise<Integration[]> {
return fetchJSON("/admin/integrations");
}
export async function getIntegration(type: string): Promise<Integration> {
return fetchJSON(`/admin/integrations/${type}`);
}
export async function updateIntegration(
type: string,
data: { name?: string; config?: Record<string, unknown>; enabled?: boolean; display_order?: number }
): Promise<Integration> {
return putJSON(`/admin/integrations/${type}`, data);
}
export async function testIntegration(type: string): Promise<{ success: boolean; message: string }> {
return postJSON(`/admin/integrations/${type}/test`);
}
// ---------------------------------------------------------------------------
// MQTT Subscriptions API
// ---------------------------------------------------------------------------
export interface MqttSubscription {
id: number;
topic_pattern: string;
display_name: string;
category: string;
unit: string;
widget_type: string;
enabled: boolean;
display_order: number;
}
export async function getMqttSubscriptions(): Promise<MqttSubscription[]> {
return fetchJSON("/admin/mqtt/subscriptions");
}
export async function createMqttSubscription(data: Omit<MqttSubscription, "id">): Promise<MqttSubscription> {
return postJSON("/admin/mqtt/subscriptions", data);
}
export async function updateMqttSubscription(
id: number,
data: Partial<Omit<MqttSubscription, "id">>
): Promise<MqttSubscription> {
return putJSON(`/admin/mqtt/subscriptions/${id}`, data);
}
export async function deleteMqttSubscription(id: number): Promise<void> {
await deleteJSON(`/admin/mqtt/subscriptions/${id}`);
}

View file

@ -0,0 +1,63 @@
interface Props {
label: string;
description?: string;
children: React.ReactNode;
}
export default function FormField({ label, description, children }: Props) {
return (
<div>
<label className="block text-xs font-medium text-slate-300 mb-1.5">{label}</label>
{description && <p className="text-[11px] text-slate-500 mb-2">{description}</p>}
{children}
</div>
);
}
export function TextInput({
value,
onChange,
placeholder,
type = "text",
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
type?: string;
}) {
return (
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full px-3.5 py-2.5 rounded-xl bg-white/5 border border-white/[0.08] text-sm text-white placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors"
/>
);
}
export function NumberInput({
value,
onChange,
min,
max,
step,
}: {
value: number;
onChange: (v: number) => void;
min?: number;
max?: number;
step?: number;
}) {
return (
<input
type="number"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
min={min}
max={max}
step={step}
className="w-full px-3.5 py-2.5 rounded-xl bg-white/5 border border-white/[0.08] text-sm text-white placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors"
/>
);
}

View file

@ -0,0 +1,98 @@
import { useState, ReactNode, FormEvent } from "react";
import { Loader2, Save, CheckCircle2 } from "lucide-react";
import { updateIntegration, type Integration } from "../api";
import TestButton from "./TestButton";
interface Props {
integration: Integration;
onSaved: (updated: Integration) => void;
children: (config: Record<string, unknown>, setConfig: (key: string, value: unknown) => void) => ReactNode;
}
export default function IntegrationForm({ integration, onSaved, children }: Props) {
const [config, setConfigState] = useState<Record<string, unknown>>(integration.config || {});
const [enabled, setEnabled] = useState(integration.enabled);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState("");
const setConfig = (key: string, value: unknown) => {
setConfigState((prev) => ({ ...prev, [key]: value }));
setSaved(false);
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setSaving(true);
setError("");
setSaved(false);
try {
const updated = await updateIntegration(integration.type, { config, enabled });
onSaved(updated);
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err: any) {
setError(err.message || "Speichern fehlgeschlagen");
} finally {
setSaving(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Enable toggle */}
<div className="flex items-center justify-between p-4 rounded-xl bg-white/[0.02] border border-white/[0.06]">
<div>
<p className="text-sm font-medium text-white">{integration.name}</p>
<p className="text-xs text-slate-500 mt-0.5">Integration {enabled ? "aktiv" : "deaktiviert"}</p>
</div>
<button
type="button"
onClick={() => { setEnabled(!enabled); setSaved(false); }}
className={`relative w-11 h-6 rounded-full transition-colors ${
enabled ? "bg-cyan-500" : "bg-slate-700"
}`}
>
<span
className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${
enabled ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
{/* Config fields (rendered by parent) */}
<div className={`space-y-4 transition-opacity ${enabled ? "opacity-100" : "opacity-40 pointer-events-none"}`}>
{children(config, setConfig)}
</div>
{/* Error */}
{error && (
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/20">
<p className="text-xs text-red-300">{error}</p>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
disabled={saving}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-500 text-sm font-semibold text-white hover:from-cyan-400 hover:to-blue-400 transition-all disabled:opacity-40"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : saved ? (
<CheckCircle2 className="w-4 h-4" />
) : (
<Save className="w-4 h-4" />
)}
{saving ? "Speichern..." : saved ? "Gespeichert!" : "Speichern"}
</button>
<TestButton type={integration.type} disabled={!enabled} />
</div>
</form>
);
}

View file

@ -0,0 +1,21 @@
import { type LucideIcon } from "lucide-react";
interface Props {
icon: LucideIcon;
title: string;
description: string;
}
export default function PageHeader({ icon: Icon, title, description }: Props) {
return (
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center justify-center w-9 h-9 rounded-xl bg-gradient-to-br from-cyan-500/15 to-blue-500/15 border border-white/[0.06]">
<Icon className="w-4 h-4 text-cyan-400" />
</div>
<h1 className="text-lg font-bold tracking-tight text-white">{title}</h1>
</div>
<p className="text-sm text-slate-500 ml-12">{description}</p>
</div>
);
}

View file

@ -0,0 +1,59 @@
import { useState } from "react";
import { Loader2, CheckCircle2, XCircle, Zap } from "lucide-react";
import { testIntegration } from "../api";
interface Props {
type: string;
disabled?: boolean;
}
export default function TestButton({ type, disabled }: Props) {
const [status, setStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
const [message, setMessage] = useState("");
const handleTest = async () => {
setStatus("testing");
setMessage("");
try {
const result = await testIntegration(type);
setStatus(result.success ? "success" : "error");
setMessage(result.message);
} catch (err: any) {
setStatus("error");
setMessage(err.message || "Test fehlgeschlagen");
}
};
return (
<div className="space-y-2">
<button
onClick={handleTest}
disabled={disabled || status === "testing"}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-medium transition-all ${
status === "success"
? "bg-emerald-500/15 border border-emerald-500/30 text-emerald-400"
: status === "error"
? "bg-red-500/15 border border-red-500/30 text-red-400"
: "bg-white/5 border border-white/[0.08] text-slate-300 hover:bg-white/10 hover:text-white"
} disabled:opacity-40 disabled:cursor-not-allowed`}
>
{status === "testing" ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : status === "success" ? (
<CheckCircle2 className="w-3.5 h-3.5" />
) : status === "error" ? (
<XCircle className="w-3.5 h-3.5" />
) : (
<Zap className="w-3.5 h-3.5" />
)}
{status === "testing" ? "Teste..." : "Verbindung testen"}
</button>
{message && (
<p className={`text-xs ${status === "success" ? "text-emerald-400/80" : "text-red-400/80"}`}>
{message}
</p>
)}
</div>
);
}

View file

@ -0,0 +1,114 @@
import { useState, FormEvent } from "react";
import { Lock, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
import PageHeader from "../components/PageHeader";
import FormField, { TextInput } from "../components/FormField";
import { changePassword } from "../api";
export default function ChangePassword() {
const [currentPw, setCurrentPw] = useState("");
const [newPw, setNewPw] = useState("");
const [confirmPw, setConfirmPw] = useState("");
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState("");
const canSubmit = currentPw.length > 0 && newPw.length >= 6 && newPw === confirmPw;
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
setSaving(true);
setError("");
setSaved(false);
try {
await changePassword(currentPw, newPw);
setSaved(true);
setCurrentPw("");
setNewPw("");
setConfirmPw("");
setTimeout(() => setSaved(false), 5000);
} catch (err: any) {
setError(err.message || "Passwort ändern fehlgeschlagen");
} finally {
setSaving(false);
}
};
return (
<div>
<PageHeader
icon={Lock}
title="Passwort ändern"
description="Admin-Passwort für den Login aktualisieren"
/>
<div className="glass-card p-6 max-w-md">
<form onSubmit={handleSubmit} className="space-y-5">
{saved && (
<div className="flex items-center gap-2 p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<CheckCircle2 className="w-4 h-4 text-emerald-400 flex-shrink-0" />
<p className="text-xs text-emerald-300">Passwort erfolgreich geändert!</p>
</div>
)}
{error && (
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-500/10 border border-red-500/20">
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
<p className="text-xs text-red-300">{error}</p>
</div>
)}
<FormField label="Aktuelles Passwort">
<TextInput
value={currentPw}
onChange={setCurrentPw}
type="password"
placeholder="••••••••"
/>
</FormField>
<FormField label="Neues Passwort" description="Mindestens 6 Zeichen">
<TextInput
value={newPw}
onChange={setNewPw}
type="password"
placeholder="••••••••"
/>
</FormField>
<FormField label="Neues Passwort bestätigen">
<TextInput
value={confirmPw}
onChange={setConfirmPw}
type="password"
placeholder="••••••••"
/>
</FormField>
{newPw && confirmPw && newPw !== confirmPw && (
<p className="text-xs text-amber-400">Passwörter stimmen nicht überein</p>
)}
{newPw && newPw.length > 0 && newPw.length < 6 && (
<p className="text-xs text-amber-400">Mindestens 6 Zeichen erforderlich</p>
)}
<button
type="submit"
disabled={!canSubmit || saving}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-500 text-sm font-semibold text-white hover:from-cyan-400 hover:to-blue-400 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Lock className="w-4 h-4" />
)}
{saving ? "Wird geändert..." : "Passwort ändern"}
</button>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,136 @@
import { useEffect, useState } from "react";
import { Settings, Loader2, Save, CheckCircle2 } from "lucide-react";
import PageHeader from "../components/PageHeader";
import FormField, { NumberInput } from "../components/FormField";
import { getSettings, updateSettings, type AppSettings } from "../api";
export default function GeneralSettings() {
const [settings, setSettings] = useState<AppSettings | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState("");
// Local form values
const [wsInterval, setWsInterval] = useState(15);
const [weatherTtl, setWeatherTtl] = useState(600);
const [newsTtl, setNewsTtl] = useState(300);
const [serversTtl, setServersTtl] = useState(120);
const [haTtl, setHaTtl] = useState(60);
const [tasksTtl, setTasksTtl] = useState(180);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
const data = await getSettings();
setSettings(data);
// Populate form from loaded settings
if (data.ws_interval) setWsInterval(Number(data.ws_interval.value));
if (data.weather_cache_ttl) setWeatherTtl(Number(data.weather_cache_ttl.value));
if (data.news_cache_ttl) setNewsTtl(Number(data.news_cache_ttl.value));
if (data.unraid_cache_ttl) setServersTtl(Number(data.unraid_cache_ttl.value));
if (data.ha_cache_ttl) setHaTtl(Number(data.ha_cache_ttl.value));
if (data.tasks_cache_ttl) setTasksTtl(Number(data.tasks_cache_ttl.value));
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
setError("");
setSaved(false);
try {
await updateSettings({
ws_interval: wsInterval,
weather_cache_ttl: weatherTtl,
news_cache_ttl: newsTtl,
unraid_cache_ttl: serversTtl,
ha_cache_ttl: haTtl,
tasks_cache_ttl: tasksTtl,
});
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
</div>
);
}
return (
<div>
<PageHeader
icon={Settings}
title="Allgemeine Einstellungen"
description="Cache-Zeiten und Dashboard-Verhalten konfigurieren"
/>
<div className="glass-card p-6 space-y-6">
<h3 className="text-sm font-semibold text-white">WebSocket Intervall</h3>
<FormField label="Update-Intervall (Sekunden)" description="Wie oft das Dashboard automatisch neue Daten empfängt">
<NumberInput value={wsInterval} onChange={setWsInterval} min={5} max={120} step={5} />
</FormField>
<div className="border-t border-white/[0.04] my-2" />
<h3 className="text-sm font-semibold text-white">Cache TTLs (Sekunden)</h3>
<p className="text-xs text-slate-500">Wie lange gecachte Daten gültig sind bevor sie neu geladen werden</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField label="Wetter">
<NumberInput value={weatherTtl} onChange={setWeatherTtl} min={60} max={3600} />
</FormField>
<FormField label="News">
<NumberInput value={newsTtl} onChange={setNewsTtl} min={60} max={3600} />
</FormField>
<FormField label="Unraid Server">
<NumberInput value={serversTtl} onChange={setServersTtl} min={30} max={3600} />
</FormField>
<FormField label="Home Assistant">
<NumberInput value={haTtl} onChange={setHaTtl} min={10} max={3600} />
</FormField>
<FormField label="Tasks (Vikunja)">
<NumberInput value={tasksTtl} onChange={setTasksTtl} min={30} max={3600} />
</FormField>
</div>
{error && (
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/20">
<p className="text-xs text-red-300">{error}</p>
</div>
)}
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-500 text-sm font-semibold text-white hover:from-cyan-400 hover:to-blue-400 transition-all disabled:opacity-40"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : saved ? (
<CheckCircle2 className="w-4 h-4" />
) : (
<Save className="w-4 h-4" />
)}
{saving ? "Speichern..." : saved ? "Gespeichert!" : "Speichern"}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,81 @@
import { useEffect, useState } from "react";
import { Home, Loader2 } from "lucide-react";
import PageHeader from "../components/PageHeader";
import FormField, { TextInput } from "../components/FormField";
import IntegrationForm from "../components/IntegrationForm";
import { getIntegration, type Integration } from "../api";
export default function HASettings() {
const [integration, setIntegration] = useState<Integration | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
loadIntegration();
}, []);
const loadIntegration = async () => {
try {
const data = await getIntegration("ha");
setIntegration(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
</div>
);
}
if (error || !integration) {
return (
<div>
<PageHeader icon={Home} title="Home Assistant" description="Home Assistant konfigurieren" />
<div className="glass-card p-6">
<p className="text-sm text-red-400">{error || "Integration nicht gefunden"}</p>
</div>
</div>
);
}
return (
<div>
<PageHeader
icon={Home}
title="Home Assistant"
description="Verbindung zu deiner Home Assistant Instanz konfigurieren"
/>
<div className="glass-card p-6">
<IntegrationForm integration={integration} onSaved={setIntegration}>
{(config, setConfig) => (
<>
<FormField label="Home Assistant URL" description="Basis-URL deiner HA-Instanz (inkl. Port)">
<TextInput
value={(config.url as string) || ""}
onChange={(v) => setConfig("url", v)}
placeholder="http://10.10.10.50:8123"
/>
</FormField>
<FormField label="Long-Lived Access Token" description="Erstelle diesen Token in HA unter Profil → Langlebige Zugangstoken">
<TextInput
value={(config.token as string) || ""}
onChange={(v) => setConfig("token", v)}
type="password"
placeholder="eyJhbGciOi..."
/>
</FormField>
</>
)}
</IntegrationForm>
</div>
</div>
);
}

View file

@ -0,0 +1,343 @@
import { useEffect, useState } from "react";
import {
Radio, Loader2, Plus, Trash2, Save, CheckCircle2, Edit2, X, Check,
} from "lucide-react";
import PageHeader from "../components/PageHeader";
import FormField, { TextInput, NumberInput } from "../components/FormField";
import IntegrationForm from "../components/IntegrationForm";
import {
getIntegration,
getMqttSubscriptions,
createMqttSubscription,
updateMqttSubscription,
deleteMqttSubscription,
type Integration,
type MqttSubscription,
} from "../api";
export default function MqttSettings() {
const [integration, setIntegration] = useState<Integration | null>(null);
const [subscriptions, setSubscriptions] = useState<MqttSubscription[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [integ, subs] = await Promise.all([
getIntegration("mqtt"),
getMqttSubscriptions(),
]);
setIntegration(integ);
setSubscriptions(subs);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
</div>
);
}
if (error && !integration) {
return (
<div>
<PageHeader icon={Radio} title="MQTT" description="MQTT Broker konfigurieren" />
<div className="glass-card p-6">
<p className="text-sm text-red-400">{error}</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
icon={Radio}
title="MQTT"
description="MQTT Broker und Topic-Subscriptions verwalten"
/>
{/* Broker Config */}
{integration && (
<div className="glass-card p-6">
<h3 className="text-sm font-semibold text-white mb-4">Broker-Verbindung</h3>
<IntegrationForm integration={integration} onSaved={setIntegration}>
{(config, setConfig) => (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField label="Broker Host">
<TextInput
value={(config.host as string) || ""}
onChange={(v) => setConfig("host", v)}
placeholder="z.B. 10.10.10.50"
/>
</FormField>
<FormField label="Port">
<NumberInput
value={Number(config.port) || 1883}
onChange={(v) => setConfig("port", v)}
min={1}
max={65535}
/>
</FormField>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField label="Benutzername" description="Optional">
<TextInput
value={(config.username as string) || ""}
onChange={(v) => setConfig("username", v)}
/>
</FormField>
<FormField label="Passwort" description="Optional">
<TextInput
value={(config.password as string) || ""}
onChange={(v) => setConfig("password", v)}
type="password"
/>
</FormField>
</div>
<FormField label="Client ID" description="Eindeutige ID für diese MQTT-Verbindung">
<TextInput
value={(config.client_id as string) || ""}
onChange={(v) => setConfig("client_id", v)}
placeholder="daily-briefing"
/>
</FormField>
</>
)}
</IntegrationForm>
</div>
)}
{/* Subscriptions */}
<div className="glass-card p-6">
<SubscriptionManager
subscriptions={subscriptions}
onUpdate={setSubscriptions}
/>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Subscription Manager sub-component
// ---------------------------------------------------------------------------
function SubscriptionManager({
subscriptions,
onUpdate,
}: {
subscriptions: MqttSubscription[];
onUpdate: (subs: MqttSubscription[]) => void;
}) {
const [adding, setAdding] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [error, setError] = useState("");
// New subscription form
const [newTopic, setNewTopic] = useState("");
const [newName, setNewName] = useState("");
const [newCategory, setNewCategory] = useState("other");
const [newUnit, setNewUnit] = useState("");
const [newWidget, setNewWidget] = useState("value");
const handleAdd = async () => {
if (!newTopic.trim()) return;
setError("");
try {
const created = await createMqttSubscription({
topic_pattern: newTopic.trim(),
display_name: newName.trim() || newTopic.trim(),
category: newCategory,
unit: newUnit,
widget_type: newWidget,
enabled: true,
display_order: subscriptions.length,
});
onUpdate([...subscriptions, created]);
resetNewForm();
} catch (err: any) {
setError(err.message);
}
};
const handleDelete = async (id: number) => {
setError("");
try {
await deleteMqttSubscription(id);
onUpdate(subscriptions.filter((s) => s.id !== id));
} catch (err: any) {
setError(err.message);
}
};
const handleToggle = async (sub: MqttSubscription) => {
setError("");
try {
const updated = await updateMqttSubscription(sub.id, { enabled: !sub.enabled });
onUpdate(subscriptions.map((s) => (s.id === sub.id ? updated : s)));
} catch (err: any) {
setError(err.message);
}
};
const resetNewForm = () => {
setAdding(false);
setNewTopic("");
setNewName("");
setNewCategory("other");
setNewUnit("");
setNewWidget("value");
};
const CATEGORIES = ["system", "sensor", "docker", "network", "other"];
const WIDGETS = ["value", "gauge", "switch", "badge"];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white">Topic-Subscriptions</h3>
<span className="text-xs text-slate-500">{subscriptions.length} Topics</span>
</div>
{/* Existing subscriptions */}
<div className="space-y-2">
{subscriptions.map((sub) => (
<div
key={sub.id}
className={`flex items-center gap-3 p-3 rounded-xl border transition-colors ${
sub.enabled
? "bg-white/[0.02] border-white/[0.06]"
: "bg-white/[0.01] border-white/[0.03] opacity-50"
}`}
>
<button
onClick={() => handleToggle(sub)}
className={`relative w-9 h-5 rounded-full transition-colors flex-shrink-0 ${
sub.enabled ? "bg-cyan-500" : "bg-slate-700"
}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
sub.enabled ? "translate-x-4" : "translate-x-0"
}`} />
</button>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-white truncate">{sub.display_name || sub.topic_pattern}</p>
<p className="text-[10px] text-slate-500 font-mono truncate">{sub.topic_pattern}</p>
</div>
<span className="text-[10px] px-2 py-0.5 rounded-full bg-white/5 text-slate-400 flex-shrink-0">
{sub.category}
</span>
{sub.unit && (
<span className="text-[10px] text-slate-500 flex-shrink-0">{sub.unit}</span>
)}
<button
onClick={() => handleDelete(sub.id)}
className="p-1.5 rounded-lg text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition-colors flex-shrink-0"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
{subscriptions.length === 0 && !adding && (
<p className="text-xs text-slate-600 text-center py-6">
Keine Subscriptions konfiguriert. Füge einen MQTT-Topic hinzu um Daten zu empfangen.
</p>
)}
</div>
{/* Add new */}
{adding ? (
<div className="p-4 rounded-xl bg-white/[0.03] border border-cyan-500/20 space-y-3">
<p className="text-xs font-semibold text-cyan-400">Neuer Topic</p>
<FormField label="Topic Pattern" description="z.B. unraid/server1/cpu/usage oder sensors/#">
<TextInput value={newTopic} onChange={setNewTopic} placeholder="unraid/+/cpu/usage" />
</FormField>
<FormField label="Anzeigename">
<TextInput value={newName} onChange={setNewName} placeholder="CPU Auslastung" />
</FormField>
<div className="grid grid-cols-3 gap-3">
<FormField label="Kategorie">
<select
value={newCategory}
onChange={(e) => setNewCategory(e.target.value)}
className="w-full px-3 py-2.5 rounded-xl bg-white/5 border border-white/[0.08] text-sm text-white focus:outline-none focus:border-cyan-500/50 transition-colors"
>
{CATEGORIES.map((c) => (
<option key={c} value={c} className="bg-slate-900">{c}</option>
))}
</select>
</FormField>
<FormField label="Einheit">
<TextInput value={newUnit} onChange={setNewUnit} placeholder="%, °C, MB" />
</FormField>
<FormField label="Widget">
<select
value={newWidget}
onChange={(e) => setNewWidget(e.target.value)}
className="w-full px-3 py-2.5 rounded-xl bg-white/5 border border-white/[0.08] text-sm text-white focus:outline-none focus:border-cyan-500/50 transition-colors"
>
{WIDGETS.map((w) => (
<option key={w} value={w} className="bg-slate-900">{w}</option>
))}
</select>
</FormField>
</div>
<div className="flex items-center gap-2 pt-1">
<button
onClick={handleAdd}
disabled={!newTopic.trim()}
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-cyan-500/20 border border-cyan-500/30 text-xs font-medium text-cyan-400 hover:bg-cyan-500/30 transition-colors disabled:opacity-40"
>
<Check className="w-3.5 h-3.5" />
Hinzufügen
</button>
<button
onClick={resetNewForm}
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-white/5 border border-white/[0.08] text-xs text-slate-400 hover:text-white transition-colors"
>
<X className="w-3.5 h-3.5" />
Abbrechen
</button>
</div>
</div>
) : (
<button
onClick={() => setAdding(true)}
className="flex items-center gap-2 w-full p-3 rounded-xl border border-dashed border-white/[0.1] text-xs text-slate-400 hover:text-white hover:border-white/[0.2] hover:bg-white/[0.02] transition-all"
>
<Plus className="w-4 h-4" />
Topic-Subscription hinzufügen
</button>
)}
{error && (
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/20">
<p className="text-xs text-red-300">{error}</p>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,116 @@
import { useEffect, useState } from "react";
import { Newspaper, Loader2 } from "lucide-react";
import PageHeader from "../components/PageHeader";
import FormField, { NumberInput, TextInput } from "../components/FormField";
import IntegrationForm from "../components/IntegrationForm";
import { getIntegration, type Integration } from "../api";
export default function NewsSettings() {
const [integration, setIntegration] = useState<Integration | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
loadIntegration();
}, []);
const loadIntegration = async () => {
try {
const data = await getIntegration("news");
setIntegration(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
</div>
);
}
if (error || !integration) {
return (
<div>
<PageHeader icon={Newspaper} title="News" description="News-Datenbank konfigurieren" />
<div className="glass-card p-6">
<p className="text-sm text-red-400">{error || "Integration nicht gefunden"}</p>
</div>
</div>
);
}
return (
<div>
<PageHeader
icon={Newspaper}
title="News"
description="News-Datenquelle und Anzeige konfigurieren"
/>
<div className="glass-card p-6">
<IntegrationForm integration={integration} onSaved={setIntegration}>
{(config, setConfig) => (
<>
<FormField label="Datenbank Host" description="PostgreSQL Host für die News-Datenbank">
<TextInput
value={(config.db_host as string) || ""}
onChange={(v) => setConfig("db_host", v)}
placeholder="z.B. 10.10.10.100"
/>
</FormField>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField label="Datenbank Port">
<NumberInput
value={Number(config.db_port) || 5432}
onChange={(v) => setConfig("db_port", v)}
min={1}
max={65535}
/>
</FormField>
<FormField label="Datenbank Name">
<TextInput
value={(config.db_name as string) || ""}
onChange={(v) => setConfig("db_name", v)}
placeholder="market_news"
/>
</FormField>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField label="Datenbank User">
<TextInput
value={(config.db_user as string) || ""}
onChange={(v) => setConfig("db_user", v)}
placeholder="postgres"
/>
</FormField>
<FormField label="Datenbank Passwort">
<TextInput
value={(config.db_password as string) || ""}
onChange={(v) => setConfig("db_password", v)}
type="password"
/>
</FormField>
</div>
<FormField label="Max. Alter (Stunden)" description="Nur Artikel anzeigen die jünger sind als dieser Wert">
<NumberInput
value={Number(config.max_age_hours) || 72}
onChange={(v) => setConfig("max_age_hours", v)}
min={1}
max={720}
/>
</FormField>
</>
)}
</IntegrationForm>
</div>
</div>
);
}

View file

@ -0,0 +1,219 @@
import { useEffect, useState } from "react";
import { Server, Loader2, Plus, Trash2, Save, CheckCircle2 } from "lucide-react";
import PageHeader from "../components/PageHeader";
import FormField, { TextInput, NumberInput } from "../components/FormField";
import TestButton from "../components/TestButton";
import { getIntegration, updateIntegration, type Integration } from "../api";
interface ServerEntry {
name: string;
host: string;
api_key: string;
port: number;
}
export default function UnraidSettings() {
const [integration, setIntegration] = useState<Integration | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState("");
const [enabled, setEnabled] = useState(false);
const [servers, setServers] = useState<ServerEntry[]>([]);
useEffect(() => {
loadIntegration();
}, []);
const loadIntegration = async () => {
try {
const data = await getIntegration("unraid");
setIntegration(data);
setEnabled(data.enabled);
const cfg = data.config || {};
const rawServers = cfg.servers as ServerEntry[] | undefined;
if (Array.isArray(rawServers) && rawServers.length > 0) {
setServers(rawServers);
} else {
// Legacy single-server or empty
setServers([]);
}
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
const addServer = () => {
setServers((prev) => [...prev, { name: "", host: "", api_key: "", port: 80 }]);
setSaved(false);
};
const removeServer = (index: number) => {
setServers((prev) => prev.filter((_, i) => i !== index));
setSaved(false);
};
const updateServer = (index: number, field: keyof ServerEntry, value: string | number) => {
setServers((prev) => prev.map((s, i) => (i === index ? { ...s, [field]: value } : s)));
setSaved(false);
};
const handleSave = async () => {
if (!integration) return;
setSaving(true);
setError("");
setSaved(false);
try {
const updated = await updateIntegration(integration.type, {
enabled,
config: { servers },
});
setIntegration(updated);
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
</div>
);
}
if (error && !integration) {
return (
<div>
<PageHeader icon={Server} title="Unraid Server" description="Unraid-Server verwalten" />
<div className="glass-card p-6">
<p className="text-sm text-red-400">{error}</p>
</div>
</div>
);
}
return (
<div>
<PageHeader
icon={Server}
title="Unraid Server"
description="Unraid-Server hinzufügen, bearbeiten oder entfernen"
/>
<div className="glass-card p-6 space-y-6">
{/* Enable toggle */}
<div className="flex items-center justify-between p-4 rounded-xl bg-white/[0.02] border border-white/[0.06]">
<div>
<p className="text-sm font-medium text-white">Unraid Integration</p>
<p className="text-xs text-slate-500 mt-0.5">{enabled ? "Aktiv" : "Deaktiviert"}</p>
</div>
<button
type="button"
onClick={() => { setEnabled(!enabled); setSaved(false); }}
className={`relative w-11 h-6 rounded-full transition-colors ${
enabled ? "bg-cyan-500" : "bg-slate-700"
}`}
>
<span className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${
enabled ? "translate-x-5" : "translate-x-0"
}`} />
</button>
</div>
<div className={`space-y-4 transition-opacity ${enabled ? "opacity-100" : "opacity-40 pointer-events-none"}`}>
{/* Server list */}
{servers.map((srv, idx) => (
<div key={idx} className="p-4 rounded-xl bg-white/[0.02] border border-white/[0.06] space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-300">Server #{idx + 1}</span>
<button
onClick={() => removeServer(idx)}
className="p-1.5 rounded-lg text-slate-500 hover:text-red-400 hover:bg-red-500/10 transition-colors"
title="Server entfernen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField label="Name">
<TextInput
value={srv.name}
onChange={(v) => updateServer(idx, "name", v)}
placeholder="z.B. Main Server"
/>
</FormField>
<FormField label="Host">
<TextInput
value={srv.host}
onChange={(v) => updateServer(idx, "host", v)}
placeholder="z.B. 10.10.10.100"
/>
</FormField>
<FormField label="API Key">
<TextInput
value={srv.api_key}
onChange={(v) => updateServer(idx, "api_key", v)}
type="password"
/>
</FormField>
<FormField label="Port">
<NumberInput
value={srv.port}
onChange={(v) => updateServer(idx, "port", v)}
min={1}
max={65535}
/>
</FormField>
</div>
</div>
))}
{/* Add server button */}
<button
onClick={addServer}
className="flex items-center gap-2 w-full p-3 rounded-xl border border-dashed border-white/[0.1] text-xs text-slate-400 hover:text-white hover:border-white/[0.2] hover:bg-white/[0.02] transition-all"
>
<Plus className="w-4 h-4" />
Server hinzufügen
</button>
</div>
{error && (
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/20">
<p className="text-xs text-red-300">{error}</p>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-3 pt-2">
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-500 text-sm font-semibold text-white hover:from-cyan-400 hover:to-blue-400 transition-all disabled:opacity-40"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : saved ? (
<CheckCircle2 className="w-4 h-4" />
) : (
<Save className="w-4 h-4" />
)}
{saving ? "Speichern..." : saved ? "Gespeichert!" : "Speichern"}
</button>
{integration && <TestButton type={integration.type} disabled={!enabled} />}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,103 @@
import { useEffect, useState } from "react";
import { ListTodo, Loader2 } from "lucide-react";
import PageHeader from "../components/PageHeader";
import FormField, { TextInput } from "../components/FormField";
import IntegrationForm from "../components/IntegrationForm";
import { getIntegration, type Integration } from "../api";
export default function VikunjaSettings() {
const [integration, setIntegration] = useState<Integration | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
loadIntegration();
}, []);
const loadIntegration = async () => {
try {
const data = await getIntegration("vikunja");
setIntegration(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
</div>
);
}
if (error || !integration) {
return (
<div>
<PageHeader icon={ListTodo} title="Vikunja" description="Vikunja Task-Manager konfigurieren" />
<div className="glass-card p-6">
<p className="text-sm text-red-400">{error || "Integration nicht gefunden"}</p>
</div>
</div>
);
}
return (
<div>
<PageHeader
icon={ListTodo}
title="Vikunja"
description="Vikunja-Instanz und Projekte für die Task-Anzeige konfigurieren"
/>
<div className="glass-card p-6">
<IntegrationForm integration={integration} onSaved={setIntegration}>
{(config, setConfig) => (
<>
<FormField label="Vikunja URL" description="Basis-URL deiner Vikunja-Instanz">
<TextInput
value={(config.url as string) || ""}
onChange={(v) => setConfig("url", v)}
placeholder="http://10.10.10.50:3456"
/>
</FormField>
<FormField label="API Token" description="Erstelle einen Token in Vikunja unter Einstellungen → API Tokens">
<TextInput
value={(config.token as string) || ""}
onChange={(v) => setConfig("token", v)}
type="password"
placeholder="tk_..."
/>
</FormField>
<FormField
label="Private Projekt-IDs"
description="Komma-getrennte Liste von Projekt-IDs für den privaten Bereich"
>
<TextInput
value={(config.private_projects as string) || ""}
onChange={(v) => setConfig("private_projects", v)}
placeholder="z.B. 1,5,12"
/>
</FormField>
<FormField
label="Sam's Projekt-IDs"
description="Komma-getrennte Liste von Projekt-IDs für den gemeinsamen Bereich"
>
<TextInput
value={(config.sams_projects as string) || ""}
onChange={(v) => setConfig("sams_projects", v)}
placeholder="z.B. 3,8"
/>
</FormField>
</>
)}
</IntegrationForm>
</div>
</div>
);
}

View file

@ -0,0 +1,80 @@
import { useEffect, useState } from "react";
import { Cloud, Loader2 } from "lucide-react";
import PageHeader from "../components/PageHeader";
import FormField, { TextInput } from "../components/FormField";
import IntegrationForm from "../components/IntegrationForm";
import { getIntegration, type Integration } from "../api";
export default function WeatherSettings() {
const [integration, setIntegration] = useState<Integration | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
loadIntegration();
}, []);
const loadIntegration = async () => {
try {
const data = await getIntegration("weather");
setIntegration(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
</div>
);
}
if (error || !integration) {
return (
<div>
<PageHeader icon={Cloud} title="Wetter" description="Wetteranbieter konfigurieren" />
<div className="glass-card p-6">
<p className="text-sm text-red-400">{error || "Integration nicht gefunden"}</p>
</div>
</div>
);
}
return (
<div>
<PageHeader
icon={Cloud}
title="Wetter"
description="Standorte für die Wettervorhersage konfigurieren (wttr.in)"
/>
<div className="glass-card p-6">
<IntegrationForm integration={integration} onSaved={setIntegration}>
{(config, setConfig) => (
<>
<FormField label="Primärer Standort" description="Stadt oder Koordinaten für die Hauptanzeige">
<TextInput
value={(config.primary_location as string) || ""}
onChange={(v) => setConfig("primary_location", v)}
placeholder="z.B. Berlin oder 52.52,13.405"
/>
</FormField>
<FormField label="Sekundärer Standort" description="Zweiter Standort für den Vergleich">
<TextInput
value={(config.secondary_location as string) || ""}
onChange={(v) => setConfig("secondary_location", v)}
placeholder="z.B. München oder 48.137,11.576"
/>
</FormField>
</>
)}
</IntegrationForm>
</div>
</div>
);
}

View file

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

149
web/src/pages/Dashboard.tsx Normal file
View file

@ -0,0 +1,149 @@
import { Link } from "react-router-dom";
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 MqttCard from "../components/MqttCard";
import { RefreshCw, Wifi, WifiOff, AlertTriangle, Settings } from "lucide-react";
export default function Dashboard() {
const { data, loading, error, connected, refresh } = useDashboard();
return (
<div className="min-h-screen text-white">
{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 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">
<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>
<div className="flex items-center gap-3">
<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>
<Link
to="/admin"
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"
title="Admin Panel"
>
<Settings className="w-3.5 h-3.5 text-slate-400" />
</Link>
<Clock />
</div>
</div>
</header>
<main className="max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
{loading && !data ? (
<LoadingSkeleton />
) : data ? (
<>
<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>
<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>
{(data.mqtt?.connected || (data.mqtt?.entities?.length ?? 0) > 0) && (
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<div className="md:col-span-2 xl:col-span-4">
<MqttCard data={data.mqtt} />
</div>
</section>
)}
<section>
<NewsGrid data={data.news} />
</section>
<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>
);
}
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>
);
}
function LoadingSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<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>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} className="h-72" />)}
</div>
<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>
);
}