From f6a42c2dd2f470c5937f8c117cd61304b4cd1151 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 2 Mar 2026 10:37:30 +0100 Subject: [PATCH] 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 --- docker-compose.yml | 8 +- requirements.txt | 2 + server/auth.py | 72 ++++ server/config.py | 142 +++++++- server/db.py | 52 +++ server/main.py | 94 +++-- server/migrations/001_admin_schema.sql | 53 +++ server/migrations/__init__.py | 0 server/migrations/runner.py | 58 ++++ server/routers/admin.py | 186 ++++++++++ server/routers/auth.py | 79 +++++ server/routers/homeassistant.py | 8 +- server/routers/news.py | 8 +- server/routers/servers.py | 6 +- server/routers/tasks.py | 8 +- server/routers/weather.py | 10 +- server/services/news_service.py | 84 +---- server/services/seed_service.py | 150 ++++++++ server/services/settings_service.py | 297 ++++++++++++++++ server/services/test_connections.py | 147 ++++++++ web/package-lock.json | 61 +++- web/package.json | 3 +- web/src/App.tsx | 205 ++--------- web/src/admin/AdminLayout.tsx | 147 ++++++++ web/src/admin/LoginPage.tsx | 113 ++++++ web/src/admin/api.ts | 218 ++++++++++++ web/src/admin/components/FormField.tsx | 63 ++++ web/src/admin/components/IntegrationForm.tsx | 98 ++++++ web/src/admin/components/PageHeader.tsx | 21 ++ web/src/admin/components/TestButton.tsx | 59 ++++ web/src/admin/pages/ChangePassword.tsx | 114 ++++++ web/src/admin/pages/GeneralSettings.tsx | 136 ++++++++ web/src/admin/pages/HASettings.tsx | 81 +++++ web/src/admin/pages/MqttSettings.tsx | 343 +++++++++++++++++++ web/src/admin/pages/NewsSettings.tsx | 116 +++++++ web/src/admin/pages/UnraidSettings.tsx | 219 ++++++++++++ web/src/admin/pages/VikunjaSettings.tsx | 103 ++++++ web/src/admin/pages/WeatherSettings.tsx | 80 +++++ web/src/main.tsx | 5 +- web/src/pages/Dashboard.tsx | 149 ++++++++ 40 files changed, 3487 insertions(+), 311 deletions(-) create mode 100644 server/auth.py create mode 100644 server/db.py create mode 100644 server/migrations/001_admin_schema.sql create mode 100644 server/migrations/__init__.py create mode 100644 server/migrations/runner.py create mode 100644 server/routers/admin.py create mode 100644 server/routers/auth.py create mode 100644 server/services/seed_service.py create mode 100644 server/services/settings_service.py create mode 100644 server/services/test_connections.py create mode 100644 web/src/admin/AdminLayout.tsx create mode 100644 web/src/admin/LoginPage.tsx create mode 100644 web/src/admin/api.ts create mode 100644 web/src/admin/components/FormField.tsx create mode 100644 web/src/admin/components/IntegrationForm.tsx create mode 100644 web/src/admin/components/PageHeader.tsx create mode 100644 web/src/admin/components/TestButton.tsx create mode 100644 web/src/admin/pages/ChangePassword.tsx create mode 100644 web/src/admin/pages/GeneralSettings.tsx create mode 100644 web/src/admin/pages/HASettings.tsx create mode 100644 web/src/admin/pages/MqttSettings.tsx create mode 100644 web/src/admin/pages/NewsSettings.tsx create mode 100644 web/src/admin/pages/UnraidSettings.tsx create mode 100644 web/src/admin/pages/VikunjaSettings.tsx create mode 100644 web/src/admin/pages/WeatherSettings.tsx create mode 100644 web/src/pages/Dashboard.tsx diff --git a/docker-compose.yml b/docker-compose.yml index 93b3b55..f13bab8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,12 +5,18 @@ services: ports: - "8080:8080" environment: - # Database (PostgreSQL) + # ── Required: Database (PostgreSQL) ── - DB_HOST=10.10.10.10 - DB_PORT=5433 - DB_NAME=openclaw - DB_USER=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_LOCATION=Leverkusen - WEATHER_LOCATION_SECONDARY=Rab,Croatia diff --git a/requirements.txt b/requirements.txt index cb95e39..ddac990 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,5 @@ asyncpg==0.30.0 jinja2==3.1.5 websockets==14.2 aiomqtt==2.3.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 diff --git a/server/auth.py b/server/auth.py new file mode 100644 index 0000000..93afc13 --- /dev/null +++ b/server/auth.py @@ -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"}, + ) diff --git a/server/config.py b/server/config.py index 8a5a46d..b2cdd74 100644 --- a/server/config.py +++ b/server/config.py @@ -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 import json +import logging import os from dataclasses import dataclass, field -from typing import List +from typing import Any, Dict, List + +logger = logging.getLogger(__name__) @dataclass @@ -18,7 +26,7 @@ class UnraidServer: @dataclass class Settings: - # --- Database (PostgreSQL) --- + # --- Bootstrap (always from ENV) --- db_host: str = "10.10.10.10" db_port: int = 5433 db_name: str = "openclaw" @@ -28,25 +36,31 @@ class Settings: # --- Weather --- weather_location: str = "Leverkusen" weather_location_secondary: str = "Rab,Croatia" - weather_cache_ttl: int = 1800 # 30 min + weather_cache_ttl: int = 1800 # --- Home Assistant --- - ha_url: str = "https://homeassistant.daddelolymp.de" + ha_url: str = "" ha_token: str = "" ha_cache_ttl: int = 30 + ha_enabled: bool = False # --- Vikunja Tasks --- - vikunja_url: str = "http://10.10.10.10:3456/api/v1" + vikunja_url: str = "" vikunja_token: str = "" 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: List[UnraidServer] = field(default_factory=list) unraid_cache_ttl: int = 15 + unraid_enabled: bool = False # --- News --- - news_cache_ttl: int = 300 # 5 min + news_cache_ttl: int = 300 news_max_age_hours: int = 48 + news_enabled: bool = True # --- MQTT --- mqtt_host: str = "" @@ -55,39 +69,44 @@ class Settings: mqtt_password: str = "" mqtt_topics: List[str] = field(default_factory=lambda: ["#"]) mqtt_client_id: str = "daily-briefing" + mqtt_enabled: bool = False # --- Server --- host: str = "0.0.0.0" port: int = 8080 debug: bool = False + # --- WebSocket --- + ws_interval: int = 15 + @classmethod def from_env(cls) -> "Settings": + """Load bootstrap config from environment variables.""" s = cls() s.db_host = os.getenv("DB_HOST", s.db_host) s.db_port = int(os.getenv("DB_PORT", str(s.db_port))) s.db_name = os.getenv("DB_NAME", s.db_name) s.db_user = os.getenv("DB_USER", s.db_user) s.db_password = os.getenv("DB_PASSWORD", s.db_password) + s.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_secondary = os.getenv( - "WEATHER_LOCATION_SECONDARY", s.weather_location_secondary - ) - + s.weather_location_secondary = os.getenv("WEATHER_LOCATION_SECONDARY", s.weather_location_secondary) s.ha_url = os.getenv("HA_URL", s.ha_url) s.ha_token = os.getenv("HA_TOKEN", s.ha_token) - + s.ha_enabled = bool(s.ha_url) s.vikunja_url = os.getenv("VIKUNJA_URL", s.vikunja_url) 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_port = int(os.getenv("MQTT_PORT", str(s.mqtt_port))) s.mqtt_username = os.getenv("MQTT_USERNAME", s.mqtt_username) 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_enabled = bool(s.mqtt_host) - # Parse MQTT_TOPICS (comma-separated or JSON array) + # Parse MQTT_TOPICS raw_topics = os.getenv("MQTT_TOPICS", "") if raw_topics: try: @@ -95,8 +114,6 @@ class Settings: except (json.JSONDecodeError, TypeError): 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 raw = os.getenv("UNRAID_SERVERS", "[]") try: @@ -111,10 +128,103 @@ class Settings: for i, srv in enumerate(servers_data) if srv.get("host") ] + s.unraid_enabled = len(s.unraid_servers) > 0 except (json.JSONDecodeError, TypeError): s.unraid_servers = [] 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() + + +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") diff --git a/server/db.py b/server/db.py new file mode 100644 index 0000000..b9c6f12 --- /dev/null +++ b/server/db.py @@ -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") diff --git a/server/main.py b/server/main.py index 457c332..b7bb864 100644 --- a/server/main.py +++ b/server/main.py @@ -8,10 +8,10 @@ from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles -from server.config import settings -from server.services import news_service +from server.config import get_settings, reload_settings, settings from server.services.mqtt_service import mqtt_service logger = logging.getLogger("daily-briefing") @@ -24,15 +24,15 @@ logging.basicConfig( @asynccontextmanager async def lifespan(app: FastAPI): """Startup / shutdown lifecycle.""" - logger.info("Starting Daily Briefing Dashboard...") - logger.info( - "Unraid servers configured: %d", - len(settings.unraid_servers), - ) + from server import db + from server.migrations.runner import run_migrations + from server.services.seed_service import seed_if_empty - # Initialize database pool + logger.info("Starting Daily Briefing Dashboard v2.1...") + + # 1. Initialize shared database pool (bootstrap from ENV) try: - await news_service.init_pool( + pool = await db.init_pool( host=settings.db_host, port=settings.db_port, dbname=settings.db_name, @@ -41,36 +41,64 @@ async def lifespan(app: FastAPI): ) logger.info("Database pool initialized") 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 - if settings.mqtt_host: + # 2. Run database migrations + 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: await mqtt_service.start( - host=settings.mqtt_host, - port=settings.mqtt_port, - username=settings.mqtt_username or None, - password=settings.mqtt_password or None, - topics=settings.mqtt_topics, - client_id=settings.mqtt_client_id, + host=cfg.mqtt_host, + port=cfg.mqtt_port, + username=cfg.mqtt_username or None, + password=cfg.mqtt_password or None, + topics=cfg.mqtt_topics, + 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: - logger.exception("Failed to start MQTT service — MQTT will be unavailable") + logger.exception("Failed to start MQTT service") else: - logger.info("MQTT disabled — set MQTT_HOST to enable") + logger.info("MQTT disabled — configure via Admin Panel or MQTT_HOST env") yield # Shutdown logger.info("Shutting down...") await mqtt_service.stop() - await news_service.close_pool() + await db.close_pool() app = FastAPI( title="Daily Briefing", - version="2.0.0", + version="2.1.0", lifespan=lifespan, ) @@ -84,8 +112,10 @@ app.add_middleware( ) # --- 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(news.router) app.include_router(servers.router) @@ -97,6 +127,14 @@ app.include_router(dashboard.router) # --- Serve static frontend (production) --- static_dir = Path(__file__).parent.parent / "static" 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") logger.info("Serving static frontend from %s", static_dir) else: @@ -104,6 +142,10 @@ else: async def root(): return { "status": "ok", - "message": "Daily Briefing API — Frontend not built yet", - "endpoints": ["/api/all", "/api/weather", "/api/news", "/api/servers", "/api/ha", "/api/tasks"], + "message": "Daily Briefing API v2.1 — Frontend not built yet", + "endpoints": [ + "/api/all", "/api/weather", "/api/news", "/api/servers", + "/api/ha", "/api/tasks", "/api/mqtt", + "/api/auth/login", "/api/admin/integrations", + ], } diff --git a/server/migrations/001_admin_schema.sql b/server/migrations/001_admin_schema.sql new file mode 100644 index 0000000..7f3b7b9 --- /dev/null +++ b/server/migrations/001_admin_schema.sql @@ -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; diff --git a/server/migrations/__init__.py b/server/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/migrations/runner.py b/server/migrations/runner.py new file mode 100644 index 0000000..3389f57 --- /dev/null +++ b/server/migrations/runner.py @@ -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) diff --git a/server/routers/admin.py b/server/routers/admin.py new file mode 100644 index 0000000..ee8975a --- /dev/null +++ b/server/routers/admin.py @@ -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") diff --git a/server/routers/auth.py b/server/routers/auth.py new file mode 100644 index 0000000..3da322c --- /dev/null +++ b/server/routers/auth.py @@ -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"} diff --git a/server/routers/homeassistant.py b/server/routers/homeassistant.py index 6e8f750..f95e44a 100644 --- a/server/routers/homeassistant.py +++ b/server/routers/homeassistant.py @@ -8,7 +8,7 @@ from typing import Any, Dict from fastapi import APIRouter 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 logger = logging.getLogger(__name__) @@ -36,12 +36,12 @@ async def get_ha() -> Dict[str, Any]: # --- cache miss ----------------------------------------------------------- try: data: Dict[str, Any] = await fetch_ha_data( - settings.ha_url, - settings.ha_token, + get_settings().ha_url, + get_settings().ha_token, ) except Exception as exc: logger.exception("Failed to fetch Home Assistant data") return {"error": True, "message": str(exc)} - await cache.set(CACHE_KEY, data, settings.ha_cache_ttl) + await cache.set(CACHE_KEY, data, get_settings().ha_cache_ttl) return data diff --git a/server/routers/news.py b/server/routers/news.py index 46c47fe..bbfbd05 100644 --- a/server/routers/news.py +++ b/server/routers/news.py @@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional from fastapi import APIRouter, Query 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 logger = logging.getLogger(__name__) @@ -50,7 +50,7 @@ async def get_news_articles( total: int = 0 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: logger.exception("Failed to fetch news articles") return { @@ -63,7 +63,7 @@ async def get_news_articles( } 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: logger.exception("Failed to fetch news count") # We still have articles -- return them with total = len(articles) @@ -76,5 +76,5 @@ async def get_news_articles( "offset": offset, } - await cache.set(key, payload, settings.news_cache_ttl) + await cache.set(key, payload, get_settings().news_cache_ttl) return payload diff --git a/server/routers/servers.py b/server/routers/servers.py index d1f0caa..4720f6b 100644 --- a/server/routers/servers.py +++ b/server/routers/servers.py @@ -8,7 +8,7 @@ from typing import Any, Dict, List from fastapi import APIRouter 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 logger = logging.getLogger(__name__) @@ -42,7 +42,7 @@ async def get_servers() -> Dict[str, Any]: api_key=srv.api_key, port=srv.port, ) - for srv in settings.unraid_servers + for srv in get_settings().unraid_servers ] servers_data: List[Dict[str, Any]] = [] @@ -60,5 +60,5 @@ async def get_servers() -> Dict[str, Any]: "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 diff --git a/server/routers/tasks.py b/server/routers/tasks.py index 366ae40..988762b 100644 --- a/server/routers/tasks.py +++ b/server/routers/tasks.py @@ -8,7 +8,7 @@ from typing import Any, Dict from fastapi import APIRouter from server.cache import cache -from server.config import settings +from server.config import get_settings from server.services.vikunja_service import fetch_tasks logger = logging.getLogger(__name__) @@ -36,12 +36,12 @@ async def get_tasks() -> Dict[str, Any]: # --- cache miss ----------------------------------------------------------- try: data: Dict[str, Any] = await fetch_tasks( - settings.vikunja_url, - settings.vikunja_token, + get_settings().vikunja_url, + get_settings().vikunja_token, ) except Exception as exc: logger.exception("Failed to fetch Vikunja tasks") return {"error": True, "message": str(exc)} - await cache.set(CACHE_KEY, data, settings.vikunja_cache_ttl) + await cache.set(CACHE_KEY, data, get_settings().vikunja_cache_ttl) return data diff --git a/server/routers/weather.py b/server/routers/weather.py index 24d742a..dfb09ed 100644 --- a/server/routers/weather.py +++ b/server/routers/weather.py @@ -9,7 +9,7 @@ from typing import Any, Dict, List from fastapi import APIRouter 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 logger = logging.getLogger(__name__) @@ -43,9 +43,9 @@ async def get_weather() -> Dict[str, Any]: hourly_data: List[Dict[str, Any]] = [] results = await asyncio.gather( - _safe_fetch_weather(settings.weather_location), - _safe_fetch_weather(settings.weather_location_secondary), - _safe_fetch_hourly(settings.weather_location), + _safe_fetch_weather(get_settings().weather_location), + _safe_fetch_weather(get_settings().weather_location_secondary), + _safe_fetch_hourly(get_settings().weather_location), return_exceptions=False, # we handle errors inside the helpers ) @@ -59,7 +59,7 @@ async def get_weather() -> Dict[str, Any]: "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 diff --git a/server/services/news_service.py b/server/services/news_service.py index 7ec4f6f..54cb216 100644 --- a/server/services/news_service.py +++ b/server/services/news_service.py @@ -1,43 +1,11 @@ +"""News service — queries market_news from PostgreSQL via shared pool.""" + from __future__ import annotations import asyncpg from typing import Any, Dict, List, Optional -_pool: Optional[asyncpg.Pool] = None - - -async def init_pool( - host: str, - port: int, - dbname: str, - user: str, - password: str, -) -> None: - """Initialise the global asyncpg connection pool. - - Call once at application startup. - """ - global _pool - _pool = await asyncpg.create_pool( - host=host, - port=port, - database=dbname, - user=user, - password=password, - min_size=1, - max_size=5, - ) - - -async def close_pool() -> None: - """Close the global asyncpg connection pool. - - Call once at application shutdown. - """ - global _pool - if _pool is not None: - await _pool.close() - _pool = None +from server.db import get_pool def _row_to_dict(row: asyncpg.Record) -> Dict[str, Any]: @@ -54,19 +22,8 @@ async def get_news( category: Optional[str] = None, max_age_hours: int = 48, ) -> List[Dict[str, Any]]: - """Fetch recent news articles from the market_news table. - - Args: - limit: Maximum number of rows to return. - offset: Number of rows to skip (for pagination). - category: Optional category filter (exact match). - max_age_hours: Only return articles published within this many hours. - - Returns: - List of news article dictionaries. - """ - if _pool is None: - raise RuntimeError("Database pool is not initialised. Call init_pool() first.") + """Fetch recent news articles from the market_news table.""" + pool = await get_pool() params: List[Any] = [] param_idx = 1 @@ -86,7 +43,7 @@ async def get_news( params.append(limit) params.append(offset) - async with _pool.acquire() as conn: + async with pool.acquire() as conn: rows = await conn.fetch(base_query, *params) return [_row_to_dict(row) for row in rows] @@ -96,17 +53,8 @@ async def get_news_count( max_age_hours: int = 48, category: Optional[str] = None, ) -> int: - """Return the total count of recent news articles. - - Args: - max_age_hours: Only count articles published within this many hours. - category: Optional category filter. - - Returns: - Integer count. - """ - if _pool is None: - raise RuntimeError("Database pool is not initialised. Call init_pool() first.") + """Return the total count of recent news articles.""" + pool = await get_pool() params: List[Any] = [] param_idx = 1 @@ -121,23 +69,15 @@ async def get_news_count( query += f" AND category = ${param_idx}" params.append(category) - async with _pool.acquire() as conn: + async with pool.acquire() as conn: row = await conn.fetchrow(query, *params) return int(row["cnt"]) if row else 0 async def get_categories(max_age_hours: int = 48) -> List[str]: - """Return distinct categories from recent news articles. - - Args: - max_age_hours: Only consider articles published within this many hours. - - Returns: - Sorted list of category strings. - """ - if _pool is None: - raise RuntimeError("Database pool is not initialised. Call init_pool() first.") + """Return distinct categories from recent news articles.""" + pool = await get_pool() query = ( "SELECT DISTINCT category " @@ -147,7 +87,7 @@ async def get_categories(max_age_hours: int = 48) -> List[str]: "ORDER BY category" ) - async with _pool.acquire() as conn: + async with pool.acquire() as conn: rows = await conn.fetch(query) return [row["category"] for row in rows] diff --git a/server/services/seed_service.py b/server/services/seed_service.py new file mode 100644 index 0000000..6cee3ba --- /dev/null +++ b/server/services/seed_service.py @@ -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()] diff --git a/server/services/settings_service.py b/server/services/settings_service.py new file mode 100644 index 0000000..9ec5033 --- /dev/null +++ b/server/services/settings_service.py @@ -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 diff --git a/server/services/test_connections.py b/server/services/test_connections.py new file mode 100644 index 0000000..804038f --- /dev/null +++ b/server/services/test_connections.py @@ -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, +} diff --git a/web/package-lock.json b/web/package-lock.json index 9138684..3923011 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "lucide-react": "^0.468.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^7.13.1" }, "devDependencies": { "@types/react": "^18.3.18", @@ -1533,6 +1534,19 @@ "dev": true, "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": { "version": "3.0.0", "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", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -2295,6 +2310,44 @@ "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -2438,6 +2491,12 @@ "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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/web/package.json b/web/package.json index f09a1ca..73b6dc5 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,8 @@ "dependencies": { "lucide-react": "^0.468.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^7.13.1" }, "devDependencies": { "@types/react": "^18.3.18", diff --git a/web/src/App.tsx b/web/src/App.tsx index 2576538..600ba83 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,181 +1,40 @@ -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 } from "lucide-react"; +import { Routes, Route, Navigate } from "react-router-dom"; +import Dashboard from "./pages/Dashboard"; +import AdminLayout from "./admin/AdminLayout"; +import LoginPage from "./admin/LoginPage"; +import GeneralSettings from "./admin/pages/GeneralSettings"; +import WeatherSettings from "./admin/pages/WeatherSettings"; +import NewsSettings from "./admin/pages/NewsSettings"; +import HASettings from "./admin/pages/HASettings"; +import VikunjaSettings from "./admin/pages/VikunjaSettings"; +import UnraidSettings from "./admin/pages/UnraidSettings"; +import MqttSettings from "./admin/pages/MqttSettings"; +import ChangePassword from "./admin/pages/ChangePassword"; export default function App() { - const { data, loading, error, connected, refresh } = useDashboard(); - return ( -
- {/* ---- Error banner ---- */} - {error && ( -
- -

{error}

- -
- )} + + {/* Dashboard */} + } /> - {/* ---- Header bar ---- */} -
-
- {/* Left: title + live indicator */} -
-

- Daily Briefing -

- -
+ {/* Admin Login */} + } /> - {/* Right: clock + refresh */} -
- - -
-
-
+ {/* Admin Panel (protected) */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + - {/* ---- Main content ---- */} -
- {loading && !data ? ( - - ) : data ? ( - <> - {/* Row 1: Weather cards + Hourly forecast */} -
- - -
- -
-
- - {/* Row 2: Servers + Home Assistant + Tasks */} -
- {data.servers.servers.map((srv) => ( - - ))} - - -
- - {/* Row 2.5: MQTT (only show if connected or has entities) */} - {(data.mqtt?.connected || (data.mqtt?.entities?.length ?? 0) > 0) && ( -
-
- -
-
- )} - - {/* Row 3: News (full width) */} -
- -
- - {/* Footer timestamp */} -
-

- Letzte Aktualisierung:{" "} - {new Date(data.timestamp).toLocaleTimeString("de-DE", { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - })} -

-
- - ) : null} -
-
- ); -} - -/** Small pulsing dot indicating live WebSocket connection. */ -function LiveIndicator({ connected }: { connected: boolean }) { - return ( -
-
-
- {connected && ( -
- )} -
- - {connected ? "Live" : "Offline"} - - {connected ? ( - - ) : ( - - )} -
- ); -} - -/** Skeleton loading state displayed on first load. */ -function LoadingSkeleton() { - return ( -
- {/* Row 1: Weather placeholders */} -
- - - -
- - {/* Row 2: Info cards */} -
- - - - -
- - {/* Row 3: News */} -
-
-
- {Array.from({ length: 8 }).map((_, i) => ( - - ))} -
-
-
- ); -} - -function SkeletonCard({ className = "" }: { className?: string }) { - return ( -
-
-
-
-
-
-
+ {/* Catch all */} + } /> + ); } diff --git a/web/src/admin/AdminLayout.tsx b/web/src/admin/AdminLayout.tsx new file mode 100644 index 0000000..54d7247 --- /dev/null +++ b/web/src/admin/AdminLayout.tsx @@ -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 ( +
+ +
+ ); + } + + if (!verified) return null; + + return ( +
+ {/* Mobile header */} +
+
+ + Admin Panel + + + +
+
+ +
+ {/* Sidebar */} + + + {/* Overlay for mobile sidebar */} + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + + {/* Main content */} +
+
+ +
+
+
+
+ ); +} diff --git a/web/src/admin/LoginPage.tsx b/web/src/admin/LoginPage.tsx new file mode 100644 index 0000000..808891d --- /dev/null +++ b/web/src/admin/LoginPage.tsx @@ -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 ( +
+
+ {/* Logo / Title */} +
+
+ +
+

Daily Briefing

+

Admin Panel

+
+ + {/* Login Card */} +
+
+ {error && ( +
+ +

{error}

+
+ )} + +
+ +
+ + 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" + /> +
+
+ +
+ +
+ + 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" + /> +
+
+ + +
+
+ +

+ Daily Briefing v2.1 — Admin +

+
+
+ ); +} diff --git a/web/src/admin/api.ts b/web/src/admin/api.ts new file mode 100644 index 0000000..fb0b5b7 --- /dev/null +++ b/web/src/admin/api.ts @@ -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 { + const token = getToken(); + const headers: Record = { + "Content-Type": "application/json", + ...(options.headers as Record || {}), + }; + 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(path: string): Promise { + 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(path: string, data: unknown): Promise { + 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(path: string, data?: unknown): Promise { + 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(path: string): Promise { + 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 { + try { + await fetchJSON("/auth/me"); + return true; + } catch { + return false; + } +} + +export async function changePassword(currentPassword: string, newPassword: string): Promise { + 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 { + return fetchJSON("/admin/settings"); +} + +export async function updateSettings(settings: Record): Promise { + await putJSON("/admin/settings", { settings }); +} + +// --------------------------------------------------------------------------- +// Integrations API +// --------------------------------------------------------------------------- + +export interface Integration { + id: number; + type: string; + name: string; + config: Record; + enabled: boolean; + display_order: number; +} + +export async function getIntegrations(): Promise { + return fetchJSON("/admin/integrations"); +} + +export async function getIntegration(type: string): Promise { + return fetchJSON(`/admin/integrations/${type}`); +} + +export async function updateIntegration( + type: string, + data: { name?: string; config?: Record; enabled?: boolean; display_order?: number } +): Promise { + 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 { + return fetchJSON("/admin/mqtt/subscriptions"); +} + +export async function createMqttSubscription(data: Omit): Promise { + return postJSON("/admin/mqtt/subscriptions", data); +} + +export async function updateMqttSubscription( + id: number, + data: Partial> +): Promise { + return putJSON(`/admin/mqtt/subscriptions/${id}`, data); +} + +export async function deleteMqttSubscription(id: number): Promise { + await deleteJSON(`/admin/mqtt/subscriptions/${id}`); +} diff --git a/web/src/admin/components/FormField.tsx b/web/src/admin/components/FormField.tsx new file mode 100644 index 0000000..9ab3fcc --- /dev/null +++ b/web/src/admin/components/FormField.tsx @@ -0,0 +1,63 @@ +interface Props { + label: string; + description?: string; + children: React.ReactNode; +} + +export default function FormField({ label, description, children }: Props) { + return ( +
+ + {description &&

{description}

} + {children} +
+ ); +} + +export function TextInput({ + value, + onChange, + placeholder, + type = "text", +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; + type?: string; +}) { + return ( + 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 ( + 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" + /> + ); +} diff --git a/web/src/admin/components/IntegrationForm.tsx b/web/src/admin/components/IntegrationForm.tsx new file mode 100644 index 0000000..fe3704d --- /dev/null +++ b/web/src/admin/components/IntegrationForm.tsx @@ -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, setConfig: (key: string, value: unknown) => void) => ReactNode; +} + +export default function IntegrationForm({ integration, onSaved, children }: Props) { + const [config, setConfigState] = useState>(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 ( +
+ {/* Enable toggle */} +
+
+

{integration.name}

+

Integration {enabled ? "aktiv" : "deaktiviert"}

+
+ +
+ + {/* Config fields (rendered by parent) */} +
+ {children(config, setConfig)} +
+ + {/* Error */} + {error && ( +
+

{error}

+
+ )} + + {/* Actions */} +
+ + + +
+
+ ); +} diff --git a/web/src/admin/components/PageHeader.tsx b/web/src/admin/components/PageHeader.tsx new file mode 100644 index 0000000..4c45fc1 --- /dev/null +++ b/web/src/admin/components/PageHeader.tsx @@ -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 ( +
+
+
+ +
+

{title}

+
+

{description}

+
+ ); +} diff --git a/web/src/admin/components/TestButton.tsx b/web/src/admin/components/TestButton.tsx new file mode 100644 index 0000000..132884e --- /dev/null +++ b/web/src/admin/components/TestButton.tsx @@ -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 ( +
+ + + {message && ( +

+ {message} +

+ )} +
+ ); +} diff --git a/web/src/admin/pages/ChangePassword.tsx b/web/src/admin/pages/ChangePassword.tsx new file mode 100644 index 0000000..c80317b --- /dev/null +++ b/web/src/admin/pages/ChangePassword.tsx @@ -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 ( +
+ + +
+
+ {saved && ( +
+ +

Passwort erfolgreich geändert!

+
+ )} + + {error && ( +
+ +

{error}

+
+ )} + + + + + + + + + + + + + + {newPw && confirmPw && newPw !== confirmPw && ( +

Passwörter stimmen nicht überein

+ )} + + {newPw && newPw.length > 0 && newPw.length < 6 && ( +

Mindestens 6 Zeichen erforderlich

+ )} + + +
+
+
+ ); +} diff --git a/web/src/admin/pages/GeneralSettings.tsx b/web/src/admin/pages/GeneralSettings.tsx new file mode 100644 index 0000000..475cfca --- /dev/null +++ b/web/src/admin/pages/GeneralSettings.tsx @@ -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(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 ( +
+ +
+ ); + } + + return ( +
+ + +
+

WebSocket Intervall

+ + + + +
+ +

Cache TTLs (Sekunden)

+

Wie lange gecachte Daten gültig sind bevor sie neu geladen werden

+ +
+ + + + + + + + + + + + + + + +
+ + {error && ( +
+

{error}

+
+ )} + + +
+
+ ); +} diff --git a/web/src/admin/pages/HASettings.tsx b/web/src/admin/pages/HASettings.tsx new file mode 100644 index 0000000..3b09906 --- /dev/null +++ b/web/src/admin/pages/HASettings.tsx @@ -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(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 ( +
+ +
+ ); + } + + if (error || !integration) { + return ( +
+ +
+

{error || "Integration nicht gefunden"}

+
+
+ ); + } + + return ( +
+ + +
+ + {(config, setConfig) => ( + <> + + setConfig("url", v)} + placeholder="http://10.10.10.50:8123" + /> + + + + setConfig("token", v)} + type="password" + placeholder="eyJhbGciOi..." + /> + + + )} + +
+
+ ); +} diff --git a/web/src/admin/pages/MqttSettings.tsx b/web/src/admin/pages/MqttSettings.tsx new file mode 100644 index 0000000..092aff6 --- /dev/null +++ b/web/src/admin/pages/MqttSettings.tsx @@ -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(null); + const [subscriptions, setSubscriptions] = useState([]); + 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 ( +
+ +
+ ); + } + + if (error && !integration) { + return ( +
+ +
+

{error}

+
+
+ ); + } + + return ( +
+ + + {/* Broker Config */} + {integration && ( +
+

Broker-Verbindung

+ + {(config, setConfig) => ( + <> +
+ + setConfig("host", v)} + placeholder="z.B. 10.10.10.50" + /> + + + setConfig("port", v)} + min={1} + max={65535} + /> + +
+ +
+ + setConfig("username", v)} + /> + + + setConfig("password", v)} + type="password" + /> + +
+ + + setConfig("client_id", v)} + placeholder="daily-briefing" + /> + + + )} +
+
+ )} + + {/* Subscriptions */} +
+ +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Subscription Manager sub-component +// --------------------------------------------------------------------------- + +function SubscriptionManager({ + subscriptions, + onUpdate, +}: { + subscriptions: MqttSubscription[]; + onUpdate: (subs: MqttSubscription[]) => void; +}) { + const [adding, setAdding] = useState(false); + const [editId, setEditId] = useState(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 ( +
+
+

Topic-Subscriptions

+ {subscriptions.length} Topics +
+ + {/* Existing subscriptions */} +
+ {subscriptions.map((sub) => ( +
+ + +
+

{sub.display_name || sub.topic_pattern}

+

{sub.topic_pattern}

+
+ + + {sub.category} + + + {sub.unit && ( + {sub.unit} + )} + + +
+ ))} + + {subscriptions.length === 0 && !adding && ( +

+ Keine Subscriptions konfiguriert. Füge einen MQTT-Topic hinzu um Daten zu empfangen. +

+ )} +
+ + {/* Add new */} + {adding ? ( +
+

Neuer Topic

+ + + + + + + + + +
+ + + + + + + + + +
+ +
+ + +
+
+ ) : ( + + )} + + {error && ( +
+

{error}

+
+ )} +
+ ); +} diff --git a/web/src/admin/pages/NewsSettings.tsx b/web/src/admin/pages/NewsSettings.tsx new file mode 100644 index 0000000..396f35f --- /dev/null +++ b/web/src/admin/pages/NewsSettings.tsx @@ -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(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 ( +
+ +
+ ); + } + + if (error || !integration) { + return ( +
+ +
+

{error || "Integration nicht gefunden"}

+
+
+ ); + } + + return ( +
+ + +
+ + {(config, setConfig) => ( + <> + + setConfig("db_host", v)} + placeholder="z.B. 10.10.10.100" + /> + + +
+ + setConfig("db_port", v)} + min={1} + max={65535} + /> + + + setConfig("db_name", v)} + placeholder="market_news" + /> + +
+ +
+ + setConfig("db_user", v)} + placeholder="postgres" + /> + + + setConfig("db_password", v)} + type="password" + /> + +
+ + + setConfig("max_age_hours", v)} + min={1} + max={720} + /> + + + )} +
+
+
+ ); +} diff --git a/web/src/admin/pages/UnraidSettings.tsx b/web/src/admin/pages/UnraidSettings.tsx new file mode 100644 index 0000000..551a1a4 --- /dev/null +++ b/web/src/admin/pages/UnraidSettings.tsx @@ -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(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([]); + + 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 ( +
+ +
+ ); + } + + if (error && !integration) { + return ( +
+ +
+

{error}

+
+
+ ); + } + + return ( +
+ + +
+ {/* Enable toggle */} +
+
+

Unraid Integration

+

{enabled ? "Aktiv" : "Deaktiviert"}

+
+ +
+ +
+ {/* Server list */} + {servers.map((srv, idx) => ( +
+
+ Server #{idx + 1} + +
+ +
+ + updateServer(idx, "name", v)} + placeholder="z.B. Main Server" + /> + + + updateServer(idx, "host", v)} + placeholder="z.B. 10.10.10.100" + /> + + + updateServer(idx, "api_key", v)} + type="password" + /> + + + updateServer(idx, "port", v)} + min={1} + max={65535} + /> + +
+
+ ))} + + {/* Add server button */} + +
+ + {error && ( +
+

{error}

+
+ )} + + {/* Actions */} +
+ + + {integration && } +
+
+
+ ); +} diff --git a/web/src/admin/pages/VikunjaSettings.tsx b/web/src/admin/pages/VikunjaSettings.tsx new file mode 100644 index 0000000..6e2e6d5 --- /dev/null +++ b/web/src/admin/pages/VikunjaSettings.tsx @@ -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(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 ( +
+ +
+ ); + } + + if (error || !integration) { + return ( +
+ +
+

{error || "Integration nicht gefunden"}

+
+
+ ); + } + + return ( +
+ + +
+ + {(config, setConfig) => ( + <> + + setConfig("url", v)} + placeholder="http://10.10.10.50:3456" + /> + + + + setConfig("token", v)} + type="password" + placeholder="tk_..." + /> + + + + setConfig("private_projects", v)} + placeholder="z.B. 1,5,12" + /> + + + + setConfig("sams_projects", v)} + placeholder="z.B. 3,8" + /> + + + )} + +
+
+ ); +} diff --git a/web/src/admin/pages/WeatherSettings.tsx b/web/src/admin/pages/WeatherSettings.tsx new file mode 100644 index 0000000..2312a5e --- /dev/null +++ b/web/src/admin/pages/WeatherSettings.tsx @@ -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(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 ( +
+ +
+ ); + } + + if (error || !integration) { + return ( +
+ +
+

{error || "Integration nicht gefunden"}

+
+
+ ); + } + + return ( +
+ + +
+ + {(config, setConfig) => ( + <> + + setConfig("primary_location", v)} + placeholder="z.B. Berlin oder 52.52,13.405" + /> + + + + setConfig("secondary_location", v)} + placeholder="z.B. München oder 48.137,11.576" + /> + + + )} + +
+
+ ); +} diff --git a/web/src/main.tsx b/web/src/main.tsx index d0d227f..d35532d 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,10 +1,13 @@ import React from "react"; import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; import App from "./App"; import "./styles/globals.css"; ReactDOM.createRoot(document.getElementById("root")!).render( - + + + ); diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx new file mode 100644 index 0000000..4221fe3 --- /dev/null +++ b/web/src/pages/Dashboard.tsx @@ -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 ( +
+ {error && ( +
+ +

{error}

+ +
+ )} + +
+
+
+

Daily Briefing

+ +
+
+ + + + + +
+
+
+ +
+ {loading && !data ? ( + + ) : data ? ( + <> +
+ + +
+ +
+
+ +
+ {data.servers.servers.map((srv) => ( + + ))} + + +
+ + {(data.mqtt?.connected || (data.mqtt?.entities?.length ?? 0) > 0) && ( +
+
+ +
+
+ )} + +
+ +
+ +
+

+ Letzte Aktualisierung:{" "} + {new Date(data.timestamp).toLocaleTimeString("de-DE", { + hour: "2-digit", minute: "2-digit", second: "2-digit", + })} +

+
+ + ) : null} +
+
+ ); +} + +function LiveIndicator({ connected }: { connected: boolean }) { + return ( +
+
+
+ {connected &&
} +
+ + {connected ? "Live" : "Offline"} + + {connected ? : } +
+ ); +} + +function LoadingSkeleton() { + return ( +
+
+ + + +
+
+ {Array.from({ length: 4 }).map((_, i) => )} +
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => )} +
+
+
+ ); +} + +function SkeletonCard({ className = "" }: { className?: string }) { + return ( +
+
+
+
+
+
+
+ ); +}