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

+92 -15
View File
@@ -9,20 +9,33 @@ import {
} from "react";
import { api } from "@/api";
import type {
Item, ItemInput, ItemPatch, Prefs, RendererName, Section, StatusMap, Theme,
Board, Item, ItemInput, ItemPatch, Prefs, RendererName, Section, StatusMap, Theme,
} from "@/types";
import { parsePrefs } from "@/types";
import { setLocale } from "@/i18n";
// Board store — single renderer-agnostic source of truth for the board tree,
// reachability statuses, and persisted settings. Renderers consume via useBoard.
interface BoardState {
sections: Section[];
boards: Board[];
boardId: string;
boardSlug: string;
statuses: StatusMap;
theme: Theme;
renderer: RendererName;
accent: string;
wallpaper: string;
customCss: string;
locale: string;
loaded: boolean;
refresh(): Promise<void>;
// slug is the URL form; "" means the default (first) board.
setActiveSlug(slug: string): void;
addBoard(name: string): Promise<Board | null>;
renameBoard(id: string, name: string): Promise<void>;
deleteBoard(id: string): Promise<void>;
addSection(name: string): Promise<Section | null>;
patchSection(id: string, patch: { name?: string; collapsed?: boolean }): Promise<void>;
deleteSection(id: string): Promise<void>;
@@ -41,14 +54,22 @@ const Ctx = createContext<BoardState | null>(null);
export function BoardProvider({ children }: { children: ReactNode }) {
const [sections, setSections] = useState<Section[]>([]);
const [boards, setBoards] = useState<Board[]>([]);
const [activeSlug, setActiveSlug] = useState("");
const [statuses, setStatuses] = useState<StatusMap>({});
const [prefs, setPrefs] = useState<Prefs>({ theme: "dark", renderer: "bento" });
const [prefs, setPrefs] = useState<Prefs>({ theme: "dark", renderer: "bento", accent: "", wallpaper: "", customCss: "", locale: "" });
const [loaded, setLoaded] = useState(false);
// Resolve slug → id; unknown/empty slugs fall back to the first board.
const board = boards.find((b) => b.slug === activeSlug) ?? boards[0];
const boardId = board?.id ?? "";
const boardSlug = board?.slug ?? "";
const refresh = useCallback(async () => {
const { data } = await api.GET("/api/sections");
if (!boardId) return;
const { data } = await api.GET("/api/sections", { params: { query: { board: boardId } } });
if (data) setSections(data);
}, []);
}, [boardId]);
const refreshStatus = useCallback(async () => {
const { data } = await api.GET("/api/status");
@@ -57,11 +78,11 @@ export function BoardProvider({ children }: { children: ReactNode }) {
useEffect(() => {
void (async () => {
const [{ data: secs }, { data: cfg }] = await Promise.all([
api.GET("/api/sections"),
const [{ data: bs }, { data: cfg }] = await Promise.all([
api.GET("/api/boards"),
api.GET("/api/settings"),
]);
if (secs) setSections(secs);
if (bs) setBoards(bs);
if (cfg) setPrefs(parsePrefs(cfg));
setLoaded(true);
void refreshStatus();
@@ -70,7 +91,34 @@ export function BoardProvider({ children }: { children: ReactNode }) {
return () => clearInterval(t);
}, [refreshStatus]);
const { theme, renderer } = prefs;
// Sections reload whenever the active board changes.
useEffect(() => {
void refresh();
}, [refresh]);
const addBoard = useCallback(async (name: string) => {
const { data } = await api.POST("/api/boards", { body: { name } });
if (data) setBoards((bs) => [...bs, data]);
return data ?? null;
}, []);
const renameBoard = useCallback(async (id: string, name: string) => {
const { data } = await api.PATCH("/api/boards/{id}", { params: { path: { id } }, body: { name } });
if (data) setBoards((bs) => bs.map((b) => (b.id === id ? data : b)));
}, []);
const deleteBoard = useCallback(async (id: string) => {
const { error } = await api.DELETE("/api/boards/{id}", { params: { path: { id } } });
if (!error) {
setBoards((bs) => bs.filter((b) => b.id !== id));
setActiveSlug("");
}
}, []);
const { theme, renderer, accent, wallpaper, customCss, locale } = prefs;
// t() reads a module-level dict; this effect swaps it when the pref lands.
useEffect(() => setLocale(locale), [locale]);
// Apply theme: <html data-theme> drives all CSS vars. localStorage mirrors
// the server setting so index.html can pre-paint without a flash.
@@ -80,11 +128,36 @@ export function BoardProvider({ children }: { children: ReactNode }) {
localStorage.setItem("dash.theme", theme);
}, [theme, loaded]);
// Appearance overrides: accent is a CSS var the whole theme references,
// wallpaper hangs off <body>, customCss lands in a managed <style> tag.
useEffect(() => {
if (!loaded) return;
const root = document.documentElement;
if (accent) root.style.setProperty("--accent", accent);
else root.style.removeProperty("--accent");
// Quotes/backslashes stripped so the URL can't break out of url("…").
const safeWallpaper = wallpaper.replace(/["\\()]/g, "");
document.body.style.backgroundImage = safeWallpaper ? `url("${safeWallpaper}")` : "";
document.body.style.backgroundSize = wallpaper ? "cover" : "";
document.body.style.backgroundAttachment = wallpaper ? "fixed" : "";
let tag = document.getElementById("dash-custom-css");
if (customCss) {
if (!tag) {
tag = document.createElement("style");
tag.id = "dash-custom-css";
document.head.appendChild(tag);
}
tag.textContent = customCss;
} else {
tag?.remove();
}
}, [accent, wallpaper, customCss, loaded]);
const addSection = useCallback(async (name: string) => {
const { data } = await api.POST("/api/sections", { body: { name } });
const { data } = await api.POST("/api/sections", { body: { name, boardId } });
if (data) setSections((s) => [...s, data]);
return data ?? null;
}, []);
}, [boardId]);
const patchSection = useCallback(async (id: string, patch: { name?: string; collapsed?: boolean }) => {
setSections((ss) => ss.map((s) => (s.id === id ? { ...s, ...patch } : s)));
@@ -195,22 +268,26 @@ export function BoardProvider({ children }: { children: ReactNode }) {
const setSetting = useCallback(async (key: keyof Prefs, value: string) => {
setPrefs((p) => ({ ...p, [key]: value }));
// Prefs uses camelCase; wire keys keep the snake_case convention.
const wireKey = key === "customCss" ? "custom_css" : key;
const { data, error } = await api.PUT("/api/settings", {
body: { [key]: value },
body: { [wireKey]: value },
});
if (error) await refresh();
else if (data) setPrefs(parsePrefs(data));
}, [refresh]);
const value = useMemo<BoardState>(() => ({
sections, statuses, theme, renderer, loaded,
refresh, addSection, patchSection, deleteSection,
sections, boards, boardId, boardSlug, statuses, theme, renderer, accent, wallpaper, customCss, locale, loaded,
refresh, setActiveSlug, addBoard, renameBoard, deleteBoard,
addSection, patchSection, deleteSection,
addItem, patchItem, deleteItem, uploadIcon,
moveItemLocal, moveSectionLocal, persistItemMove, persistSectionMove,
setSetting,
}), [
sections, statuses, theme, renderer, loaded,
refresh, addSection, patchSection, deleteSection,
sections, boards, boardId, boardSlug, statuses, theme, renderer, accent, wallpaper, customCss, locale, loaded,
refresh, addBoard, renameBoard, deleteBoard,
addSection, patchSection, deleteSection,
addItem, patchItem, deleteItem, uploadIcon,
moveItemLocal, moveSectionLocal, persistItemMove, persistSectionMove,
setSetting,