refactor: complete rewrite as React+FastAPI dashboard
Replace monolithic Jinja2 template with modern stack: Backend (FastAPI): - Modular router/service architecture - Async PostgreSQL (asyncpg) for news from n8n pipeline - Live Unraid server stats (2 servers via API) - Home Assistant, Vikunja tasks, weather (wttr.in) - WebSocket broadcast for real-time updates (15s) - TTL cache per endpoint, all config via ENV vars Frontend (React + Vite + TypeScript): - Glassmorphism dark theme with Tailwind CSS - Responsive grid: mobile/tablet/desktop/ultrawide - Weather cards, hourly forecast, news with category tabs - Server stats (CPU ring, RAM bar, Docker list) - Home Assistant controls, task management - Live clock, WebSocket connection indicator Infrastructure: - Multi-stage Dockerfile (node:22-alpine + python:3.11-slim) - docker-compose with full ENV configuration - Kaniko CI/CD pipeline for GitLab registry Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4bbc125a67
commit
9f7330e217
48 changed files with 6390 additions and 1461 deletions
153
server/services/news_service.py
Normal file
153
server/services/news_service.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
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
|
||||
|
||||
|
||||
def _row_to_dict(row: asyncpg.Record) -> Dict[str, Any]:
|
||||
"""Convert an asyncpg Record to a plain dictionary with JSON-safe values."""
|
||||
d: Dict[str, Any] = dict(row)
|
||||
if "published_at" in d and d["published_at"] is not None:
|
||||
d["published_at"] = d["published_at"].isoformat()
|
||||
return d
|
||||
|
||||
|
||||
async def get_news(
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
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.")
|
||||
|
||||
params: List[Any] = []
|
||||
param_idx = 1
|
||||
|
||||
base_query = (
|
||||
"SELECT id, source, title, url, category, published_at "
|
||||
"FROM market_news "
|
||||
f"WHERE published_at > NOW() - INTERVAL '{int(max_age_hours)} hours'"
|
||||
)
|
||||
|
||||
if category is not None:
|
||||
base_query += f" AND category = ${param_idx}"
|
||||
params.append(category)
|
||||
param_idx += 1
|
||||
|
||||
base_query += f" ORDER BY published_at DESC LIMIT ${param_idx} OFFSET ${param_idx + 1}"
|
||||
params.append(limit)
|
||||
params.append(offset)
|
||||
|
||||
async with _pool.acquire() as conn:
|
||||
rows = await conn.fetch(base_query, *params)
|
||||
|
||||
return [_row_to_dict(row) for row in rows]
|
||||
|
||||
|
||||
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.")
|
||||
|
||||
params: List[Any] = []
|
||||
param_idx = 1
|
||||
|
||||
query = (
|
||||
"SELECT COUNT(*) AS cnt "
|
||||
"FROM market_news "
|
||||
f"WHERE published_at > NOW() - INTERVAL '{int(max_age_hours)} hours'"
|
||||
)
|
||||
|
||||
if category is not None:
|
||||
query += f" AND category = ${param_idx}"
|
||||
params.append(category)
|
||||
|
||||
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.")
|
||||
|
||||
query = (
|
||||
"SELECT DISTINCT category "
|
||||
"FROM market_news "
|
||||
f"WHERE published_at > NOW() - INTERVAL '{int(max_age_hours)} hours' "
|
||||
"AND category IS NOT NULL "
|
||||
"ORDER BY category"
|
||||
)
|
||||
|
||||
async with _pool.acquire() as conn:
|
||||
rows = await conn.fetch(query)
|
||||
|
||||
return [row["category"] for row in rows]
|
||||
Loading…
Add table
Add a link
Reference in a new issue