feat: add Admin Panel with JWT auth, DB settings, and integration management
Complete admin backend with login, where all integrations (weather, news, Home Assistant, Vikunja, Unraid, MQTT) can be configured via web UI instead of ENV variables. Two-layer config: ENV seeds DB on first start, then DB is source of truth. Auto-migration system on startup. Backend: db.py shared pool, auth.py JWT, settings_service CRUD, seed_service, admin router (protected), test_connections per integration, config.py rewrite. Frontend: react-router v6, login page, admin layout with sidebar, 8 settings pages (General, Weather, News, HA, Vikunja, Unraid, MQTT, ChangePassword), shared IntegrationForm + TestButton components. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
89ed0c6d0a
commit
f6a42c2dd2
40 changed files with 3487 additions and 311 deletions
|
|
@ -5,12 +5,18 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
environment:
|
environment:
|
||||||
# Database (PostgreSQL)
|
# ── Required: Database (PostgreSQL) ──
|
||||||
- DB_HOST=10.10.10.10
|
- DB_HOST=10.10.10.10
|
||||||
- DB_PORT=5433
|
- DB_PORT=5433
|
||||||
- DB_NAME=openclaw
|
- DB_NAME=openclaw
|
||||||
- DB_USER=sam
|
- DB_USER=sam
|
||||||
- DB_PASSWORD=sam
|
- DB_PASSWORD=sam
|
||||||
|
|
||||||
|
# ── Required: Admin Panel ──
|
||||||
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin}
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-}
|
||||||
|
|
||||||
|
# ── Seed Values (used on first start only, then DB takes over) ──
|
||||||
# Weather
|
# Weather
|
||||||
- WEATHER_LOCATION=Leverkusen
|
- WEATHER_LOCATION=Leverkusen
|
||||||
- WEATHER_LOCATION_SECONDARY=Rab,Croatia
|
- WEATHER_LOCATION_SECONDARY=Rab,Croatia
|
||||||
|
|
|
||||||
|
|
@ -5,3 +5,5 @@ asyncpg==0.30.0
|
||||||
jinja2==3.1.5
|
jinja2==3.1.5
|
||||||
websockets==14.2
|
websockets==14.2
|
||||||
aiomqtt==2.3.0
|
aiomqtt==2.3.0
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
|
|
||||||
72
server/auth.py
Normal file
72
server/auth.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
"""JWT authentication for admin routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
JWT_SECRET = os.getenv("JWT_SECRET") or secrets.token_urlsafe(32)
|
||||||
|
JWT_ALGORITHM = "HS256"
|
||||||
|
JWT_EXPIRE_HOURS = 24
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
bearer_scheme = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Hash a plain-text password with bcrypt."""
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
"""Verify a plain-text password against its bcrypt hash."""
|
||||||
|
return pwd_context.verify(plain, hashed)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(subject: str) -> str:
|
||||||
|
"""Create a JWT access token for the given subject (username)."""
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
|
||||||
|
return jwt.encode(
|
||||||
|
{"sub": subject, "exp": expire},
|
||||||
|
JWT_SECRET,
|
||||||
|
algorithm=JWT_ALGORITHM,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def require_admin(
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
|
||||||
|
) -> str:
|
||||||
|
"""FastAPI dependency that validates the JWT and returns the username.
|
||||||
|
|
||||||
|
Use as: ``admin_user: str = Depends(require_admin)``
|
||||||
|
"""
|
||||||
|
if credentials is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Authentication required",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
token = credentials.credentials
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||||
|
username: Optional[str] = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token payload")
|
||||||
|
return username
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or expired token",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
142
server/config.py
142
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -18,7 +26,7 @@ class UnraidServer:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Settings:
|
class Settings:
|
||||||
# --- Database (PostgreSQL) ---
|
# --- Bootstrap (always from ENV) ---
|
||||||
db_host: str = "10.10.10.10"
|
db_host: str = "10.10.10.10"
|
||||||
db_port: int = 5433
|
db_port: int = 5433
|
||||||
db_name: str = "openclaw"
|
db_name: str = "openclaw"
|
||||||
|
|
@ -28,25 +36,31 @@ class Settings:
|
||||||
# --- Weather ---
|
# --- Weather ---
|
||||||
weather_location: str = "Leverkusen"
|
weather_location: str = "Leverkusen"
|
||||||
weather_location_secondary: str = "Rab,Croatia"
|
weather_location_secondary: str = "Rab,Croatia"
|
||||||
weather_cache_ttl: int = 1800 # 30 min
|
weather_cache_ttl: int = 1800
|
||||||
|
|
||||||
# --- Home Assistant ---
|
# --- Home Assistant ---
|
||||||
ha_url: str = "https://homeassistant.daddelolymp.de"
|
ha_url: str = ""
|
||||||
ha_token: str = ""
|
ha_token: str = ""
|
||||||
ha_cache_ttl: int = 30
|
ha_cache_ttl: int = 30
|
||||||
|
ha_enabled: bool = False
|
||||||
|
|
||||||
# --- Vikunja Tasks ---
|
# --- Vikunja Tasks ---
|
||||||
vikunja_url: str = "http://10.10.10.10:3456/api/v1"
|
vikunja_url: str = ""
|
||||||
vikunja_token: str = ""
|
vikunja_token: str = ""
|
||||||
vikunja_cache_ttl: int = 60
|
vikunja_cache_ttl: int = 60
|
||||||
|
vikunja_enabled: bool = False
|
||||||
|
vikunja_private_projects: List[int] = field(default_factory=lambda: [3, 4])
|
||||||
|
vikunja_sams_projects: List[int] = field(default_factory=lambda: [2, 5])
|
||||||
|
|
||||||
# --- Unraid Servers ---
|
# --- Unraid Servers ---
|
||||||
unraid_servers: List[UnraidServer] = field(default_factory=list)
|
unraid_servers: List[UnraidServer] = field(default_factory=list)
|
||||||
unraid_cache_ttl: int = 15
|
unraid_cache_ttl: int = 15
|
||||||
|
unraid_enabled: bool = False
|
||||||
|
|
||||||
# --- News ---
|
# --- News ---
|
||||||
news_cache_ttl: int = 300 # 5 min
|
news_cache_ttl: int = 300
|
||||||
news_max_age_hours: int = 48
|
news_max_age_hours: int = 48
|
||||||
|
news_enabled: bool = True
|
||||||
|
|
||||||
# --- MQTT ---
|
# --- MQTT ---
|
||||||
mqtt_host: str = ""
|
mqtt_host: str = ""
|
||||||
|
|
@ -55,39 +69,44 @@ class Settings:
|
||||||
mqtt_password: str = ""
|
mqtt_password: str = ""
|
||||||
mqtt_topics: List[str] = field(default_factory=lambda: ["#"])
|
mqtt_topics: List[str] = field(default_factory=lambda: ["#"])
|
||||||
mqtt_client_id: str = "daily-briefing"
|
mqtt_client_id: str = "daily-briefing"
|
||||||
|
mqtt_enabled: bool = False
|
||||||
|
|
||||||
# --- Server ---
|
# --- Server ---
|
||||||
host: str = "0.0.0.0"
|
host: str = "0.0.0.0"
|
||||||
port: int = 8080
|
port: int = 8080
|
||||||
debug: bool = False
|
debug: bool = False
|
||||||
|
|
||||||
|
# --- WebSocket ---
|
||||||
|
ws_interval: int = 15
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls) -> "Settings":
|
def from_env(cls) -> "Settings":
|
||||||
|
"""Load bootstrap config from environment variables."""
|
||||||
s = cls()
|
s = cls()
|
||||||
s.db_host = os.getenv("DB_HOST", s.db_host)
|
s.db_host = os.getenv("DB_HOST", s.db_host)
|
||||||
s.db_port = int(os.getenv("DB_PORT", str(s.db_port)))
|
s.db_port = int(os.getenv("DB_PORT", str(s.db_port)))
|
||||||
s.db_name = os.getenv("DB_NAME", s.db_name)
|
s.db_name = os.getenv("DB_NAME", s.db_name)
|
||||||
s.db_user = os.getenv("DB_USER", s.db_user)
|
s.db_user = os.getenv("DB_USER", s.db_user)
|
||||||
s.db_password = os.getenv("DB_PASSWORD", s.db_password)
|
s.db_password = os.getenv("DB_PASSWORD", s.db_password)
|
||||||
|
s.debug = os.getenv("DEBUG", "").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
# Legacy ENV support — used for first-run seeding
|
||||||
s.weather_location = os.getenv("WEATHER_LOCATION", s.weather_location)
|
s.weather_location = os.getenv("WEATHER_LOCATION", s.weather_location)
|
||||||
s.weather_location_secondary = os.getenv(
|
s.weather_location_secondary = os.getenv("WEATHER_LOCATION_SECONDARY", s.weather_location_secondary)
|
||||||
"WEATHER_LOCATION_SECONDARY", s.weather_location_secondary
|
|
||||||
)
|
|
||||||
|
|
||||||
s.ha_url = os.getenv("HA_URL", s.ha_url)
|
s.ha_url = os.getenv("HA_URL", s.ha_url)
|
||||||
s.ha_token = os.getenv("HA_TOKEN", s.ha_token)
|
s.ha_token = os.getenv("HA_TOKEN", s.ha_token)
|
||||||
|
s.ha_enabled = bool(s.ha_url)
|
||||||
s.vikunja_url = os.getenv("VIKUNJA_URL", s.vikunja_url)
|
s.vikunja_url = os.getenv("VIKUNJA_URL", s.vikunja_url)
|
||||||
s.vikunja_token = os.getenv("VIKUNJA_TOKEN", s.vikunja_token)
|
s.vikunja_token = os.getenv("VIKUNJA_TOKEN", s.vikunja_token)
|
||||||
|
s.vikunja_enabled = bool(s.vikunja_url)
|
||||||
s.mqtt_host = os.getenv("MQTT_HOST", s.mqtt_host)
|
s.mqtt_host = os.getenv("MQTT_HOST", s.mqtt_host)
|
||||||
s.mqtt_port = int(os.getenv("MQTT_PORT", str(s.mqtt_port)))
|
s.mqtt_port = int(os.getenv("MQTT_PORT", str(s.mqtt_port)))
|
||||||
s.mqtt_username = os.getenv("MQTT_USERNAME", s.mqtt_username)
|
s.mqtt_username = os.getenv("MQTT_USERNAME", s.mqtt_username)
|
||||||
s.mqtt_password = os.getenv("MQTT_PASSWORD", s.mqtt_password)
|
s.mqtt_password = os.getenv("MQTT_PASSWORD", s.mqtt_password)
|
||||||
s.mqtt_client_id = os.getenv("MQTT_CLIENT_ID", s.mqtt_client_id)
|
s.mqtt_client_id = os.getenv("MQTT_CLIENT_ID", s.mqtt_client_id)
|
||||||
|
s.mqtt_enabled = bool(s.mqtt_host)
|
||||||
|
|
||||||
# Parse MQTT_TOPICS (comma-separated or JSON array)
|
# Parse MQTT_TOPICS
|
||||||
raw_topics = os.getenv("MQTT_TOPICS", "")
|
raw_topics = os.getenv("MQTT_TOPICS", "")
|
||||||
if raw_topics:
|
if raw_topics:
|
||||||
try:
|
try:
|
||||||
|
|
@ -95,8 +114,6 @@ class Settings:
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
s.mqtt_topics = [t.strip() for t in raw_topics.split(",") if t.strip()]
|
s.mqtt_topics = [t.strip() for t in raw_topics.split(",") if t.strip()]
|
||||||
|
|
||||||
s.debug = os.getenv("DEBUG", "").lower() in ("1", "true", "yes")
|
|
||||||
|
|
||||||
# Parse UNRAID_SERVERS JSON
|
# Parse UNRAID_SERVERS JSON
|
||||||
raw = os.getenv("UNRAID_SERVERS", "[]")
|
raw = os.getenv("UNRAID_SERVERS", "[]")
|
||||||
try:
|
try:
|
||||||
|
|
@ -111,10 +128,103 @@ class Settings:
|
||||||
for i, srv in enumerate(servers_data)
|
for i, srv in enumerate(servers_data)
|
||||||
if srv.get("host")
|
if srv.get("host")
|
||||||
]
|
]
|
||||||
|
s.unraid_enabled = len(s.unraid_servers) > 0
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
s.unraid_servers = []
|
s.unraid_servers = []
|
||||||
|
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
async def load_from_db(self) -> None:
|
||||||
|
"""Override fields with values from the database."""
|
||||||
|
try:
|
||||||
|
from server.services import settings_service
|
||||||
|
|
||||||
|
# Load integrations
|
||||||
|
integrations = await settings_service.get_integrations()
|
||||||
|
for integ in integrations:
|
||||||
|
cfg = integ.get("config", {})
|
||||||
|
enabled = integ.get("enabled", True)
|
||||||
|
itype = integ["type"]
|
||||||
|
|
||||||
|
if itype == "weather":
|
||||||
|
self.weather_location = cfg.get("location", self.weather_location)
|
||||||
|
self.weather_location_secondary = cfg.get("location_secondary", self.weather_location_secondary)
|
||||||
|
|
||||||
|
elif itype == "news":
|
||||||
|
self.news_max_age_hours = int(cfg.get("max_age_hours", self.news_max_age_hours))
|
||||||
|
self.news_enabled = enabled
|
||||||
|
|
||||||
|
elif itype == "ha":
|
||||||
|
self.ha_url = cfg.get("url", self.ha_url)
|
||||||
|
self.ha_token = cfg.get("token", self.ha_token)
|
||||||
|
self.ha_enabled = enabled
|
||||||
|
|
||||||
|
elif itype == "vikunja":
|
||||||
|
self.vikunja_url = cfg.get("url", self.vikunja_url)
|
||||||
|
self.vikunja_token = cfg.get("token", self.vikunja_token)
|
||||||
|
self.vikunja_private_projects = cfg.get("private_projects", self.vikunja_private_projects)
|
||||||
|
self.vikunja_sams_projects = cfg.get("sams_projects", self.vikunja_sams_projects)
|
||||||
|
self.vikunja_enabled = enabled
|
||||||
|
|
||||||
|
elif itype == "unraid":
|
||||||
|
servers = cfg.get("servers", [])
|
||||||
|
self.unraid_servers = [
|
||||||
|
UnraidServer(
|
||||||
|
name=s.get("name", ""),
|
||||||
|
host=s.get("host", ""),
|
||||||
|
api_key=s.get("api_key", ""),
|
||||||
|
port=int(s.get("port", 80)),
|
||||||
|
)
|
||||||
|
for s in servers
|
||||||
|
if s.get("host")
|
||||||
|
]
|
||||||
|
self.unraid_enabled = enabled
|
||||||
|
|
||||||
|
elif itype == "mqtt":
|
||||||
|
self.mqtt_host = cfg.get("host", self.mqtt_host)
|
||||||
|
self.mqtt_port = int(cfg.get("port", self.mqtt_port))
|
||||||
|
self.mqtt_username = cfg.get("username", self.mqtt_username)
|
||||||
|
self.mqtt_password = cfg.get("password", self.mqtt_password)
|
||||||
|
self.mqtt_client_id = cfg.get("client_id", self.mqtt_client_id)
|
||||||
|
self.mqtt_topics = cfg.get("topics", self.mqtt_topics)
|
||||||
|
self.mqtt_enabled = enabled
|
||||||
|
|
||||||
|
# Load app_settings (cache TTLs, etc.)
|
||||||
|
all_settings = await settings_service.get_all_settings()
|
||||||
|
for key, data in all_settings.items():
|
||||||
|
val = data["value"]
|
||||||
|
if key == "weather_cache_ttl":
|
||||||
|
self.weather_cache_ttl = int(val)
|
||||||
|
elif key == "ha_cache_ttl":
|
||||||
|
self.ha_cache_ttl = int(val)
|
||||||
|
elif key == "vikunja_cache_ttl":
|
||||||
|
self.vikunja_cache_ttl = int(val)
|
||||||
|
elif key == "unraid_cache_ttl":
|
||||||
|
self.unraid_cache_ttl = int(val)
|
||||||
|
elif key == "news_cache_ttl":
|
||||||
|
self.news_cache_ttl = int(val)
|
||||||
|
elif key == "ws_interval":
|
||||||
|
self.ws_interval = int(val)
|
||||||
|
|
||||||
|
logger.info("Settings loaded from database (%d integrations)", len(integrations))
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to load settings from DB — using ENV defaults")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Module-level singleton ---
|
||||||
settings = Settings.from_env()
|
settings = Settings.from_env()
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""Return the current settings. Used by all routers."""
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
async def reload_settings() -> None:
|
||||||
|
"""Reload settings from DB. Called after admin changes."""
|
||||||
|
global settings
|
||||||
|
s = Settings.from_env()
|
||||||
|
await s.load_from_db()
|
||||||
|
settings = s
|
||||||
|
logger.info("Settings reloaded from database")
|
||||||
|
|
|
||||||
52
server/db.py
Normal file
52
server/db.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""Shared asyncpg connection pool."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_pool: Optional[asyncpg.Pool] = None
|
||||||
|
|
||||||
|
|
||||||
|
async def init_pool(
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
dbname: str,
|
||||||
|
user: str,
|
||||||
|
password: str,
|
||||||
|
min_size: int = 1,
|
||||||
|
max_size: int = 5,
|
||||||
|
) -> asyncpg.Pool:
|
||||||
|
"""Create the shared connection pool. Call once during app startup."""
|
||||||
|
global _pool
|
||||||
|
_pool = await asyncpg.create_pool(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
database=dbname,
|
||||||
|
user=user,
|
||||||
|
password=password,
|
||||||
|
min_size=min_size,
|
||||||
|
max_size=max_size,
|
||||||
|
)
|
||||||
|
logger.info("Database pool initialized (%s:%d/%s)", host, port, dbname)
|
||||||
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pool() -> asyncpg.Pool:
|
||||||
|
"""Return the shared pool. Raises if not yet initialized."""
|
||||||
|
if _pool is None:
|
||||||
|
raise RuntimeError("Database pool not initialized — call init_pool() first")
|
||||||
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
|
async def close_pool() -> None:
|
||||||
|
"""Close the shared pool. Call during app shutdown."""
|
||||||
|
global _pool
|
||||||
|
if _pool is not None:
|
||||||
|
await _pool.close()
|
||||||
|
_pool = None
|
||||||
|
logger.info("Database pool closed")
|
||||||
|
|
@ -8,10 +8,10 @@ from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from server.config import settings
|
from server.config import get_settings, reload_settings, settings
|
||||||
from server.services import news_service
|
|
||||||
from server.services.mqtt_service import mqtt_service
|
from server.services.mqtt_service import mqtt_service
|
||||||
|
|
||||||
logger = logging.getLogger("daily-briefing")
|
logger = logging.getLogger("daily-briefing")
|
||||||
|
|
@ -24,15 +24,15 @@ logging.basicConfig(
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Startup / shutdown lifecycle."""
|
"""Startup / shutdown lifecycle."""
|
||||||
logger.info("Starting Daily Briefing Dashboard...")
|
from server import db
|
||||||
logger.info(
|
from server.migrations.runner import run_migrations
|
||||||
"Unraid servers configured: %d",
|
from server.services.seed_service import seed_if_empty
|
||||||
len(settings.unraid_servers),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Initialize database pool
|
logger.info("Starting Daily Briefing Dashboard v2.1...")
|
||||||
|
|
||||||
|
# 1. Initialize shared database pool (bootstrap from ENV)
|
||||||
try:
|
try:
|
||||||
await news_service.init_pool(
|
pool = await db.init_pool(
|
||||||
host=settings.db_host,
|
host=settings.db_host,
|
||||||
port=settings.db_port,
|
port=settings.db_port,
|
||||||
dbname=settings.db_name,
|
dbname=settings.db_name,
|
||||||
|
|
@ -41,36 +41,64 @@ async def lifespan(app: FastAPI):
|
||||||
)
|
)
|
||||||
logger.info("Database pool initialized")
|
logger.info("Database pool initialized")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to initialize database pool — news will be unavailable")
|
logger.exception("Failed to initialize database pool — admin + news will be unavailable")
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
# Start MQTT service
|
# 2. Run database migrations
|
||||||
if settings.mqtt_host:
|
try:
|
||||||
|
await run_migrations(pool)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Migration error — some features may not work")
|
||||||
|
|
||||||
|
# 3. Seed database from ENV on first run
|
||||||
|
try:
|
||||||
|
await seed_if_empty()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Seeding error — admin panel may need manual setup")
|
||||||
|
|
||||||
|
# 4. Load settings from database (overrides ENV defaults)
|
||||||
|
try:
|
||||||
|
await reload_settings()
|
||||||
|
cfg = get_settings()
|
||||||
|
logger.info(
|
||||||
|
"Settings loaded from DB — %d Unraid servers, MQTT=%s, HA=%s",
|
||||||
|
len(cfg.unraid_servers),
|
||||||
|
"enabled" if cfg.mqtt_enabled else "disabled",
|
||||||
|
"enabled" if cfg.ha_enabled else "disabled",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to load settings from DB — using ENV defaults")
|
||||||
|
cfg = settings
|
||||||
|
|
||||||
|
# 5. Start MQTT service if enabled
|
||||||
|
if cfg.mqtt_enabled and cfg.mqtt_host:
|
||||||
try:
|
try:
|
||||||
await mqtt_service.start(
|
await mqtt_service.start(
|
||||||
host=settings.mqtt_host,
|
host=cfg.mqtt_host,
|
||||||
port=settings.mqtt_port,
|
port=cfg.mqtt_port,
|
||||||
username=settings.mqtt_username or None,
|
username=cfg.mqtt_username or None,
|
||||||
password=settings.mqtt_password or None,
|
password=cfg.mqtt_password or None,
|
||||||
topics=settings.mqtt_topics,
|
topics=cfg.mqtt_topics,
|
||||||
client_id=settings.mqtt_client_id,
|
client_id=cfg.mqtt_client_id,
|
||||||
)
|
)
|
||||||
logger.info("MQTT service started (broker %s:%d)", settings.mqtt_host, settings.mqtt_port)
|
logger.info("MQTT service started (%s:%d)", cfg.mqtt_host, cfg.mqtt_port)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to start MQTT service — MQTT will be unavailable")
|
logger.exception("Failed to start MQTT service")
|
||||||
else:
|
else:
|
||||||
logger.info("MQTT disabled — set MQTT_HOST to enable")
|
logger.info("MQTT disabled — configure via Admin Panel or MQTT_HOST env")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
logger.info("Shutting down...")
|
logger.info("Shutting down...")
|
||||||
await mqtt_service.stop()
|
await mqtt_service.stop()
|
||||||
await news_service.close_pool()
|
await db.close_pool()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Daily Briefing",
|
title="Daily Briefing",
|
||||||
version="2.0.0",
|
version="2.1.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -84,8 +112,10 @@ app.add_middleware(
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Register Routers ---
|
# --- Register Routers ---
|
||||||
from server.routers import dashboard, homeassistant, mqtt, news, servers, tasks, weather # noqa: E402
|
from server.routers import admin, auth, dashboard, homeassistant, mqtt, news, servers, tasks, weather # noqa: E402
|
||||||
|
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(admin.router)
|
||||||
app.include_router(weather.router)
|
app.include_router(weather.router)
|
||||||
app.include_router(news.router)
|
app.include_router(news.router)
|
||||||
app.include_router(servers.router)
|
app.include_router(servers.router)
|
||||||
|
|
@ -97,6 +127,14 @@ app.include_router(dashboard.router)
|
||||||
# --- Serve static frontend (production) ---
|
# --- Serve static frontend (production) ---
|
||||||
static_dir = Path(__file__).parent.parent / "static"
|
static_dir = Path(__file__).parent.parent / "static"
|
||||||
if static_dir.is_dir():
|
if static_dir.is_dir():
|
||||||
|
# SPA fallback: serve index.html for any non-API path
|
||||||
|
@app.get("/admin/{full_path:path}")
|
||||||
|
async def admin_spa_fallback(full_path: str):
|
||||||
|
index = static_dir / "index.html"
|
||||||
|
if index.exists():
|
||||||
|
return FileResponse(str(index))
|
||||||
|
return {"error": "Frontend not built"}
|
||||||
|
|
||||||
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
|
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
|
||||||
logger.info("Serving static frontend from %s", static_dir)
|
logger.info("Serving static frontend from %s", static_dir)
|
||||||
else:
|
else:
|
||||||
|
|
@ -104,6 +142,10 @@ else:
|
||||||
async def root():
|
async def root():
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"message": "Daily Briefing API — Frontend not built yet",
|
"message": "Daily Briefing API v2.1 — Frontend not built yet",
|
||||||
"endpoints": ["/api/all", "/api/weather", "/api/news", "/api/servers", "/api/ha", "/api/tasks"],
|
"endpoints": [
|
||||||
|
"/api/all", "/api/weather", "/api/news", "/api/servers",
|
||||||
|
"/api/ha", "/api/tasks", "/api/mqtt",
|
||||||
|
"/api/auth/login", "/api/admin/integrations",
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
server/migrations/001_admin_schema.sql
Normal file
53
server/migrations/001_admin_schema.sql
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
-- Migration 001: Admin Backend Schema
|
||||||
|
-- Creates tables for admin user, settings, integrations, and MQTT subscriptions.
|
||||||
|
|
||||||
|
-- Single admin user
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_user (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(100) NOT NULL DEFAULT 'admin',
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- General key/value settings (cache TTLs, preferences, etc.)
|
||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
key VARCHAR(100) PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL DEFAULT '',
|
||||||
|
value_type VARCHAR(20) NOT NULL DEFAULT 'string',
|
||||||
|
category VARCHAR(50) NOT NULL DEFAULT 'general',
|
||||||
|
label VARCHAR(200) NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Integration configurations (one row per integration type)
|
||||||
|
CREATE TABLE IF NOT EXISTS integrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
type VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
config JSONB NOT NULL DEFAULT '{}',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
display_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- MQTT subscription management
|
||||||
|
CREATE TABLE IF NOT EXISTS mqtt_subscriptions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
topic_pattern VARCHAR(500) NOT NULL,
|
||||||
|
display_name VARCHAR(200) NOT NULL DEFAULT '',
|
||||||
|
category VARCHAR(100) NOT NULL DEFAULT 'other',
|
||||||
|
unit VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
widget_type VARCHAR(50) NOT NULL DEFAULT 'value',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
display_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Record this migration
|
||||||
|
INSERT INTO schema_version (version, description)
|
||||||
|
VALUES (1, 'Admin backend: admin_user, app_settings, integrations, mqtt_subscriptions')
|
||||||
|
ON CONFLICT (version) DO NOTHING;
|
||||||
0
server/migrations/__init__.py
Normal file
0
server/migrations/__init__.py
Normal file
58
server/migrations/runner.py
Normal file
58
server/migrations/runner.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""Auto-migration runner. Applies pending SQL migrations on startup."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MIGRATIONS_DIR = Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
|
async def run_migrations(pool: asyncpg.Pool) -> None:
|
||||||
|
"""Check schema_version and apply any pending .sql migration files."""
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
# Ensure the version-tracking table exists
|
||||||
|
await conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
description TEXT NOT NULL DEFAULT ''
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT COALESCE(MAX(version), 0) AS v FROM schema_version"
|
||||||
|
)
|
||||||
|
current_version: int = row["v"]
|
||||||
|
logger.info("Current schema version: %d", current_version)
|
||||||
|
|
||||||
|
# Discover and sort SQL files by their numeric prefix
|
||||||
|
sql_files = sorted(
|
||||||
|
MIGRATIONS_DIR.glob("[0-9]*.sql"),
|
||||||
|
key=lambda p: int(p.stem.split("_")[0]),
|
||||||
|
)
|
||||||
|
|
||||||
|
applied = 0
|
||||||
|
for sql_file in sql_files:
|
||||||
|
version = int(sql_file.stem.split("_")[0])
|
||||||
|
if version <= current_version:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info("Applying migration %03d: %s", version, sql_file.name)
|
||||||
|
sql = sql_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Execute the entire migration in a transaction
|
||||||
|
async with conn.transaction():
|
||||||
|
await conn.execute(sql)
|
||||||
|
|
||||||
|
logger.info("Migration %03d applied successfully", version)
|
||||||
|
applied += 1
|
||||||
|
|
||||||
|
if applied == 0:
|
||||||
|
logger.info("No pending migrations")
|
||||||
|
else:
|
||||||
|
logger.info("Applied %d migration(s)", applied)
|
||||||
186
server/routers/admin.py
Normal file
186
server/routers/admin.py
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
"""Admin router — protected CRUD for settings, integrations, and MQTT subscriptions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from server.auth import require_admin
|
||||||
|
from server.services import settings_service
|
||||||
|
from server.services.test_connections import TEST_FUNCTIONS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/admin",
|
||||||
|
tags=["admin"],
|
||||||
|
dependencies=[Depends(require_admin)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Request/Response Models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class SettingsUpdate(BaseModel):
|
||||||
|
settings: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
config: Optional[Dict[str, Any]] = None
|
||||||
|
enabled: Optional[bool] = None
|
||||||
|
display_order: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MqttSubscriptionCreate(BaseModel):
|
||||||
|
topic_pattern: str
|
||||||
|
display_name: str = ""
|
||||||
|
category: str = "other"
|
||||||
|
unit: str = ""
|
||||||
|
widget_type: str = "value"
|
||||||
|
enabled: bool = True
|
||||||
|
display_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class MqttSubscriptionUpdate(BaseModel):
|
||||||
|
topic_pattern: Optional[str] = None
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
unit: Optional[str] = None
|
||||||
|
widget_type: Optional[str] = None
|
||||||
|
enabled: Optional[bool] = None
|
||||||
|
display_order: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Settings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/settings")
|
||||||
|
async def get_settings() -> Dict[str, Any]:
|
||||||
|
"""Return all app settings."""
|
||||||
|
return await settings_service.get_all_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/settings")
|
||||||
|
async def update_settings(body: SettingsUpdate) -> Dict[str, str]:
|
||||||
|
"""Bulk update settings."""
|
||||||
|
await settings_service.bulk_set_settings(body.settings)
|
||||||
|
# Reload in-memory settings
|
||||||
|
await _reload_app_settings()
|
||||||
|
return {"status": "ok", "message": f"Updated {len(body.settings)} setting(s)"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integrations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/integrations")
|
||||||
|
async def list_integrations() -> List[Dict[str, Any]]:
|
||||||
|
"""List all integration configs."""
|
||||||
|
return await settings_service.get_integrations()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/integrations/{type_name}")
|
||||||
|
async def get_integration(type_name: str) -> Dict[str, Any]:
|
||||||
|
"""Get a single integration config."""
|
||||||
|
result = await settings_service.get_integration(type_name)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Integration '{type_name}' not found")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/integrations/{type_name}")
|
||||||
|
async def update_integration(type_name: str, body: IntegrationUpdate) -> Dict[str, Any]:
|
||||||
|
"""Update an integration config."""
|
||||||
|
existing = await settings_service.get_integration(type_name)
|
||||||
|
if existing is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Integration '{type_name}' not found")
|
||||||
|
|
||||||
|
result = await settings_service.upsert_integration(
|
||||||
|
type_name=type_name,
|
||||||
|
name=body.name or existing["name"],
|
||||||
|
config=body.config if body.config is not None else existing["config"],
|
||||||
|
enabled=body.enabled if body.enabled is not None else existing["enabled"],
|
||||||
|
display_order=body.display_order if body.display_order is not None else existing["display_order"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reload in-memory settings
|
||||||
|
await _reload_app_settings()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/integrations/{type_name}/test")
|
||||||
|
async def test_integration(type_name: str) -> Dict[str, Any]:
|
||||||
|
"""Test an integration connection."""
|
||||||
|
integration = await settings_service.get_integration(type_name)
|
||||||
|
if integration is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Integration '{type_name}' not found")
|
||||||
|
|
||||||
|
test_fn = TEST_FUNCTIONS.get(type_name)
|
||||||
|
if test_fn is None:
|
||||||
|
return {"success": False, "message": f"No test available for '{type_name}'"}
|
||||||
|
|
||||||
|
return await test_fn(integration["config"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MQTT Subscriptions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/mqtt/subscriptions")
|
||||||
|
async def list_mqtt_subscriptions() -> List[Dict[str, Any]]:
|
||||||
|
"""List all MQTT subscriptions."""
|
||||||
|
return await settings_service.get_mqtt_subscriptions()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mqtt/subscriptions")
|
||||||
|
async def create_mqtt_subscription(body: MqttSubscriptionCreate) -> Dict[str, Any]:
|
||||||
|
"""Create a new MQTT subscription."""
|
||||||
|
return await settings_service.create_mqtt_subscription(
|
||||||
|
topic_pattern=body.topic_pattern,
|
||||||
|
display_name=body.display_name,
|
||||||
|
category=body.category,
|
||||||
|
unit=body.unit,
|
||||||
|
widget_type=body.widget_type,
|
||||||
|
enabled=body.enabled,
|
||||||
|
display_order=body.display_order,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/mqtt/subscriptions/{sub_id}")
|
||||||
|
async def update_mqtt_subscription(sub_id: int, body: MqttSubscriptionUpdate) -> Dict[str, Any]:
|
||||||
|
"""Update an MQTT subscription."""
|
||||||
|
fields = body.model_dump(exclude_none=True)
|
||||||
|
if not fields:
|
||||||
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
|
result = await settings_service.update_mqtt_subscription(sub_id, **fields)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/mqtt/subscriptions/{sub_id}")
|
||||||
|
async def delete_mqtt_subscription(sub_id: int) -> Dict[str, str]:
|
||||||
|
"""Delete an MQTT subscription."""
|
||||||
|
deleted = await settings_service.delete_mqtt_subscription(sub_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||||
|
return {"status": "ok", "message": "Subscription deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _reload_app_settings() -> None:
|
||||||
|
"""Reload the in-memory Settings object from the database."""
|
||||||
|
try:
|
||||||
|
from server.config import reload_settings
|
||||||
|
await reload_settings()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to reload settings after admin change")
|
||||||
79
server/routers/auth.py
Normal file
79
server/routers/auth.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""Auth router — login and password management."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from server.auth import (
|
||||||
|
create_access_token,
|
||||||
|
hash_password,
|
||||||
|
require_admin,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
from server.services import settings_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(body: LoginRequest) -> Dict[str, Any]:
|
||||||
|
"""Authenticate admin and return a JWT."""
|
||||||
|
user = await settings_service.get_admin_user()
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=503, detail="No admin user configured")
|
||||||
|
|
||||||
|
if body.username != user["username"]:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
|
||||||
|
if not verify_password(body.password, user["password_hash"]):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
|
||||||
|
token = create_access_token(user["username"])
|
||||||
|
return {
|
||||||
|
"token": token,
|
||||||
|
"username": user["username"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def get_me(admin_user: str = Depends(require_admin)) -> Dict[str, str]:
|
||||||
|
"""Return the authenticated admin username. Used to verify token validity."""
|
||||||
|
return {"username": admin_user}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/password")
|
||||||
|
async def change_password(
|
||||||
|
body: ChangePasswordRequest,
|
||||||
|
admin_user: str = Depends(require_admin),
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""Change the admin password."""
|
||||||
|
user = await settings_service.get_admin_user()
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=500, detail="Admin user not found")
|
||||||
|
|
||||||
|
if not verify_password(body.current_password, user["password_hash"]):
|
||||||
|
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||||
|
|
||||||
|
if len(body.new_password) < 6:
|
||||||
|
raise HTTPException(status_code=400, detail="New password must be at least 6 characters")
|
||||||
|
|
||||||
|
await settings_service.update_admin_password(
|
||||||
|
user["id"], hash_password(body.new_password)
|
||||||
|
)
|
||||||
|
return {"status": "ok", "message": "Password changed successfully"}
|
||||||
|
|
@ -8,7 +8,7 @@ from typing import Any, Dict
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from server.cache import cache
|
from server.cache import cache
|
||||||
from server.config import settings
|
from server.config import get_settings
|
||||||
from server.services.ha_service import fetch_ha_data
|
from server.services.ha_service import fetch_ha_data
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -36,12 +36,12 @@ async def get_ha() -> Dict[str, Any]:
|
||||||
# --- cache miss -----------------------------------------------------------
|
# --- cache miss -----------------------------------------------------------
|
||||||
try:
|
try:
|
||||||
data: Dict[str, Any] = await fetch_ha_data(
|
data: Dict[str, Any] = await fetch_ha_data(
|
||||||
settings.ha_url,
|
get_settings().ha_url,
|
||||||
settings.ha_token,
|
get_settings().ha_token,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Failed to fetch Home Assistant data")
|
logger.exception("Failed to fetch Home Assistant data")
|
||||||
return {"error": True, "message": str(exc)}
|
return {"error": True, "message": str(exc)}
|
||||||
|
|
||||||
await cache.set(CACHE_KEY, data, settings.ha_cache_ttl)
|
await cache.set(CACHE_KEY, data, get_settings().ha_cache_ttl)
|
||||||
return data
|
return data
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional
|
||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, Query
|
||||||
|
|
||||||
from server.cache import cache
|
from server.cache import cache
|
||||||
from server.config import settings
|
from server.config import get_settings
|
||||||
from server.services.news_service import get_news, get_news_count
|
from server.services.news_service import get_news, get_news_count
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -50,7 +50,7 @@ async def get_news_articles(
|
||||||
total: int = 0
|
total: int = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
articles = await get_news(limit=limit, offset=offset, category=category, max_age_hours=settings.news_max_age_hours)
|
articles = await get_news(limit=limit, offset=offset, category=category, max_age_hours=get_settings().news_max_age_hours)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Failed to fetch news articles")
|
logger.exception("Failed to fetch news articles")
|
||||||
return {
|
return {
|
||||||
|
|
@ -63,7 +63,7 @@ async def get_news_articles(
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
total = await get_news_count(max_age_hours=settings.news_max_age_hours, category=category)
|
total = await get_news_count(max_age_hours=get_settings().news_max_age_hours, category=category)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Failed to fetch news count")
|
logger.exception("Failed to fetch news count")
|
||||||
# We still have articles -- return them with total = len(articles)
|
# We still have articles -- return them with total = len(articles)
|
||||||
|
|
@ -76,5 +76,5 @@ async def get_news_articles(
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
}
|
}
|
||||||
|
|
||||||
await cache.set(key, payload, settings.news_cache_ttl)
|
await cache.set(key, payload, get_settings().news_cache_ttl)
|
||||||
return payload
|
return payload
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from typing import Any, Dict, List
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from server.cache import cache
|
from server.cache import cache
|
||||||
from server.config import settings
|
from server.config import get_settings
|
||||||
from server.services.unraid_service import ServerConfig, fetch_all_servers
|
from server.services.unraid_service import ServerConfig, fetch_all_servers
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -42,7 +42,7 @@ async def get_servers() -> Dict[str, Any]:
|
||||||
api_key=srv.api_key,
|
api_key=srv.api_key,
|
||||||
port=srv.port,
|
port=srv.port,
|
||||||
)
|
)
|
||||||
for srv in settings.unraid_servers
|
for srv in get_settings().unraid_servers
|
||||||
]
|
]
|
||||||
|
|
||||||
servers_data: List[Dict[str, Any]] = []
|
servers_data: List[Dict[str, Any]] = []
|
||||||
|
|
@ -60,5 +60,5 @@ async def get_servers() -> Dict[str, Any]:
|
||||||
"servers": servers_data,
|
"servers": servers_data,
|
||||||
}
|
}
|
||||||
|
|
||||||
await cache.set(CACHE_KEY, payload, settings.unraid_cache_ttl)
|
await cache.set(CACHE_KEY, payload, get_settings().unraid_cache_ttl)
|
||||||
return payload
|
return payload
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from typing import Any, Dict
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from server.cache import cache
|
from server.cache import cache
|
||||||
from server.config import settings
|
from server.config import get_settings
|
||||||
from server.services.vikunja_service import fetch_tasks
|
from server.services.vikunja_service import fetch_tasks
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -36,12 +36,12 @@ async def get_tasks() -> Dict[str, Any]:
|
||||||
# --- cache miss -----------------------------------------------------------
|
# --- cache miss -----------------------------------------------------------
|
||||||
try:
|
try:
|
||||||
data: Dict[str, Any] = await fetch_tasks(
|
data: Dict[str, Any] = await fetch_tasks(
|
||||||
settings.vikunja_url,
|
get_settings().vikunja_url,
|
||||||
settings.vikunja_token,
|
get_settings().vikunja_token,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Failed to fetch Vikunja tasks")
|
logger.exception("Failed to fetch Vikunja tasks")
|
||||||
return {"error": True, "message": str(exc)}
|
return {"error": True, "message": str(exc)}
|
||||||
|
|
||||||
await cache.set(CACHE_KEY, data, settings.vikunja_cache_ttl)
|
await cache.set(CACHE_KEY, data, get_settings().vikunja_cache_ttl)
|
||||||
return data
|
return data
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from typing import Any, Dict, List
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from server.cache import cache
|
from server.cache import cache
|
||||||
from server.config import settings
|
from server.config import get_settings
|
||||||
from server.services.weather_service import fetch_hourly_forecast, fetch_weather
|
from server.services.weather_service import fetch_hourly_forecast, fetch_weather
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -43,9 +43,9 @@ async def get_weather() -> Dict[str, Any]:
|
||||||
hourly_data: List[Dict[str, Any]] = []
|
hourly_data: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
results = await asyncio.gather(
|
results = await asyncio.gather(
|
||||||
_safe_fetch_weather(settings.weather_location),
|
_safe_fetch_weather(get_settings().weather_location),
|
||||||
_safe_fetch_weather(settings.weather_location_secondary),
|
_safe_fetch_weather(get_settings().weather_location_secondary),
|
||||||
_safe_fetch_hourly(settings.weather_location),
|
_safe_fetch_hourly(get_settings().weather_location),
|
||||||
return_exceptions=False, # we handle errors inside the helpers
|
return_exceptions=False, # we handle errors inside the helpers
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -59,7 +59,7 @@ async def get_weather() -> Dict[str, Any]:
|
||||||
"hourly": hourly_data,
|
"hourly": hourly_data,
|
||||||
}
|
}
|
||||||
|
|
||||||
await cache.set(CACHE_KEY, payload, settings.weather_cache_ttl)
|
await cache.set(CACHE_KEY, payload, get_settings().weather_cache_ttl)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,11 @@
|
||||||
|
"""News service — queries market_news from PostgreSQL via shared pool."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
_pool: Optional[asyncpg.Pool] = None
|
from server.db import get_pool
|
||||||
|
|
||||||
|
|
||||||
async def init_pool(
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
dbname: str,
|
|
||||||
user: str,
|
|
||||||
password: str,
|
|
||||||
) -> None:
|
|
||||||
"""Initialise the global asyncpg connection pool.
|
|
||||||
|
|
||||||
Call once at application startup.
|
|
||||||
"""
|
|
||||||
global _pool
|
|
||||||
_pool = await asyncpg.create_pool(
|
|
||||||
host=host,
|
|
||||||
port=port,
|
|
||||||
database=dbname,
|
|
||||||
user=user,
|
|
||||||
password=password,
|
|
||||||
min_size=1,
|
|
||||||
max_size=5,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def close_pool() -> None:
|
|
||||||
"""Close the global asyncpg connection pool.
|
|
||||||
|
|
||||||
Call once at application shutdown.
|
|
||||||
"""
|
|
||||||
global _pool
|
|
||||||
if _pool is not None:
|
|
||||||
await _pool.close()
|
|
||||||
_pool = None
|
|
||||||
|
|
||||||
|
|
||||||
def _row_to_dict(row: asyncpg.Record) -> Dict[str, Any]:
|
def _row_to_dict(row: asyncpg.Record) -> Dict[str, Any]:
|
||||||
|
|
@ -54,19 +22,8 @@ async def get_news(
|
||||||
category: Optional[str] = None,
|
category: Optional[str] = None,
|
||||||
max_age_hours: int = 48,
|
max_age_hours: int = 48,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Fetch recent news articles from the market_news table.
|
"""Fetch recent news articles from the market_news table."""
|
||||||
|
pool = await get_pool()
|
||||||
Args:
|
|
||||||
limit: Maximum number of rows to return.
|
|
||||||
offset: Number of rows to skip (for pagination).
|
|
||||||
category: Optional category filter (exact match).
|
|
||||||
max_age_hours: Only return articles published within this many hours.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of news article dictionaries.
|
|
||||||
"""
|
|
||||||
if _pool is None:
|
|
||||||
raise RuntimeError("Database pool is not initialised. Call init_pool() first.")
|
|
||||||
|
|
||||||
params: List[Any] = []
|
params: List[Any] = []
|
||||||
param_idx = 1
|
param_idx = 1
|
||||||
|
|
@ -86,7 +43,7 @@ async def get_news(
|
||||||
params.append(limit)
|
params.append(limit)
|
||||||
params.append(offset)
|
params.append(offset)
|
||||||
|
|
||||||
async with _pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
rows = await conn.fetch(base_query, *params)
|
rows = await conn.fetch(base_query, *params)
|
||||||
|
|
||||||
return [_row_to_dict(row) for row in rows]
|
return [_row_to_dict(row) for row in rows]
|
||||||
|
|
@ -96,17 +53,8 @@ async def get_news_count(
|
||||||
max_age_hours: int = 48,
|
max_age_hours: int = 48,
|
||||||
category: Optional[str] = None,
|
category: Optional[str] = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Return the total count of recent news articles.
|
"""Return the total count of recent news articles."""
|
||||||
|
pool = await get_pool()
|
||||||
Args:
|
|
||||||
max_age_hours: Only count articles published within this many hours.
|
|
||||||
category: Optional category filter.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Integer count.
|
|
||||||
"""
|
|
||||||
if _pool is None:
|
|
||||||
raise RuntimeError("Database pool is not initialised. Call init_pool() first.")
|
|
||||||
|
|
||||||
params: List[Any] = []
|
params: List[Any] = []
|
||||||
param_idx = 1
|
param_idx = 1
|
||||||
|
|
@ -121,23 +69,15 @@ async def get_news_count(
|
||||||
query += f" AND category = ${param_idx}"
|
query += f" AND category = ${param_idx}"
|
||||||
params.append(category)
|
params.append(category)
|
||||||
|
|
||||||
async with _pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
row = await conn.fetchrow(query, *params)
|
row = await conn.fetchrow(query, *params)
|
||||||
|
|
||||||
return int(row["cnt"]) if row else 0
|
return int(row["cnt"]) if row else 0
|
||||||
|
|
||||||
|
|
||||||
async def get_categories(max_age_hours: int = 48) -> List[str]:
|
async def get_categories(max_age_hours: int = 48) -> List[str]:
|
||||||
"""Return distinct categories from recent news articles.
|
"""Return distinct categories from recent news articles."""
|
||||||
|
pool = await get_pool()
|
||||||
Args:
|
|
||||||
max_age_hours: Only consider articles published within this many hours.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Sorted list of category strings.
|
|
||||||
"""
|
|
||||||
if _pool is None:
|
|
||||||
raise RuntimeError("Database pool is not initialised. Call init_pool() first.")
|
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
"SELECT DISTINCT category "
|
"SELECT DISTINCT category "
|
||||||
|
|
@ -147,7 +87,7 @@ async def get_categories(max_age_hours: int = 48) -> List[str]:
|
||||||
"ORDER BY category"
|
"ORDER BY category"
|
||||||
)
|
)
|
||||||
|
|
||||||
async with _pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
rows = await conn.fetch(query)
|
rows = await conn.fetch(query)
|
||||||
|
|
||||||
return [row["category"] for row in rows]
|
return [row["category"] for row in rows]
|
||||||
|
|
|
||||||
150
server/services/seed_service.py
Normal file
150
server/services/seed_service.py
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
"""First-run seeder: populates DB from ENV defaults when tables are empty."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from server.auth import hash_password
|
||||||
|
from server.db import get_pool
|
||||||
|
from server.services import settings_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_if_empty() -> None:
|
||||||
|
"""Check if admin tables are empty and seed with ENV-derived values."""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
# ---- Admin User ----
|
||||||
|
user = await settings_service.get_admin_user()
|
||||||
|
if user is None:
|
||||||
|
admin_pw = os.getenv("ADMIN_PASSWORD", "")
|
||||||
|
if not admin_pw:
|
||||||
|
admin_pw = secrets.token_urlsafe(16)
|
||||||
|
logger.warning(
|
||||||
|
"=" * 60 + "\n"
|
||||||
|
" No ADMIN_PASSWORD set — generated: %s\n"
|
||||||
|
" Set ADMIN_PASSWORD env to use your own.\n" +
|
||||||
|
"=" * 60,
|
||||||
|
admin_pw,
|
||||||
|
)
|
||||||
|
await settings_service.create_admin_user("admin", hash_password(admin_pw))
|
||||||
|
logger.info("Admin user seeded from ENV")
|
||||||
|
|
||||||
|
# ---- Integrations ----
|
||||||
|
existing = await settings_service.get_integrations()
|
||||||
|
existing_types = {i["type"] for i in existing}
|
||||||
|
|
||||||
|
seed_integrations = [
|
||||||
|
{
|
||||||
|
"type": "weather",
|
||||||
|
"name": "Wetter (wttr.in)",
|
||||||
|
"config": {
|
||||||
|
"location": os.getenv("WEATHER_LOCATION", "Leverkusen"),
|
||||||
|
"location_secondary": os.getenv("WEATHER_LOCATION_SECONDARY", "Rab,Croatia"),
|
||||||
|
},
|
||||||
|
"enabled": True,
|
||||||
|
"display_order": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "news",
|
||||||
|
"name": "News (PostgreSQL)",
|
||||||
|
"config": {
|
||||||
|
"max_age_hours": int(os.getenv("NEWS_MAX_AGE_HOURS", "48")),
|
||||||
|
},
|
||||||
|
"enabled": True,
|
||||||
|
"display_order": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "ha",
|
||||||
|
"name": "Home Assistant",
|
||||||
|
"config": {
|
||||||
|
"url": os.getenv("HA_URL", ""),
|
||||||
|
"token": os.getenv("HA_TOKEN", ""),
|
||||||
|
},
|
||||||
|
"enabled": bool(os.getenv("HA_URL")),
|
||||||
|
"display_order": 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "vikunja",
|
||||||
|
"name": "Vikunja Tasks",
|
||||||
|
"config": {
|
||||||
|
"url": os.getenv("VIKUNJA_URL", ""),
|
||||||
|
"token": os.getenv("VIKUNJA_TOKEN", ""),
|
||||||
|
"private_projects": [3, 4],
|
||||||
|
"sams_projects": [2, 5],
|
||||||
|
},
|
||||||
|
"enabled": bool(os.getenv("VIKUNJA_URL")),
|
||||||
|
"display_order": 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "unraid",
|
||||||
|
"name": "Unraid Server",
|
||||||
|
"config": {
|
||||||
|
"servers": _parse_unraid_env(),
|
||||||
|
},
|
||||||
|
"enabled": bool(os.getenv("UNRAID_SERVERS")),
|
||||||
|
"display_order": 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "mqtt",
|
||||||
|
"name": "MQTT Broker",
|
||||||
|
"config": {
|
||||||
|
"host": os.getenv("MQTT_HOST", ""),
|
||||||
|
"port": int(os.getenv("MQTT_PORT", "1883")),
|
||||||
|
"username": os.getenv("MQTT_USERNAME", ""),
|
||||||
|
"password": os.getenv("MQTT_PASSWORD", ""),
|
||||||
|
"client_id": os.getenv("MQTT_CLIENT_ID", "daily-briefing"),
|
||||||
|
"topics": _parse_mqtt_topics(),
|
||||||
|
},
|
||||||
|
"enabled": bool(os.getenv("MQTT_HOST")),
|
||||||
|
"display_order": 5,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for seed in seed_integrations:
|
||||||
|
if seed["type"] not in existing_types:
|
||||||
|
await settings_service.upsert_integration(
|
||||||
|
type_name=seed["type"],
|
||||||
|
name=seed["name"],
|
||||||
|
config=seed["config"],
|
||||||
|
enabled=seed["enabled"],
|
||||||
|
display_order=seed["display_order"],
|
||||||
|
)
|
||||||
|
logger.info("Seeded integration: %s", seed["type"])
|
||||||
|
|
||||||
|
# ---- App Settings ----
|
||||||
|
existing_settings = await settings_service.get_all_settings()
|
||||||
|
if not existing_settings:
|
||||||
|
default_settings = [
|
||||||
|
("weather_cache_ttl", "1800", "int", "cache", "Wetter Cache TTL", "Sekunden"),
|
||||||
|
("ha_cache_ttl", "30", "int", "cache", "HA Cache TTL", "Sekunden"),
|
||||||
|
("vikunja_cache_ttl", "60", "int", "cache", "Vikunja Cache TTL", "Sekunden"),
|
||||||
|
("unraid_cache_ttl", "15", "int", "cache", "Unraid Cache TTL", "Sekunden"),
|
||||||
|
("news_cache_ttl", "300", "int", "cache", "News Cache TTL", "Sekunden"),
|
||||||
|
("ws_interval", "15", "int", "general", "WebSocket Intervall", "Sekunden"),
|
||||||
|
]
|
||||||
|
for key, value, vtype, cat, label, desc in default_settings:
|
||||||
|
await settings_service.set_setting(key, value, vtype, cat, label, desc)
|
||||||
|
logger.info("Seeded %d default settings", len(default_settings))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_unraid_env() -> list:
|
||||||
|
"""Parse UNRAID_SERVERS env var."""
|
||||||
|
raw = os.getenv("UNRAID_SERVERS", "[]")
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_mqtt_topics() -> list:
|
||||||
|
"""Parse MQTT_TOPICS env var."""
|
||||||
|
raw = os.getenv("MQTT_TOPICS", "#")
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return [t.strip() for t in raw.split(",") if t.strip()]
|
||||||
297
server/services/settings_service.py
Normal file
297
server/services/settings_service.py
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
"""Database-backed settings, integrations, and user management."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from server.db import get_pool
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Admin User
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def get_admin_user() -> Optional[Dict[str, Any]]:
|
||||||
|
"""Return the admin user row, or None if not yet created."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow("SELECT * FROM admin_user LIMIT 1")
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def create_admin_user(username: str, password_hash: str) -> None:
|
||||||
|
"""Insert the initial admin user."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO admin_user (username, password_hash) VALUES ($1, $2)",
|
||||||
|
username,
|
||||||
|
password_hash,
|
||||||
|
)
|
||||||
|
logger.info("Admin user '%s' created", username)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_admin_password(user_id: int, password_hash: str) -> None:
|
||||||
|
"""Update the admin user's password."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE admin_user SET password_hash = $1, updated_at = NOW() WHERE id = $2",
|
||||||
|
password_hash,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
logger.info("Admin password updated (user_id=%d)", user_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# App Settings (key/value)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def get_all_settings() -> Dict[str, Any]:
|
||||||
|
"""Return all settings as a dict, casting values to their declared type."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch("SELECT * FROM app_settings ORDER BY category, key")
|
||||||
|
|
||||||
|
result: Dict[str, Any] = {}
|
||||||
|
for row in rows:
|
||||||
|
result[row["key"]] = {
|
||||||
|
"value": _cast_value(row["value"], row["value_type"]),
|
||||||
|
"value_type": row["value_type"],
|
||||||
|
"category": row["category"],
|
||||||
|
"label": row["label"],
|
||||||
|
"description": row["description"],
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def get_setting(key: str) -> Optional[Any]:
|
||||||
|
"""Return a single setting's typed value, or None."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow("SELECT value, value_type FROM app_settings WHERE key = $1", key)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return _cast_value(row["value"], row["value_type"])
|
||||||
|
|
||||||
|
|
||||||
|
async def set_setting(
|
||||||
|
key: str,
|
||||||
|
value: Any,
|
||||||
|
value_type: str = "string",
|
||||||
|
category: str = "general",
|
||||||
|
label: str = "",
|
||||||
|
description: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""Upsert a single setting."""
|
||||||
|
pool = await get_pool()
|
||||||
|
str_value = json.dumps(value) if value_type == "json" else str(value)
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO app_settings (key, value, value_type, category, label, description, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET
|
||||||
|
value = EXCLUDED.value,
|
||||||
|
value_type = EXCLUDED.value_type,
|
||||||
|
category = EXCLUDED.category,
|
||||||
|
label = EXCLUDED.label,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
updated_at = NOW()
|
||||||
|
""",
|
||||||
|
key, str_value, value_type, category, label, description,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def bulk_set_settings(settings_dict: Dict[str, Any]) -> None:
|
||||||
|
"""Bulk upsert settings from a flat key→value dict."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.transaction():
|
||||||
|
for key, val in settings_dict.items():
|
||||||
|
str_val = str(val)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE app_settings SET value = $1, updated_at = NOW()
|
||||||
|
WHERE key = $2
|
||||||
|
""",
|
||||||
|
str_val, key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cast_value(raw: str, value_type: str) -> Any:
|
||||||
|
"""Cast a stored string value to its declared type."""
|
||||||
|
if value_type == "int":
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 0
|
||||||
|
elif value_type == "bool":
|
||||||
|
return raw.lower() in ("1", "true", "yes")
|
||||||
|
elif value_type == "json":
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return raw
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integrations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def get_integrations() -> List[Dict[str, Any]]:
|
||||||
|
"""Return all integration configs."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT * FROM integrations ORDER BY display_order, type"
|
||||||
|
)
|
||||||
|
return [_integration_to_dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_integration(type_name: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Return a single integration by type name."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT * FROM integrations WHERE type = $1", type_name
|
||||||
|
)
|
||||||
|
return _integration_to_dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_integration(
|
||||||
|
type_name: str,
|
||||||
|
name: str,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
enabled: bool = True,
|
||||||
|
display_order: int = 0,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Insert or update an integration config."""
|
||||||
|
pool = await get_pool()
|
||||||
|
config_json = json.dumps(config)
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO integrations (type, name, config, enabled, display_order, updated_at)
|
||||||
|
VALUES ($1, $2, $3::jsonb, $4, $5, NOW())
|
||||||
|
ON CONFLICT (type) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
config = EXCLUDED.config,
|
||||||
|
enabled = EXCLUDED.enabled,
|
||||||
|
display_order = EXCLUDED.display_order,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
type_name, name, config_json, enabled, display_order,
|
||||||
|
)
|
||||||
|
return _integration_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def toggle_integration(type_name: str, enabled: bool) -> None:
|
||||||
|
"""Enable or disable an integration."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE integrations SET enabled = $1, updated_at = NOW() WHERE type = $2",
|
||||||
|
enabled, type_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _integration_to_dict(row: Any) -> Dict[str, Any]:
|
||||||
|
"""Convert an integration row to a dict."""
|
||||||
|
d = dict(row)
|
||||||
|
# Ensure config is a dict (asyncpg returns JSONB as dict already)
|
||||||
|
if isinstance(d.get("config"), str):
|
||||||
|
d["config"] = json.loads(d["config"])
|
||||||
|
# Convert datetimes
|
||||||
|
for k in ("created_at", "updated_at"):
|
||||||
|
if k in d and d[k] is not None:
|
||||||
|
d[k] = d[k].isoformat()
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MQTT Subscriptions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def get_mqtt_subscriptions() -> List[Dict[str, Any]]:
|
||||||
|
"""Return all MQTT subscriptions."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT * FROM mqtt_subscriptions ORDER BY display_order, id"
|
||||||
|
)
|
||||||
|
return [_sub_to_dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def create_mqtt_subscription(
|
||||||
|
topic_pattern: str,
|
||||||
|
display_name: str = "",
|
||||||
|
category: str = "other",
|
||||||
|
unit: str = "",
|
||||||
|
widget_type: str = "value",
|
||||||
|
enabled: bool = True,
|
||||||
|
display_order: int = 0,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Create a new MQTT subscription."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO mqtt_subscriptions
|
||||||
|
(topic_pattern, display_name, category, unit, widget_type, enabled, display_order)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
topic_pattern, display_name, category, unit, widget_type, enabled, display_order,
|
||||||
|
)
|
||||||
|
return _sub_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_mqtt_subscription(sub_id: int, **fields: Any) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Update specific fields of an MQTT subscription."""
|
||||||
|
pool = await get_pool()
|
||||||
|
allowed = {"topic_pattern", "display_name", "category", "unit", "widget_type", "enabled", "display_order"}
|
||||||
|
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||||
|
if not updates:
|
||||||
|
return None
|
||||||
|
|
||||||
|
set_parts = []
|
||||||
|
params = []
|
||||||
|
for i, (k, v) in enumerate(updates.items(), start=1):
|
||||||
|
set_parts.append(f"{k} = ${i}")
|
||||||
|
params.append(v)
|
||||||
|
params.append(sub_id)
|
||||||
|
set_clause = ", ".join(set_parts)
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"UPDATE mqtt_subscriptions SET {set_clause}, updated_at = NOW() "
|
||||||
|
f"WHERE id = ${len(params)} RETURNING *",
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
return _sub_to_dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_mqtt_subscription(sub_id: int) -> bool:
|
||||||
|
"""Delete an MQTT subscription. Returns True if deleted."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
result = await conn.execute(
|
||||||
|
"DELETE FROM mqtt_subscriptions WHERE id = $1", sub_id
|
||||||
|
)
|
||||||
|
return result == "DELETE 1"
|
||||||
|
|
||||||
|
|
||||||
|
def _sub_to_dict(row: Any) -> Dict[str, Any]:
|
||||||
|
d = dict(row)
|
||||||
|
for k in ("created_at", "updated_at"):
|
||||||
|
if k in d and d[k] is not None:
|
||||||
|
d[k] = d[k].isoformat()
|
||||||
|
return d
|
||||||
147
server/services/test_connections.py
Normal file
147
server/services/test_connections.py
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
"""Integration connection testing functions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TIMEOUT = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_weather(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Test weather service by fetching current conditions."""
|
||||||
|
location = config.get("location", "Leverkusen")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
||||||
|
r = await client.get(f"https://wttr.in/{location}?format=j1")
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
temp = data["current_condition"][0]["temp_C"]
|
||||||
|
return {"success": True, "message": f"Verbunden — {location}: {temp}°C"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"success": False, "message": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ha(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Test Home Assistant connection."""
|
||||||
|
url = config.get("url", "")
|
||||||
|
token = config.get("token", "")
|
||||||
|
if not url or not token:
|
||||||
|
return {"success": False, "message": "URL und Token sind erforderlich"}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=TIMEOUT, verify=False) as client:
|
||||||
|
r = await client.get(
|
||||||
|
f"{url.rstrip('/')}/api/",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
return {"success": True, "message": f"Verbunden — {data.get('message', 'OK')}"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"success": False, "message": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_vikunja(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Test Vikunja API connection."""
|
||||||
|
url = config.get("url", "")
|
||||||
|
token = config.get("token", "")
|
||||||
|
if not url or not token:
|
||||||
|
return {"success": False, "message": "URL und Token sind erforderlich"}
|
||||||
|
try:
|
||||||
|
base = url.rstrip("/")
|
||||||
|
# Try to reach the info or user endpoint
|
||||||
|
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
||||||
|
r = await client.get(
|
||||||
|
f"{base}/user",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
return {"success": True, "message": f"Verbunden als {data.get('username', 'OK')}"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"success": False, "message": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unraid(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Test Unraid server connectivity."""
|
||||||
|
servers = config.get("servers", [])
|
||||||
|
if not servers:
|
||||||
|
return {"success": False, "message": "Keine Server konfiguriert"}
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for srv in servers:
|
||||||
|
name = srv.get("name", srv.get("host", "?"))
|
||||||
|
host = srv.get("host", "")
|
||||||
|
port = srv.get("port", 80)
|
||||||
|
if not host:
|
||||||
|
results.append(f"{name}: Kein Host")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
r = await client.get(f"http://{host}:{port}/")
|
||||||
|
results.append(f"{name}: Online ({r.status_code})")
|
||||||
|
except Exception as exc:
|
||||||
|
results.append(f"{name}: Offline ({exc})")
|
||||||
|
|
||||||
|
all_ok = all("Online" in r for r in results)
|
||||||
|
return {
|
||||||
|
"success": all_ok,
|
||||||
|
"message": " | ".join(results),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mqtt(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Test MQTT broker connection."""
|
||||||
|
host = config.get("host", "")
|
||||||
|
port = int(config.get("port", 1883))
|
||||||
|
username = config.get("username") or None
|
||||||
|
password = config.get("password") or None
|
||||||
|
|
||||||
|
if not host:
|
||||||
|
return {"success": False, "message": "MQTT Host ist erforderlich"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
import aiomqtt
|
||||||
|
|
||||||
|
async with aiomqtt.Client(
|
||||||
|
hostname=host,
|
||||||
|
port=port,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
identifier="daily-briefing-test",
|
||||||
|
) as client:
|
||||||
|
# If we get here, connection succeeded
|
||||||
|
pass
|
||||||
|
return {"success": True, "message": f"Verbunden mit {host}:{port}"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"success": False, "message": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_db(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Test that market_news table is accessible."""
|
||||||
|
try:
|
||||||
|
from server.db import get_pool
|
||||||
|
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow("SELECT COUNT(*) AS cnt FROM market_news")
|
||||||
|
count = row["cnt"] if row else 0
|
||||||
|
return {"success": True, "message": f"Verbunden — {count} Artikel in der Datenbank"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"success": False, "message": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
# Map integration type → test function
|
||||||
|
TEST_FUNCTIONS = {
|
||||||
|
"weather": test_weather,
|
||||||
|
"ha": test_ha,
|
||||||
|
"vikunja": test_vikunja,
|
||||||
|
"unraid": test_unraid,
|
||||||
|
"mqtt": test_mqtt,
|
||||||
|
"news": test_news_db,
|
||||||
|
}
|
||||||
61
web/package-lock.json
generated
61
web/package-lock.json
generated
|
|
@ -10,7 +10,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^7.13.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
|
|
@ -1533,6 +1534,19 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
|
|
@ -2277,6 +2291,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
|
|
@ -2295,6 +2310,44 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "7.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
|
||||||
|
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
|
||||||
|
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.13.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
|
|
@ -2438,6 +2491,12 @@
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^7.13.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
|
|
|
||||||
205
web/src/App.tsx
205
web/src/App.tsx
|
|
@ -1,181 +1,40 @@
|
||||||
import { useDashboard } from "./hooks/useDashboard";
|
import { Routes, Route, Navigate } from "react-router-dom";
|
||||||
import Clock from "./components/Clock";
|
import Dashboard from "./pages/Dashboard";
|
||||||
import WeatherCard from "./components/WeatherCard";
|
import AdminLayout from "./admin/AdminLayout";
|
||||||
import HourlyForecast from "./components/HourlyForecast";
|
import LoginPage from "./admin/LoginPage";
|
||||||
import NewsGrid from "./components/NewsGrid";
|
import GeneralSettings from "./admin/pages/GeneralSettings";
|
||||||
import ServerCard from "./components/ServerCard";
|
import WeatherSettings from "./admin/pages/WeatherSettings";
|
||||||
import HomeAssistant from "./components/HomeAssistant";
|
import NewsSettings from "./admin/pages/NewsSettings";
|
||||||
import TasksCard from "./components/TasksCard";
|
import HASettings from "./admin/pages/HASettings";
|
||||||
import MqttCard from "./components/MqttCard";
|
import VikunjaSettings from "./admin/pages/VikunjaSettings";
|
||||||
import { RefreshCw, Wifi, WifiOff, AlertTriangle } from "lucide-react";
|
import UnraidSettings from "./admin/pages/UnraidSettings";
|
||||||
|
import MqttSettings from "./admin/pages/MqttSettings";
|
||||||
|
import ChangePassword from "./admin/pages/ChangePassword";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { data, loading, error, connected, refresh } = useDashboard();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen text-white">
|
<Routes>
|
||||||
{/* ---- Error banner ---- */}
|
{/* Dashboard */}
|
||||||
{error && (
|
<Route path="/" element={<Dashboard />} />
|
||||||
<div className="fixed top-0 inset-x-0 z-50 flex items-center justify-center gap-2 bg-red-500/15 border-b border-red-500/20 backdrop-blur-sm px-4 py-2 animate-slide-up">
|
|
||||||
<AlertTriangle className="w-4 h-4 text-red-400 flex-shrink-0" />
|
|
||||||
<p className="text-xs text-red-300">{error}</p>
|
|
||||||
<button
|
|
||||||
onClick={refresh}
|
|
||||||
className="ml-3 text-xs text-red-300 hover:text-white underline underline-offset-2 transition-colors"
|
|
||||||
>
|
|
||||||
Erneut versuchen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ---- Header bar ---- */}
|
{/* Admin Login */}
|
||||||
<header className="sticky top-0 z-40 border-b border-white/[0.04] bg-slate-950/80 backdrop-blur-lg">
|
<Route path="/admin/login" element={<LoginPage />} />
|
||||||
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
|
||||||
{/* Left: title + live indicator */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h1 className="text-base sm:text-lg font-bold tracking-tight text-white">
|
|
||||||
Daily Briefing
|
|
||||||
</h1>
|
|
||||||
<LiveIndicator connected={connected} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: clock + refresh */}
|
{/* Admin Panel (protected) */}
|
||||||
<div className="flex items-center gap-4">
|
<Route path="/admin" element={<AdminLayout />}>
|
||||||
<button
|
<Route index element={<Navigate to="general" replace />} />
|
||||||
onClick={refresh}
|
<Route path="general" element={<GeneralSettings />} />
|
||||||
disabled={loading}
|
<Route path="weather" element={<WeatherSettings />} />
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-lg bg-white/5 border border-white/[0.06] hover:bg-white/10 transition-colors disabled:opacity-40"
|
<Route path="news" element={<NewsSettings />} />
|
||||||
title="Daten aktualisieren"
|
<Route path="homeassistant" element={<HASettings />} />
|
||||||
>
|
<Route path="vikunja" element={<VikunjaSettings />} />
|
||||||
<RefreshCw className={`w-3.5 h-3.5 text-slate-400 ${loading ? "animate-spin" : ""}`} />
|
<Route path="unraid" element={<UnraidSettings />} />
|
||||||
</button>
|
<Route path="mqtt" element={<MqttSettings />} />
|
||||||
<Clock />
|
<Route path="password" element={<ChangePassword />} />
|
||||||
</div>
|
</Route>
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* ---- Main content ---- */}
|
{/* Catch all */}
|
||||||
<main className="max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
{loading && !data ? (
|
</Routes>
|
||||||
<LoadingSkeleton />
|
|
||||||
) : data ? (
|
|
||||||
<>
|
|
||||||
{/* Row 1: Weather cards + Hourly forecast */}
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
|
||||||
<WeatherCard data={data.weather.primary} accent="cyan" />
|
|
||||||
<WeatherCard data={data.weather.secondary} accent="amber" />
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<HourlyForecast slots={data.weather.hourly} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Row 2: Servers + Home Assistant + Tasks */}
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
|
||||||
{data.servers.servers.map((srv) => (
|
|
||||||
<ServerCard key={srv.name} server={srv} />
|
|
||||||
))}
|
|
||||||
<HomeAssistant data={data.ha} />
|
|
||||||
<TasksCard data={data.tasks} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Row 2.5: MQTT (only show if connected or has entities) */}
|
|
||||||
{(data.mqtt?.connected || (data.mqtt?.entities?.length ?? 0) > 0) && (
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
|
||||||
<div className="md:col-span-2 xl:col-span-4">
|
|
||||||
<MqttCard data={data.mqtt} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Row 3: News (full width) */}
|
|
||||||
<section>
|
|
||||||
<NewsGrid data={data.news} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Footer timestamp */}
|
|
||||||
<footer className="text-center pb-4">
|
|
||||||
<p className="text-[10px] text-slate-700">
|
|
||||||
Letzte Aktualisierung:{" "}
|
|
||||||
{new Date(data.timestamp).toLocaleTimeString("de-DE", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</footer>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Small pulsing dot indicating live WebSocket connection. */
|
|
||||||
function LiveIndicator({ connected }: { connected: boolean }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/5 border border-white/[0.06]">
|
|
||||||
<div className="relative">
|
|
||||||
<div
|
|
||||||
className={`w-1.5 h-1.5 rounded-full ${connected ? "bg-emerald-400" : "bg-slate-600"}`}
|
|
||||||
/>
|
|
||||||
{connected && (
|
|
||||||
<div className="absolute inset-0 w-1.5 h-1.5 rounded-full bg-emerald-400 animate-ping opacity-50" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className={`text-[10px] font-medium ${connected ? "text-emerald-400" : "text-slate-600"}`}>
|
|
||||||
{connected ? "Live" : "Offline"}
|
|
||||||
</span>
|
|
||||||
{connected ? (
|
|
||||||
<Wifi className="w-3 h-3 text-emerald-400/50" />
|
|
||||||
) : (
|
|
||||||
<WifiOff className="w-3 h-3 text-slate-600" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Skeleton loading state displayed on first load. */
|
|
||||||
function LoadingSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 animate-pulse">
|
|
||||||
{/* Row 1: Weather placeholders */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
|
||||||
<SkeletonCard className="h-52" />
|
|
||||||
<SkeletonCard className="h-52" />
|
|
||||||
<SkeletonCard className="h-24 md:col-span-2" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 2: Info cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
|
||||||
<SkeletonCard className="h-72" />
|
|
||||||
<SkeletonCard className="h-72" />
|
|
||||||
<SkeletonCard className="h-72" />
|
|
||||||
<SkeletonCard className="h-72" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 3: News */}
|
|
||||||
<div>
|
|
||||||
<div className="h-6 w-32 rounded bg-white/5 mb-4" />
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
|
||||||
<SkeletonCard key={i} className="h-28" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SkeletonCard({ className = "" }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`glass-card ${className}`}
|
|
||||||
>
|
|
||||||
<div className="p-5 space-y-3">
|
|
||||||
<div className="h-3 w-1/3 rounded bg-white/5" />
|
|
||||||
<div className="h-2 w-2/3 rounded bg-white/[0.03]" />
|
|
||||||
<div className="h-2 w-1/2 rounded bg-white/[0.03]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
147
web/src/admin/AdminLayout.tsx
Normal file
147
web/src/admin/AdminLayout.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Outlet, NavLink, useNavigate, Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Settings, Cloud, Newspaper, Home, ListTodo, Server, Radio,
|
||||||
|
Lock, ArrowLeft, Menu, X, LogOut, Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { isAuthenticated, verifyToken, clearAuth, getUsername } from "./api";
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ to: "general", label: "Allgemein", icon: Settings },
|
||||||
|
{ to: "weather", label: "Wetter", icon: Cloud },
|
||||||
|
{ to: "news", label: "News", icon: Newspaper },
|
||||||
|
{ to: "homeassistant", label: "Home Assistant", icon: Home },
|
||||||
|
{ to: "vikunja", label: "Vikunja", icon: ListTodo },
|
||||||
|
{ to: "unraid", label: "Unraid Server", icon: Server },
|
||||||
|
{ to: "mqtt", label: "MQTT", icon: Radio },
|
||||||
|
{ to: "password", label: "Passwort ändern", icon: Lock },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminLayout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [verified, setVerified] = useState(false);
|
||||||
|
const [checking, setChecking] = useState(true);
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated()) {
|
||||||
|
navigate("/admin/login", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
verifyToken().then((ok) => {
|
||||||
|
if (!ok) {
|
||||||
|
clearAuth();
|
||||||
|
navigate("/admin/login", { replace: true });
|
||||||
|
} else {
|
||||||
|
setVerified(true);
|
||||||
|
}
|
||||||
|
setChecking(false);
|
||||||
|
});
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
clearAuth();
|
||||||
|
navigate("/admin/login", { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (checking) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<Loader2 className="w-6 h-6 text-slate-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verified) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen text-white">
|
||||||
|
{/* Mobile header */}
|
||||||
|
<header className="lg:hidden sticky top-0 z-50 border-b border-white/[0.04] bg-slate-950/80 backdrop-blur-lg">
|
||||||
|
<div className="flex items-center justify-between px-4 h-14">
|
||||||
|
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="p-2 -ml-2 hover:bg-white/5 rounded-lg transition-colors">
|
||||||
|
{sidebarOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-semibold">Admin Panel</span>
|
||||||
|
<Link to="/" className="p-2 -mr-2 hover:bg-white/5 rounded-lg transition-colors">
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className={`
|
||||||
|
fixed inset-y-0 left-0 z-40 w-64 border-r border-white/[0.04] bg-slate-950/95 backdrop-blur-lg
|
||||||
|
transform transition-transform duration-200 ease-in-out
|
||||||
|
lg:relative lg:translate-x-0 lg:block
|
||||||
|
${sidebarOpen ? "translate-x-0" : "-translate-x-full"}
|
||||||
|
`}>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Sidebar header */}
|
||||||
|
<div className="hidden lg:flex items-center justify-between px-5 h-16 border-b border-white/[0.04]">
|
||||||
|
<h2 className="text-sm font-bold tracking-tight">Admin Panel</h2>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav items */}
|
||||||
|
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-1">
|
||||||
|
{NAV_ITEMS.map(({ to, label, icon: Icon }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150 ${
|
||||||
|
isActive
|
||||||
|
? "bg-white/10 text-white shadow-sm shadow-black/20"
|
||||||
|
: "text-slate-400 hover:text-white hover:bg-white/5"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-white/[0.04]">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-slate-500 truncate">{getUsername()}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-slate-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="w-3.5 h-3.5" />
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Overlay for mobile sidebar */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-30 bg-black/50 lg:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 min-h-screen lg:min-h-[calc(100vh)]">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
web/src/admin/LoginPage.tsx
Normal file
113
web/src/admin/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { useState, FormEvent } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Lock, User, AlertCircle, Loader2 } from "lucide-react";
|
||||||
|
import { login, isAuthenticated } from "./api";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Redirect if already logged in
|
||||||
|
if (isAuthenticated()) {
|
||||||
|
navigate("/admin", { replace: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
navigate("/admin", { replace: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Login fehlgeschlagen");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center px-4">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
{/* Logo / Title */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-gradient-to-br from-cyan-500/20 to-blue-500/20 border border-white/[0.08] mb-4">
|
||||||
|
<Lock className="w-6 h-6 text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-white tracking-tight">Daily Briefing</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">Admin Panel</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login Card */}
|
||||||
|
<div className="glass-card p-6">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-500/10 border border-red-500/20">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
|
||||||
|
<p className="text-xs text-red-300">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||||
|
Benutzername
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="admin"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white/5 border border-white/[0.08] text-sm text-white placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white/5 border border-white/[0.08] text-sm text-white placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !username || !password}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-500 text-sm font-semibold text-white hover:from-cyan-400 hover:to-blue-400 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Anmelden"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-[10px] text-slate-700 mt-6">
|
||||||
|
Daily Briefing v2.1 — Admin
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
web/src/admin/api.ts
Normal file
218
web/src/admin/api.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
/**
|
||||||
|
* Admin API client with JWT token management.
|
||||||
|
* Stores the token in localStorage and automatically attaches it to requests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = "/api";
|
||||||
|
const TOKEN_KEY = "admin_token";
|
||||||
|
const USERNAME_KEY = "admin_user";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Token Management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUsername(): string | null {
|
||||||
|
return localStorage.getItem(USERNAME_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAuth(token: string, username: string): void {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
localStorage.setItem(USERNAME_KEY, username);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuth(): void {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(USERNAME_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthenticated(): boolean {
|
||||||
|
return !!getToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function authFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||||
|
const token = getToken();
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(options.headers as Record<string, string> || {}),
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||||
|
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
clearAuth();
|
||||||
|
window.location.href = "/admin/login";
|
||||||
|
throw new Error("Sitzung abgelaufen");
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJSON<T>(path: string): Promise<T> {
|
||||||
|
const res = await authFetch(path);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.detail || `Fehler: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putJSON<T>(path: string, data: unknown): Promise<T> {
|
||||||
|
const res = await authFetch(path, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.detail || `Fehler: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJSON<T>(path: string, data?: unknown): Promise<T> {
|
||||||
|
const res = await authFetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.detail || `Fehler: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteJSON<T>(path: string): Promise<T> {
|
||||||
|
const res = await authFetch(path, { method: "DELETE" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.detail || `Fehler: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function login(username: string, password: string): Promise<{ token: string; username: string }> {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.detail || "Login fehlgeschlagen");
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setAuth(data.token, data.username);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyToken(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fetchJSON("/auth/me");
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||||
|
await putJSON("/auth/password", {
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Settings API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
[key: string]: { value: string; value_type: string; category: string; label: string; description: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSettings(): Promise<AppSettings> {
|
||||||
|
return fetchJSON("/admin/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSettings(settings: Record<string, unknown>): Promise<void> {
|
||||||
|
await putJSON("/admin/settings", { settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Integrations API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface Integration {
|
||||||
|
id: number;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
enabled: boolean;
|
||||||
|
display_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIntegrations(): Promise<Integration[]> {
|
||||||
|
return fetchJSON("/admin/integrations");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIntegration(type: string): Promise<Integration> {
|
||||||
|
return fetchJSON(`/admin/integrations/${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateIntegration(
|
||||||
|
type: string,
|
||||||
|
data: { name?: string; config?: Record<string, unknown>; enabled?: boolean; display_order?: number }
|
||||||
|
): Promise<Integration> {
|
||||||
|
return putJSON(`/admin/integrations/${type}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testIntegration(type: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
return postJSON(`/admin/integrations/${type}/test`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MQTT Subscriptions API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface MqttSubscription {
|
||||||
|
id: number;
|
||||||
|
topic_pattern: string;
|
||||||
|
display_name: string;
|
||||||
|
category: string;
|
||||||
|
unit: string;
|
||||||
|
widget_type: string;
|
||||||
|
enabled: boolean;
|
||||||
|
display_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMqttSubscriptions(): Promise<MqttSubscription[]> {
|
||||||
|
return fetchJSON("/admin/mqtt/subscriptions");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMqttSubscription(data: Omit<MqttSubscription, "id">): Promise<MqttSubscription> {
|
||||||
|
return postJSON("/admin/mqtt/subscriptions", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMqttSubscription(
|
||||||
|
id: number,
|
||||||
|
data: Partial<Omit<MqttSubscription, "id">>
|
||||||
|
): Promise<MqttSubscription> {
|
||||||
|
return putJSON(`/admin/mqtt/subscriptions/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMqttSubscription(id: number): Promise<void> {
|
||||||
|
await deleteJSON(`/admin/mqtt/subscriptions/${id}`);
|
||||||
|
}
|
||||||
63
web/src/admin/components/FormField.tsx
Normal file
63
web/src/admin/components/FormField.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FormField({ label, description, children }: Props) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-300 mb-1.5">{label}</label>
|
||||||
|
{description && <p className="text-[11px] text-slate-500 mb-2">{description}</p>}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
type = "text",
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
type?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-white/5 border border-white/[0.08] text-sm text-white placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NumberInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
onChange: (v: number) => void;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-white/5 border border-white/[0.08] text-sm text-white placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
web/src/admin/components/IntegrationForm.tsx
Normal file
98
web/src/admin/components/IntegrationForm.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { useState, ReactNode, FormEvent } from "react";
|
||||||
|
import { Loader2, Save, CheckCircle2 } from "lucide-react";
|
||||||
|
import { updateIntegration, type Integration } from "../api";
|
||||||
|
import TestButton from "./TestButton";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
integration: Integration;
|
||||||
|
onSaved: (updated: Integration) => void;
|
||||||
|
children: (config: Record<string, unknown>, setConfig: (key: string, value: unknown) => void) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IntegrationForm({ integration, onSaved, children }: Props) {
|
||||||
|
const [config, setConfigState] = useState<Record<string, unknown>>(integration.config || {});
|
||||||
|
const [enabled, setEnabled] = useState(integration.enabled);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const setConfig = (key: string, value: unknown) => {
|
||||||
|
setConfigState((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setSaved(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
setSaved(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await updateIntegration(integration.type, { config, enabled });
|
||||||
|
onSaved(updated);
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Speichern fehlgeschlagen");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Enable toggle */}
|
||||||
|
<div className="flex items-center justify-between p-4 rounded-xl bg-white/[0.02] border border-white/[0.06]">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">{integration.name}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">Integration {enabled ? "aktiv" : "deaktiviert"}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setEnabled(!enabled); setSaved(false); }}
|
||||||
|
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||||
|
enabled ? "bg-cyan-500" : "bg-slate-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${
|
||||||
|
enabled ? "translate-x-5" : "translate-x-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config fields (rendered by parent) */}
|
||||||
|
<div className={`space-y-4 transition-opacity ${enabled ? "opacity-100" : "opacity-40 pointer-events-none"}`}>
|
||||||
|
{children(config, setConfig)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/20">
|
||||||
|
<p className="text-xs text-red-300">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-500 text-sm font-semibold text-white hover:from-cyan-400 hover:to-blue-400 transition-all disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : saved ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{saving ? "Speichern..." : saved ? "Gespeichert!" : "Speichern"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<TestButton type={integration.type} disabled={!enabled} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
web/src/admin/components/PageHeader.tsx
Normal file
21
web/src/admin/components/PageHeader.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { type LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageHeader({ icon: Icon, title, description }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="flex items-center justify-center w-9 h-9 rounded-xl bg-gradient-to-br from-cyan-500/15 to-blue-500/15 border border-white/[0.06]">
|
||||||
|
<Icon className="w-4 h-4 text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-lg font-bold tracking-tight text-white">{title}</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500 ml-12">{description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
web/src/admin/components/TestButton.tsx
Normal file
59
web/src/admin/components/TestButton.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Loader2, CheckCircle2, XCircle, Zap } from "lucide-react";
|
||||||
|
import { testIntegration } from "../api";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
type: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TestButton({ type, disabled }: Props) {
|
||||||
|
const [status, setStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
setStatus("testing");
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
const result = await testIntegration(type);
|
||||||
|
setStatus(result.success ? "success" : "error");
|
||||||
|
setMessage(result.message);
|
||||||
|
} catch (err: any) {
|
||||||
|
setStatus("error");
|
||||||
|
setMessage(err.message || "Test fehlgeschlagen");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={disabled || status === "testing"}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-medium transition-all ${
|
||||||
|
status === "success"
|
||||||
|
? "bg-emerald-500/15 border border-emerald-500/30 text-emerald-400"
|
||||||
|
: status === "error"
|
||||||
|
? "bg-red-500/15 border border-red-500/30 text-red-400"
|
||||||
|
: "bg-white/5 border border-white/[0.08] text-slate-300 hover:bg-white/10 hover:text-white"
|
||||||
|
} disabled:opacity-40 disabled:cursor-not-allowed`}
|
||||||
|
>
|
||||||
|
{status === "testing" ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : status === "success" ? (
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
|
) : status === "error" ? (
|
||||||
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<Zap className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
{status === "testing" ? "Teste..." : "Verbindung testen"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<p className={`text-xs ${status === "success" ? "text-emerald-400/80" : "text-red-400/80"}`}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
web/src/admin/pages/ChangePassword.tsx
Normal file
114
web/src/admin/pages/ChangePassword.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { useState, FormEvent } from "react";
|
||||||
|
import { Lock, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||||
|
import PageHeader from "../components/PageHeader";
|
||||||
|
import FormField, { TextInput } from "../components/FormField";
|
||||||
|
import { changePassword } from "../api";
|
||||||
|
|
||||||
|
export default function ChangePassword() {
|
||||||
|
const [currentPw, setCurrentPw] = useState("");
|
||||||
|
const [newPw, setNewPw] = useState("");
|
||||||
|
const [confirmPw, setConfirmPw] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const canSubmit = currentPw.length > 0 && newPw.length >= 6 && newPw === confirmPw;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
setSaved(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await changePassword(currentPw, newPw);
|
||||||
|
setSaved(true);
|
||||||
|
setCurrentPw("");
|
||||||
|
setNewPw("");
|
||||||
|
setConfirmPw("");
|
||||||
|
setTimeout(() => setSaved(false), 5000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Passwort ändern fehlgeschlagen");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
icon={Lock}
|
||||||
|
title="Passwort ändern"
|
||||||
|
description="Admin-Passwort für den Login aktualisieren"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="glass-card p-6 max-w-md">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
{saved && (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-emerald-400 flex-shrink-0" />
|
||||||
|
<p className="text-xs text-emerald-300">Passwort erfolgreich geändert!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-500/10 border border-red-500/20">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
|
||||||
|
<p className="text-xs text-red-300">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField label="Aktuelles Passwort">
|
||||||
|
<TextInput
|
||||||
|
value={currentPw}
|
||||||
|
onChange={setCurrentPw}
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Neues Passwort" description="Mindestens 6 Zeichen">
|
||||||
|
<TextInput
|
||||||
|
value={newPw}
|
||||||
|
onChange={setNewPw}
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Neues Passwort bestätigen">
|
||||||
|
<TextInput
|
||||||
|
value={confirmPw}
|
||||||
|
onChange={setConfirmPw}
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{newPw && confirmPw && newPw !== confirmPw && (
|
||||||
|
<p className="text-xs text-amber-400">Passwörter stimmen nicht überein</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{newPw && newPw.length > 0 && newPw.length < 6 && (
|
||||||
|
<p className="text-xs text-amber-400">Mindestens 6 Zeichen erforderlich</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit || saving}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-500 text-sm font-semibold text-white hover:from-cyan-400 hover:to-blue-400 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Lock className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{saving ? "Wird geändert..." : "Passwort ändern"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
web/src/admin/pages/GeneralSettings.tsx
Normal file
136
web/src/admin/pages/GeneralSettings.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Settings, Loader2, Save, CheckCircle2 } from "lucide-react";
|
||||||
|
import PageHeader from "../components/PageHeader";
|
||||||
|
import FormField, { NumberInput } from "../components/FormField";
|
||||||
|
import { getSettings, updateSettings, type AppSettings } from "../api";
|
||||||
|
|
||||||
|
export default function GeneralSettings() {
|
||||||
|
const [settings, setSettings] = useState<AppSettings | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
// Local form values
|
||||||
|
const [wsInterval, setWsInterval] = useState(15);
|
||||||
|
const [weatherTtl, setWeatherTtl] = useState(600);
|
||||||
|
const [newsTtl, setNewsTtl] = useState(300);
|
||||||
|
const [serversTtl, setServersTtl] = useState(120);
|
||||||
|
const [haTtl, setHaTtl] = useState(60);
|
||||||
|
const [tasksTtl, setTasksTtl] = useState(180);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSettings = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getSettings();
|
||||||
|
setSettings(data);
|
||||||
|
|
||||||
|
// Populate form from loaded settings
|
||||||
|
if (data.ws_interval) setWsInterval(Number(data.ws_interval.value));
|
||||||
|
if (data.weather_cache_ttl) setWeatherTtl(Number(data.weather_cache_ttl.value));
|
||||||
|
if (data.news_cache_ttl) setNewsTtl(Number(data.news_cache_ttl.value));
|
||||||
|
if (data.unraid_cache_ttl) setServersTtl(Number(data.unraid_cache_ttl.value));
|
||||||
|
if (data.ha_cache_ttl) setHaTtl(Number(data.ha_cache_ttl.value));
|
||||||
|
if (data.tasks_cache_ttl) setTasksTtl(Number(data.tasks_cache_ttl.value));
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
setSaved(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateSettings({
|
||||||
|
ws_interval: wsInterval,
|
||||||
|
weather_cache_ttl: weatherTtl,
|
||||||
|
news_cache_ttl: newsTtl,
|
||||||
|
unraid_cache_ttl: serversTtl,
|
||||||
|
ha_cache_ttl: haTtl,
|
||||||
|
tasks_cache_ttl: tasksTtl,
|
||||||
|
});
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
icon={Settings}
|
||||||
|
title="Allgemeine Einstellungen"
|
||||||
|
description="Cache-Zeiten und Dashboard-Verhalten konfigurieren"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="glass-card p-6 space-y-6">
|
||||||
|
<h3 className="text-sm font-semibold text-white">WebSocket Intervall</h3>
|
||||||
|
<FormField label="Update-Intervall (Sekunden)" description="Wie oft das Dashboard automatisch neue Daten empfängt">
|
||||||
|
<NumberInput value={wsInterval} onChange={setWsInterval} min={5} max={120} step={5} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="border-t border-white/[0.04] my-2" />
|
||||||
|
|
||||||
|
<h3 className="text-sm font-semibold text-white">Cache TTLs (Sekunden)</h3>
|
||||||
|
<p className="text-xs text-slate-500">Wie lange gecachte Daten gültig sind bevor sie neu geladen werden</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<FormField label="Wetter">
|
||||||
|
<NumberInput value={weatherTtl} onChange={setWeatherTtl} min={60} max={3600} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="News">
|
||||||
|
<NumberInput value={newsTtl} onChange={setNewsTtl} min={60} max={3600} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Unraid Server">
|
||||||
|
<NumberInput value={serversTtl} onChange={setServersTtl} min={30} max={3600} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Home Assistant">
|
||||||
|
<NumberInput value={haTtl} onChange={setHaTtl} min={10} max={3600} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Tasks (Vikunja)">
|
||||||
|
<NumberInput value={tasksTtl} onChange={setTasksTtl} min={30} max={3600} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/20">
|
||||||
|
<p className="text-xs text-red-300">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-500 text-sm font-semibold text-white hover:from-cyan-400 hover:to-blue-400 transition-all disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : saved ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{saving ? "Speichern..." : saved ? "Gespeichert!" : "Speichern"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
web/src/admin/pages/HASettings.tsx
Normal file
81
web/src/admin/pages/HASettings.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Home, Loader2 } from "lucide-react";
|
||||||
|
import PageHeader from "../components/PageHeader";
|
||||||
|
import FormField, { TextInput } from "../components/FormField";
|
||||||
|
import IntegrationForm from "../components/IntegrationForm";
|
||||||
|
import { getIntegration, type Integration } from "../api";
|
||||||
|
|
||||||
|
export default function HASettings() {
|
||||||
|
const [integration, setIntegration] = useState<Integration | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadIntegration();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadIntegration = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getIntegration("ha");
|
||||||
|
setIntegration(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !integration) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader icon={Home} title="Home Assistant" description="Home Assistant konfigurieren" />
|
||||||
|
<div className="glass-card p-6">
|
||||||
|
<p className="text-sm text-red-400">{error || "Integration nicht gefunden"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
icon={Home}
|
||||||
|
title="Home Assistant"
|
||||||
|
description="Verbindung zu deiner Home Assistant Instanz konfigurieren"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="glass-card p-6">
|
||||||
|
<IntegrationForm integration={integration} onSaved={setIntegration}>
|
||||||
|
{(config, setConfig) => (
|
||||||
|
<>
|
||||||
|
<FormField label="Home Assistant URL" description="Basis-URL deiner HA-Instanz (inkl. Port)">
|
||||||
|
<TextInput
|
||||||
|
value={(config.url as string) || ""}
|
||||||
|
onChange={(v) => setConfig("url", v)}
|
||||||
|
placeholder="http://10.10.10.50:8123"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Long-Lived Access Token" description="Erstelle diesen Token in HA unter Profil → Langlebige Zugangstoken">
|
||||||
|
<TextInput
|
||||||
|
value={(config.token as string) || ""}
|
||||||
|
onChange={(v) => setConfig("token", v)}
|
||||||
|
type="password"
|
||||||
|
placeholder="eyJhbGciOi..."
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</IntegrationForm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
343
web/src/admin/pages/MqttSettings.tsx
Normal file
343
web/src/admin/pages/MqttSettings.tsx
Normal file
|
|
@ -0,0 +1,343 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Radio, Loader2, Plus, Trash2, Save, CheckCircle2, Edit2, X, Check,
|
||||||
|
} from "lucide-react";
|
||||||
|
import PageHeader from "../components/PageHeader";
|
||||||
|
import FormField, { TextInput, NumberInput } from "../components/FormField";
|
||||||
|
import IntegrationForm from "../components/IntegrationForm";
|
||||||
|
import {
|
||||||
|
getIntegration,
|
||||||
|
getMqttSubscriptions,
|
||||||
|
createMqttSubscription,
|
||||||
|
updateMqttSubscription,
|
||||||
|
deleteMqttSubscription,
|
||||||
|
type Integration,
|
||||||
|
type MqttSubscription,
|
||||||
|
} from "../api";
|
||||||
|
|
||||||
|
export default function MqttSettings() {
|
||||||
|
const [integration, setIntegration] = useState<Integration | null>(null);
|
||||||
|
const [subscriptions, setSubscriptions] = useState<MqttSubscription[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const [integ, subs] = await Promise.all([
|
||||||
|
getIntegration("mqtt"),
|
||||||
|
getMqttSubscriptions(),
|
||||||
|
]);
|
||||||
|
setIntegration(integ);
|
||||||
|
setSubscriptions(subs);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !integration) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader icon={Radio} title="MQTT" description="MQTT Broker konfigurieren" />
|
||||||
|
<div className="glass-card p-6">
|
||||||
|
<p className="text-sm text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
icon={Radio}
|
||||||
|
title="MQTT"
|
||||||
|
description="MQTT Broker und Topic-Subscriptions verwalten"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Broker Config */}
|
||||||
|
{integration && (
|
||||||
|
<div className="glass-card p-6">
|
||||||
|
<h3 className="text-sm font-semibold text-white mb-4">Broker-Verbindung</h3>
|
||||||
|
<IntegrationForm integration={integration} onSaved={setIntegration}>
|
||||||
|
{(config, setConfig) => (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<FormField label="Broker Host">
|
||||||
|
<TextInput
|
||||||
|
value={(config.host as string) || ""}
|
||||||
|
onChange={(v) => setConfig("host", v)}
|
||||||
|
placeholder="z.B. 10.10.10.50"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Port">
|
||||||
|
<NumberInput
|
||||||
|
value={Number(config.port) || 1883}
|
||||||
|
onChange={(v) => setConfig("port", v)}
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<FormField label="Benutzername" description="Optional">
|
||||||
|
<TextInput
|
||||||
|
value={(config.username as string) || ""}
|
||||||
|
onChange={(v) => setConfig("username", v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Passwort" description="Optional">
|
||||||
|
<TextInput
|
||||||
|
value={(config.password as string) || ""}
|
||||||
|
onChange={(v) => setConfig("password", v)}
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField label="Client ID" description="Eindeutige ID für diese MQTT-Verbindung">
|
||||||
|
<TextInput
|
||||||
|
value={(config.client_id as string) || ""}
|
||||||
|
onChange={(v) => setConfig("client_id", v)}
|
||||||
|
placeholder="daily-briefing"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</IntegrationForm>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subscriptions */}
|
||||||
|
<div className="glass-card p-6">
|
||||||
|
<SubscriptionManager
|
||||||
|
subscriptions={subscriptions}
|
||||||
|
onUpdate={setSubscriptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Subscription Manager sub-component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function SubscriptionManager({
|
||||||
|
subscriptions,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
subscriptions: MqttSubscription[];
|
||||||
|
onUpdate: (subs: MqttSubscription[]) => void;
|
||||||
|
}) {
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
// New subscription form
|
||||||
|
const [newTopic, setNewTopic] = useState("");
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [newCategory, setNewCategory] = useState("other");
|
||||||
|
const [newUnit, setNewUnit] = useState("");
|
||||||
|
const [newWidget, setNewWidget] = useState("value");
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!newTopic.trim()) return;
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const created = await createMqttSubscription({
|
||||||
|
topic_pattern: newTopic.trim(),
|
||||||
|
display_name: newName.trim() || newTopic.trim(),
|
||||||
|
category: newCategory,
|
||||||
|
unit: newUnit,
|
||||||
|
widget_type: newWidget,
|
||||||
|
enabled: true,
|
||||||
|
display_order: subscriptions.length,
|
||||||
|
});
|
||||||
|
onUpdate([...subscriptions, created]);
|
||||||
|
resetNewForm();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await deleteMqttSubscription(id);
|
||||||
|
onUpdate(subscriptions.filter((s) => s.id !== id));
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = async (sub: MqttSubscription) => {
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const updated = await updateMqttSubscription(sub.id, { enabled: !sub.enabled });
|
||||||
|
onUpdate(subscriptions.map((s) => (s.id === sub.id ? updated : s)));
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetNewForm = () => {
|
||||||
|
setAdding(false);
|
||||||
|
setNewTopic("");
|
||||||
|
setNewName("");
|
||||||
|
setNewCategory("other");
|
||||||
|
setNewUnit("");
|
||||||
|
setNewWidget("value");
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORIES = ["system", "sensor", "docker", "network", "other"];
|
||||||
|
const WIDGETS = ["value", "gauge", "switch", "badge"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-white">Topic-Subscriptions</h3>
|
||||||
|
<span className="text-xs text-slate-500">{subscriptions.length} Topics</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Existing subscriptions */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{subscriptions.map((sub) => (
|
||||||
|
<div
|
||||||
|
key={sub.id}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-xl border transition-colors ${
|
||||||
|
sub.enabled
|
||||||
|
? "bg-white/[0.02] border-white/[0.06]"
|
||||||
|
: "bg-white/[0.01] border-white/[0.03] opacity-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggle(sub)}
|
||||||
|
className={`relative w-9 h-5 rounded-full transition-colors flex-shrink-0 ${
|
||||||
|
sub.enabled ? "bg-cyan-500" : "bg-slate-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
|
||||||
|
sub.enabled ? "translate-x-4" : "translate-x-0"
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium text-white truncate">{sub.display_name || sub.topic_pattern}</p>
|
||||||
|
<p className="text-[10px] text-slate-500 font-mono truncate">{sub.topic_pattern}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-white/5 text-slate-400 flex-shrink-0">
|
||||||
|
{sub.category}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{sub.unit && (
|
||||||
|
<span className="text-[10px] text-slate-500 flex-shrink-0">{sub.unit}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(sub.id)}
|
||||||
|
className="p-1.5 rounded-lg text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{subscriptions.length === 0 && !adding && (
|
||||||
|
<p className="text-xs text-slate-600 text-center py-6">
|
||||||
|
Keine Subscriptions konfiguriert. Füge einen MQTT-Topic hinzu um Daten zu empfangen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add new */}
|
||||||
|
{adding ? (
|
||||||
|
<div className="p-4 rounded-xl bg-white/[0.03] border border-cyan-500/20 space-y-3">
|
||||||
|
<p className="text-xs font-semibold text-cyan-400">Neuer Topic</p>
|
||||||
|
|
||||||
|
<FormField label="Topic Pattern" description="z.B. unraid/server1/cpu/usage oder sensors/#">
|
||||||
|
<TextInput value={newTopic} onChange={setNewTopic} placeholder="unraid/+/cpu/usage" />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Anzeigename">
|
||||||
|
<TextInput value={newName} onChange={setNewName} placeholder="CPU Auslastung" />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<FormField label="Kategorie">
|
||||||
|
<select
|
||||||
|
value={newCategory}
|
||||||
|
onChange={(e) => setNewCategory(e.target.value)}
|
||||||
|
className="w-full px-3 py-2.5 rounded-xl bg-white/5 border border-white/[0.08] text-sm text-white focus:outline-none focus:border-cyan-500/50 transition-colors"
|
||||||
|
>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<option key={c} value={c} className="bg-slate-900">{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Einheit">
|
||||||
|
<TextInput value={newUnit} onChange={setNewUnit} placeholder="%, °C, MB" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Widget">
|
||||||
|
<select
|
||||||
|
value={newWidget}
|
||||||
|
onChange={(e) => setNewWidget(e.target.value)}
|
||||||
|
className="w-full px-3 py-2.5 rounded-xl bg-white/5 border border-white/[0.08] text-sm text-white focus:outline-none focus:border-cyan-500/50 transition-colors"
|
||||||
|
>
|
||||||
|
{WIDGETS.map((w) => (
|
||||||
|
<option key={w} value={w} className="bg-slate-900">{w}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={!newTopic.trim()}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-cyan-500/20 border border-cyan-500/30 text-xs font-medium text-cyan-400 hover:bg-cyan-500/30 transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
Hinzufügen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={resetNewForm}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-white/5 border border-white/[0.08] text-xs text-slate-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setAdding(true)}
|
||||||
|
className="flex items-center gap-2 w-full p-3 rounded-xl border border-dashed border-white/[0.1] text-xs text-slate-400 hover:text-white hover:border-white/[0.2] hover:bg-white/[0.02] transition-all"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Topic-Subscription hinzufügen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/20">
|
||||||
|
<p className="text-xs text-red-300">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
web/src/admin/pages/NewsSettings.tsx
Normal file
116
web/src/admin/pages/NewsSettings.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Newspaper, Loader2 } from "lucide-react";
|
||||||
|
import PageHeader from "../components/PageHeader";
|
||||||
|
import FormField, { NumberInput, TextInput } from "../components/FormField";
|
||||||
|
import IntegrationForm from "../components/IntegrationForm";
|
||||||
|
import { getIntegration, type Integration } from "../api";
|
||||||
|
|
||||||
|
export default function NewsSettings() {
|
||||||
|
const [integration, setIntegration] = useState<Integration | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadIntegration();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadIntegration = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getIntegration("news");
|
||||||
|
setIntegration(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !integration) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader icon={Newspaper} title="News" description="News-Datenbank konfigurieren" />
|
||||||
|
<div className="glass-card p-6">
|
||||||
|
<p className="text-sm text-red-400">{error || "Integration nicht gefunden"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
icon={Newspaper}
|
||||||
|
title="News"
|
||||||
|
description="News-Datenquelle und Anzeige konfigurieren"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="glass-card p-6">
|
||||||
|
<IntegrationForm integration={integration} onSaved={setIntegration}>
|
||||||
|
{(config, setConfig) => (
|
||||||
|
<>
|
||||||
|
<FormField label="Datenbank Host" description="PostgreSQL Host für die News-Datenbank">
|
||||||
|
<TextInput
|
||||||
|
value={(config.db_host as string) || ""}
|
||||||
|
onChange={(v) => setConfig("db_host", v)}
|
||||||
|
placeholder="z.B. 10.10.10.100"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<FormField label="Datenbank Port">
|
||||||
|
<NumberInput
|
||||||
|
value={Number(config.db_port) || 5432}
|
||||||
|
onChange={(v) => setConfig("db_port", v)}
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Datenbank Name">
|
||||||
|
<TextInput
|
||||||
|
value={(config.db_name as string) || ""}
|
||||||
|
onChange={(v) => setConfig("db_name", v)}
|
||||||
|
placeholder="market_news"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<FormField label="Datenbank User">
|
||||||
|
<TextInput
|
||||||
|
value={(config.db_user as string) || ""}
|
||||||
|
onChange={(v) => setConfig("db_user", v)}
|
||||||
|
placeholder="postgres"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Datenbank Passwort">
|
||||||
|
<TextInput
|
||||||
|
value={(config.db_password as string) || ""}
|
||||||
|
onChange={(v) => setConfig("db_password", v)}
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField label="Max. Alter (Stunden)" description="Nur Artikel anzeigen die jünger sind als dieser Wert">
|
||||||
|
<NumberInput
|
||||||
|
value={Number(config.max_age_hours) || 72}
|
||||||
|
onChange={(v) => setConfig("max_age_hours", v)}
|
||||||
|
min={1}
|
||||||
|
max={720}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</IntegrationForm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
web/src/admin/pages/UnraidSettings.tsx
Normal file
219
web/src/admin/pages/UnraidSettings.tsx
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Server, Loader2, Plus, Trash2, Save, CheckCircle2 } from "lucide-react";
|
||||||
|
import PageHeader from "../components/PageHeader";
|
||||||
|
import FormField, { TextInput, NumberInput } from "../components/FormField";
|
||||||
|
import TestButton from "../components/TestButton";
|
||||||
|
import { getIntegration, updateIntegration, type Integration } from "../api";
|
||||||
|
|
||||||
|
interface ServerEntry {
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
api_key: string;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UnraidSettings() {
|
||||||
|
const [integration, setIntegration] = useState<Integration | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [servers, setServers] = useState<ServerEntry[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadIntegration();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadIntegration = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getIntegration("unraid");
|
||||||
|
setIntegration(data);
|
||||||
|
setEnabled(data.enabled);
|
||||||
|
|
||||||
|
const cfg = data.config || {};
|
||||||
|
const rawServers = cfg.servers as ServerEntry[] | undefined;
|
||||||
|
if (Array.isArray(rawServers) && rawServers.length > 0) {
|
||||||
|
setServers(rawServers);
|
||||||
|
} else {
|
||||||
|
// Legacy single-server or empty
|
||||||
|
setServers([]);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addServer = () => {
|
||||||
|
setServers((prev) => [...prev, { name: "", host: "", api_key: "", port: 80 }]);
|
||||||
|
setSaved(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeServer = (index: number) => {
|
||||||
|
setServers((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
setSaved(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateServer = (index: number, field: keyof ServerEntry, value: string | number) => {
|
||||||
|
setServers((prev) => prev.map((s, i) => (i === index ? { ...s, [field]: value } : s)));
|
||||||
|
setSaved(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!integration) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
setSaved(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await updateIntegration(integration.type, {
|
||||||
|
enabled,
|
||||||
|
config: { servers },
|
||||||
|
});
|
||||||
|
setIntegration(updated);
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !integration) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader icon={Server} title="Unraid Server" description="Unraid-Server verwalten" />
|
||||||
|
<div className="glass-card p-6">
|
||||||
|
<p className="text-sm text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
icon={Server}
|
||||||
|
title="Unraid Server"
|
||||||
|
description="Unraid-Server hinzufügen, bearbeiten oder entfernen"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="glass-card p-6 space-y-6">
|
||||||
|
{/* Enable toggle */}
|
||||||
|
<div className="flex items-center justify-between p-4 rounded-xl bg-white/[0.02] border border-white/[0.06]">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">Unraid Integration</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">{enabled ? "Aktiv" : "Deaktiviert"}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setEnabled(!enabled); setSaved(false); }}
|
||||||
|
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||||
|
enabled ? "bg-cyan-500" : "bg-slate-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${
|
||||||
|
enabled ? "translate-x-5" : "translate-x-0"
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`space-y-4 transition-opacity ${enabled ? "opacity-100" : "opacity-40 pointer-events-none"}`}>
|
||||||
|
{/* Server list */}
|
||||||
|
{servers.map((srv, idx) => (
|
||||||
|
<div key={idx} className="p-4 rounded-xl bg-white/[0.02] border border-white/[0.06] space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-slate-300">Server #{idx + 1}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeServer(idx)}
|
||||||
|
className="p-1.5 rounded-lg text-slate-500 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
||||||
|
title="Server entfernen"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<FormField label="Name">
|
||||||
|
<TextInput
|
||||||
|
value={srv.name}
|
||||||
|
onChange={(v) => updateServer(idx, "name", v)}
|
||||||
|
placeholder="z.B. Main Server"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Host">
|
||||||
|
<TextInput
|
||||||
|
value={srv.host}
|
||||||
|
onChange={(v) => updateServer(idx, "host", v)}
|
||||||
|
placeholder="z.B. 10.10.10.100"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="API Key">
|
||||||
|
<TextInput
|
||||||
|
value={srv.api_key}
|
||||||
|
onChange={(v) => updateServer(idx, "api_key", v)}
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Port">
|
||||||
|
<NumberInput
|
||||||
|
value={srv.port}
|
||||||
|
onChange={(v) => updateServer(idx, "port", v)}
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add server button */}
|
||||||
|
<button
|
||||||
|
onClick={addServer}
|
||||||
|
className="flex items-center gap-2 w-full p-3 rounded-xl border border-dashed border-white/[0.1] text-xs text-slate-400 hover:text-white hover:border-white/[0.2] hover:bg-white/[0.02] transition-all"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Server hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/20">
|
||||||
|
<p className="text-xs text-red-300">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-500 text-sm font-semibold text-white hover:from-cyan-400 hover:to-blue-400 transition-all disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : saved ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{saving ? "Speichern..." : saved ? "Gespeichert!" : "Speichern"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{integration && <TestButton type={integration.type} disabled={!enabled} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
web/src/admin/pages/VikunjaSettings.tsx
Normal file
103
web/src/admin/pages/VikunjaSettings.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ListTodo, Loader2 } from "lucide-react";
|
||||||
|
import PageHeader from "../components/PageHeader";
|
||||||
|
import FormField, { TextInput } from "../components/FormField";
|
||||||
|
import IntegrationForm from "../components/IntegrationForm";
|
||||||
|
import { getIntegration, type Integration } from "../api";
|
||||||
|
|
||||||
|
export default function VikunjaSettings() {
|
||||||
|
const [integration, setIntegration] = useState<Integration | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadIntegration();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadIntegration = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getIntegration("vikunja");
|
||||||
|
setIntegration(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !integration) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader icon={ListTodo} title="Vikunja" description="Vikunja Task-Manager konfigurieren" />
|
||||||
|
<div className="glass-card p-6">
|
||||||
|
<p className="text-sm text-red-400">{error || "Integration nicht gefunden"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
icon={ListTodo}
|
||||||
|
title="Vikunja"
|
||||||
|
description="Vikunja-Instanz und Projekte für die Task-Anzeige konfigurieren"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="glass-card p-6">
|
||||||
|
<IntegrationForm integration={integration} onSaved={setIntegration}>
|
||||||
|
{(config, setConfig) => (
|
||||||
|
<>
|
||||||
|
<FormField label="Vikunja URL" description="Basis-URL deiner Vikunja-Instanz">
|
||||||
|
<TextInput
|
||||||
|
value={(config.url as string) || ""}
|
||||||
|
onChange={(v) => setConfig("url", v)}
|
||||||
|
placeholder="http://10.10.10.50:3456"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="API Token" description="Erstelle einen Token in Vikunja unter Einstellungen → API Tokens">
|
||||||
|
<TextInput
|
||||||
|
value={(config.token as string) || ""}
|
||||||
|
onChange={(v) => setConfig("token", v)}
|
||||||
|
type="password"
|
||||||
|
placeholder="tk_..."
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Private Projekt-IDs"
|
||||||
|
description="Komma-getrennte Liste von Projekt-IDs für den privaten Bereich"
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
value={(config.private_projects as string) || ""}
|
||||||
|
onChange={(v) => setConfig("private_projects", v)}
|
||||||
|
placeholder="z.B. 1,5,12"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Sam's Projekt-IDs"
|
||||||
|
description="Komma-getrennte Liste von Projekt-IDs für den gemeinsamen Bereich"
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
value={(config.sams_projects as string) || ""}
|
||||||
|
onChange={(v) => setConfig("sams_projects", v)}
|
||||||
|
placeholder="z.B. 3,8"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</IntegrationForm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
web/src/admin/pages/WeatherSettings.tsx
Normal file
80
web/src/admin/pages/WeatherSettings.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Cloud, Loader2 } from "lucide-react";
|
||||||
|
import PageHeader from "../components/PageHeader";
|
||||||
|
import FormField, { TextInput } from "../components/FormField";
|
||||||
|
import IntegrationForm from "../components/IntegrationForm";
|
||||||
|
import { getIntegration, type Integration } from "../api";
|
||||||
|
|
||||||
|
export default function WeatherSettings() {
|
||||||
|
const [integration, setIntegration] = useState<Integration | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadIntegration();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadIntegration = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getIntegration("weather");
|
||||||
|
setIntegration(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !integration) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader icon={Cloud} title="Wetter" description="Wetteranbieter konfigurieren" />
|
||||||
|
<div className="glass-card p-6">
|
||||||
|
<p className="text-sm text-red-400">{error || "Integration nicht gefunden"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
icon={Cloud}
|
||||||
|
title="Wetter"
|
||||||
|
description="Standorte für die Wettervorhersage konfigurieren (wttr.in)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="glass-card p-6">
|
||||||
|
<IntegrationForm integration={integration} onSaved={setIntegration}>
|
||||||
|
{(config, setConfig) => (
|
||||||
|
<>
|
||||||
|
<FormField label="Primärer Standort" description="Stadt oder Koordinaten für die Hauptanzeige">
|
||||||
|
<TextInput
|
||||||
|
value={(config.primary_location as string) || ""}
|
||||||
|
onChange={(v) => setConfig("primary_location", v)}
|
||||||
|
placeholder="z.B. Berlin oder 52.52,13.405"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Sekundärer Standort" description="Zweiter Standort für den Vergleich">
|
||||||
|
<TextInput
|
||||||
|
value={(config.secondary_location as string) || ""}
|
||||||
|
onChange={(v) => setConfig("secondary_location", v)}
|
||||||
|
placeholder="z.B. München oder 48.137,11.576"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</IntegrationForm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./styles/globals.css";
|
import "./styles/globals.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
149
web/src/pages/Dashboard.tsx
Normal file
149
web/src/pages/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useDashboard } from "../hooks/useDashboard";
|
||||||
|
import Clock from "../components/Clock";
|
||||||
|
import WeatherCard from "../components/WeatherCard";
|
||||||
|
import HourlyForecast from "../components/HourlyForecast";
|
||||||
|
import NewsGrid from "../components/NewsGrid";
|
||||||
|
import ServerCard from "../components/ServerCard";
|
||||||
|
import HomeAssistant from "../components/HomeAssistant";
|
||||||
|
import TasksCard from "../components/TasksCard";
|
||||||
|
import MqttCard from "../components/MqttCard";
|
||||||
|
import { RefreshCw, Wifi, WifiOff, AlertTriangle, Settings } from "lucide-react";
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { data, loading, error, connected, refresh } = useDashboard();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen text-white">
|
||||||
|
{error && (
|
||||||
|
<div className="fixed top-0 inset-x-0 z-50 flex items-center justify-center gap-2 bg-red-500/15 border-b border-red-500/20 backdrop-blur-sm px-4 py-2 animate-slide-up">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-red-400 flex-shrink-0" />
|
||||||
|
<p className="text-xs text-red-300">{error}</p>
|
||||||
|
<button onClick={refresh} className="ml-3 text-xs text-red-300 hover:text-white underline underline-offset-2 transition-colors">
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<header className="sticky top-0 z-40 border-b border-white/[0.04] bg-slate-950/80 backdrop-blur-lg">
|
||||||
|
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-base sm:text-lg font-bold tracking-tight text-white">Daily Briefing</h1>
|
||||||
|
<LiveIndicator connected={connected} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={refresh}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center justify-center w-8 h-8 rounded-lg bg-white/5 border border-white/[0.06] hover:bg-white/10 transition-colors disabled:opacity-40"
|
||||||
|
title="Daten aktualisieren"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 text-slate-400 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className="flex items-center justify-center w-8 h-8 rounded-lg bg-white/5 border border-white/[0.06] hover:bg-white/10 transition-colors"
|
||||||
|
title="Admin Panel"
|
||||||
|
>
|
||||||
|
<Settings className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
</Link>
|
||||||
|
<Clock />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
|
||||||
|
{loading && !data ? (
|
||||||
|
<LoadingSkeleton />
|
||||||
|
) : data ? (
|
||||||
|
<>
|
||||||
|
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||||
|
<WeatherCard data={data.weather.primary} accent="cyan" />
|
||||||
|
<WeatherCard data={data.weather.secondary} accent="amber" />
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<HourlyForecast slots={data.weather.hourly} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||||
|
{data.servers.servers.map((srv) => (
|
||||||
|
<ServerCard key={srv.name} server={srv} />
|
||||||
|
))}
|
||||||
|
<HomeAssistant data={data.ha} />
|
||||||
|
<TasksCard data={data.tasks} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{(data.mqtt?.connected || (data.mqtt?.entities?.length ?? 0) > 0) && (
|
||||||
|
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||||
|
<div className="md:col-span-2 xl:col-span-4">
|
||||||
|
<MqttCard data={data.mqtt} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<NewsGrid data={data.news} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="text-center pb-4">
|
||||||
|
<p className="text-[10px] text-slate-700">
|
||||||
|
Letzte Aktualisierung:{" "}
|
||||||
|
{new Date(data.timestamp).toLocaleTimeString("de-DE", {
|
||||||
|
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LiveIndicator({ connected }: { connected: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/5 border border-white/[0.06]">
|
||||||
|
<div className="relative">
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full ${connected ? "bg-emerald-400" : "bg-slate-600"}`} />
|
||||||
|
{connected && <div className="absolute inset-0 w-1.5 h-1.5 rounded-full bg-emerald-400 animate-ping opacity-50" />}
|
||||||
|
</div>
|
||||||
|
<span className={`text-[10px] font-medium ${connected ? "text-emerald-400" : "text-slate-600"}`}>
|
||||||
|
{connected ? "Live" : "Offline"}
|
||||||
|
</span>
|
||||||
|
{connected ? <Wifi className="w-3 h-3 text-emerald-400/50" /> : <WifiOff className="w-3 h-3 text-slate-600" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-pulse">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||||
|
<SkeletonCard className="h-52" />
|
||||||
|
<SkeletonCard className="h-52" />
|
||||||
|
<SkeletonCard className="h-24 md:col-span-2" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} className="h-72" />)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-6 w-32 rounded bg-white/5 mb-4" />
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => <SkeletonCard key={i} className="h-28" />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonCard({ className = "" }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`glass-card ${className}`}>
|
||||||
|
<div className="p-5 space-y-3">
|
||||||
|
<div className="h-3 w-1/3 rounded bg-white/5" />
|
||||||
|
<div className="h-2 w-2/3 rounded bg-white/[0.03]" />
|
||||||
|
<div className="h-2 w-1/2 rounded bg-white/[0.03]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue