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

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

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

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

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

View file

@ -1,43 +1,11 @@
"""News service — queries market_news from PostgreSQL via shared pool."""
from __future__ import annotations
import asyncpg
from typing import Any, Dict, List, Optional
_pool: Optional[asyncpg.Pool] = None
async def init_pool(
host: str,
port: int,
dbname: str,
user: str,
password: str,
) -> None:
"""Initialise the global asyncpg connection pool.
Call once at application startup.
"""
global _pool
_pool = await asyncpg.create_pool(
host=host,
port=port,
database=dbname,
user=user,
password=password,
min_size=1,
max_size=5,
)
async def close_pool() -> None:
"""Close the global asyncpg connection pool.
Call once at application shutdown.
"""
global _pool
if _pool is not None:
await _pool.close()
_pool = None
from server.db import get_pool
def _row_to_dict(row: asyncpg.Record) -> Dict[str, Any]:
@ -54,19 +22,8 @@ async def get_news(
category: Optional[str] = None,
max_age_hours: int = 48,
) -> List[Dict[str, Any]]:
"""Fetch recent news articles from the market_news table.
Args:
limit: Maximum number of rows to return.
offset: Number of rows to skip (for pagination).
category: Optional category filter (exact match).
max_age_hours: Only return articles published within this many hours.
Returns:
List of news article dictionaries.
"""
if _pool is None:
raise RuntimeError("Database pool is not initialised. Call init_pool() first.")
"""Fetch recent news articles from the market_news table."""
pool = await get_pool()
params: List[Any] = []
param_idx = 1
@ -86,7 +43,7 @@ async def get_news(
params.append(limit)
params.append(offset)
async with _pool.acquire() as conn:
async with pool.acquire() as conn:
rows = await conn.fetch(base_query, *params)
return [_row_to_dict(row) for row in rows]
@ -96,17 +53,8 @@ async def get_news_count(
max_age_hours: int = 48,
category: Optional[str] = None,
) -> int:
"""Return the total count of recent news articles.
Args:
max_age_hours: Only count articles published within this many hours.
category: Optional category filter.
Returns:
Integer count.
"""
if _pool is None:
raise RuntimeError("Database pool is not initialised. Call init_pool() first.")
"""Return the total count of recent news articles."""
pool = await get_pool()
params: List[Any] = []
param_idx = 1
@ -121,23 +69,15 @@ async def get_news_count(
query += f" AND category = ${param_idx}"
params.append(category)
async with _pool.acquire() as conn:
async with pool.acquire() as conn:
row = await conn.fetchrow(query, *params)
return int(row["cnt"]) if row else 0
async def get_categories(max_age_hours: int = 48) -> List[str]:
"""Return distinct categories from recent news articles.
Args:
max_age_hours: Only consider articles published within this many hours.
Returns:
Sorted list of category strings.
"""
if _pool is None:
raise RuntimeError("Database pool is not initialised. Call init_pool() first.")
"""Return distinct categories from recent news articles."""
pool = await get_pool()
query = (
"SELECT DISTINCT category "
@ -147,7 +87,7 @@ async def get_categories(max_age_hours: int = 48) -> List[str]:
"ORDER BY category"
)
async with _pool.acquire() as conn:
async with pool.acquire() as conn:
rows = await conn.fetch(query)
return [row["category"] for row in rows]