feat(ui): phase 5 — graphs, widgets, theming, PWA, boards, i18n

P5a: 24h uptime bar strip + latency chart on monitor detail, iframe
embed widget, generic JSON-path widget, accent/wallpaper/custom CSS
settings, PWA manifest + service worker, demo GIF.

P5b: transport picker + per-transport fields + test button in
settings; per-monitor alert-rule fields in dialogs.

P5c: container cpu/mem columns, SMART/ZFS/GPU sections on system
detail, subdomains section on domain detail.

P5d: login/setup page + auth middleware client handling, board
switcher + /b/:slug routes, full string extraction to typed t() keys
with {param} interpolation, complete cs locale, language select.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-22 12:49:51 +02:00
1 parent f3143af351
commit e0ef60eca6
49 files changed
+2131 -426

No files matched your search

+33 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { Link, Route, useLocation } from "wouter";
import { Activity, AlertTriangle, Globe, LayoutGrid, Megaphone, Moon, Plus, Search, Server, Settings, Sun } from "lucide-react";
import { BoardProvider, useBoard } from "@/board/store";
import { BoardSwitcher } from "@/components/BoardSwitcher";
import { Board } from "@/board/Board";
import { ServiceDialog } from "@/components/ServiceDialog";
import { WidgetDialog } from "@/components/WidgetDialog";
@@ -16,6 +17,8 @@ import { SystemDetailPage } from "@/pages/SystemDetailPage";
import { IncidentsPage } from "@/pages/IncidentsPage";
import { StatusPagesPage } from "@/pages/StatusPagesPage";
import { StatusPublicPage } from "@/pages/StatusPublicPage";
import { LoginPage } from "@/pages/LoginPage";
import { api } from "@/api";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { t, type Key } from "@/i18n";
@@ -41,6 +44,8 @@ const NAV: { href: string; labelKey: Key; icon: typeof LayoutGrid }[] = [
function Shell() {
const board = useBoard();
const [loc] = useLocation();
// null = still checking; gate blocks everything except public status pages.
const [auth, setAuth] = useState<{ enabled?: boolean; setup?: boolean; authed?: boolean } | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const [svcOpen, setSvcOpen] = useState(false);
@@ -64,12 +69,34 @@ function Shell() {
return () => window.removeEventListener("keydown", onKey);
}, []);
// Auth gate: check the session once, and again whenever the client sees a
// 401 (middleware in api.ts dispatches dash:unauthorized).
useEffect(() => {
const load = () => api.GET("/api/auth/session").then(({ data }) => setAuth(data ?? {}));
load();
window.addEventListener("dash:unauthorized", load);
return () => window.removeEventListener("dash:unauthorized", load);
}, []);
// URL is the board source of truth: / -> default, /b/<slug> -> that board.
const setActiveSlug = board.setActiveSlug;
useEffect(() => {
setActiveSlug(loc.startsWith("/b/") ? decodeURIComponent(loc.slice(3)) : "");
}, [loc, setActiveSlug]);
function openEditor(it: Item) {
setEditing(it);
if (it.kind === "widget") setWdgOpen(true);
else setSvcOpen(true);
}
// Public status pages stay reachable when auth is on — the backend serves
// their API unauthenticated, so the SPA must let the route through too.
const publicPage = loc.startsWith("/status/");
if (auth?.enabled && !auth.authed && !publicPage) {
return <LoginPage setup={!!auth.setup} onDone={() => location.reload()} />;
}
return (
<div className="font-sans">
<header className="sticky top-0 z-10 flex items-center gap-3.5 border-b border-border bg-bg/80 px-7 py-3.5 backdrop-blur-md">
@@ -82,9 +109,10 @@ function Shell() {
</svg>
Dash
</div>
<nav className="ml-6 flex items-center gap-1" aria-label="Primary">
<BoardSwitcher />
<nav className="ml-4 flex items-center gap-1" aria-label="Primary">
{NAV.map((n) => {
const active = n.href === "/" ? loc === "/" : loc.startsWith(n.href);
const active = n.href === "/" ? loc === "/" || loc.startsWith("/b/") : loc.startsWith(n.href);
return (
<Link
key={n.href}
@@ -138,6 +166,9 @@ function Shell() {
<Route path="/">
<Board onEditItem={openEditor} />
</Route>
<Route path="/b/:slug">
<Board onEditItem={openEditor} />
</Route>
<Route path="/monitors">
<MonitorsPage />
</Route>