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 */} - - - ) : 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 ( +
+
+
+
+
+
+
+ ); +}