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
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