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

+4
View File
@@ -3,6 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0a0a0a" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<title>Dash</title>
<script>
// Pre-paint theme: localStorage mirrors the server setting, applied
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="104" fill="#0a0a0a"/>
<g fill="#3b82f6">
<rect x="120" y="120" width="124" height="124" rx="28"/>
<rect x="268" y="120" width="124" height="124" rx="28" fill="#6b9ef8"/>
<rect x="120" y="268" width="124" height="124" rx="28" fill="#1d4fd7"/>
<rect x="268" y="268" width="124" height="124" rx="28"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 431 B

+16
View File
@@ -0,0 +1,16 @@
{
"name": "Dash",
"short_name": "Dash",
"description": "Self-hosted homelab dashboard",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#0a0a0a",
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+57
View File
@@ -0,0 +1,57 @@
// Dash service worker: cache-first for static assets, network-first for /api.
// Bump CACHE on each deploy shape change; clients drop stale caches on activate.
const CACHE = "dash-v1";
const ASSET = /\.(?:js|css|woff2?|png|svg|webmanifest|ico)$/;
self.addEventListener("install", (e) => {
self.skipWaiting();
e.waitUntil(caches.open(CACHE).then((c) => c.add("/")));
});
self.addEventListener("activate", (e) => {
e.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (e) => {
const url = new URL(e.request.url);
if (e.request.method !== "GET" || url.origin !== self.location.origin) return;
// API: network-first so widgets stay live; fall back to cache offline.
if (url.pathname.startsWith("/api/")) {
e.respondWith(
fetch(e.request)
.then((res) => {
const clone = res.clone();
if (res.ok) caches.open(CACHE).then((c) => c.put(e.request, clone));
return res;
})
.catch(() => caches.match(e.request)),
);
return;
}
// Navigations: network-first, fall back to cached "/" (read-only offline).
if (e.request.mode === "navigate") {
e.respondWith(fetch(e.request).catch(() => caches.match("/")));
return;
}
// Static assets: cache-first, populate on miss.
if (ASSET.test(url.pathname)) {
e.respondWith(
caches.match(e.request).then(
(hit) =>
hit ||
fetch(e.request).then((res) => {
const clone = res.clone();
if (res.ok) caches.open(CACHE).then((c) => c.put(e.request, clone));
return res;
}),
),
);
}
});
+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>
+12
View File
@@ -2,3 +2,15 @@ import { createClient } from "@dash/api-client";
/** Same-origin typed client; vite proxies /api in dev. */
export const api = createClient();
api.use({
onResponse({ request, response }) {
// A 401 from a real endpoint means auth is enabled and the session is
// gone — tell the shell to drop back to the login gate. Auth endpoints
// handle their own 401s (bad password shows inline).
if (response.status === 401 && !request.url.includes("/api/auth/")) {
window.dispatchEvent(new Event("dash:unauthorized"));
}
return response;
},
});
+6 -5
View File
@@ -6,6 +6,7 @@ import { renderers } from "@/renderers";
import type { Item } from "@/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { t } from "@/i18n";
// Board is the renderer-agnostic glue: state + dnd + sort contexts wired once,
// the active renderer supplies the chrome.
@@ -31,7 +32,7 @@ export function Board({ onEditItem }: { onEditItem: (item: Item) => void }) {
if (board.sections.length === 0) {
return (
<main className="mx-auto flex max-w-[1140px] flex-col items-center gap-4 px-7 pb-24 pt-24">
<p className="text-[13px] text-text-faint">No sections yet.</p>
<p className="text-[13px] text-text-faint">{t("board.empty")}</p>
<AddSectionRow
open={addingSection} setOpen={setAddingSection}
name={sectionName} setName={setSectionName} onSubmit={submitSection}
@@ -52,7 +53,7 @@ export function Board({ onEditItem }: { onEditItem: (item: Item) => void }) {
index={si}
onToggle={() => void board.patchSection(sec.id, { collapsed: !sec.collapsed })}
onDelete={() => {
if (window.confirm(`Delete section "${sec.name}" and its ${sec.items.length} items?`)) {
if (window.confirm(t("board.deleteSectionConfirm", { name: sec.name ?? "", count: sec.items.length }))) {
void board.deleteSection(sec.id);
}
}}
@@ -100,7 +101,7 @@ function AddSectionRow({
if (!open) {
return (
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
<Plus size={12} /> Add section
<Plus size={12} /> {t("board.addSection")}
</Button>
);
}
@@ -110,12 +111,12 @@ function AddSectionRow({
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Section name"
placeholder={t("board.sectionNamePlaceholder")}
className="w-[200px]"
onBlur={() => !name.trim() && setOpen(false)}
onKeyDown={(e) => e.key === "Escape" && setOpen(false)}
/>
<Button type="submit" size="sm">Add</Button>
<Button type="submit" size="sm">{t("common.add")}</Button>
</form>
);
}
+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,
@@ -0,0 +1,127 @@
import { useState } from "react";
import { useLocation } from "wouter";
import { Check, ChevronsUpDown, Pencil, Trash2 } from "lucide-react";
import { useBoard } from "@/board/store";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { t } from "@/i18n";
// BoardSwitcher — header control for navigating boards (/b/<slug>) and
// managing them. The store owns the data; this only routes and calls API.
export function BoardSwitcher() {
const board = useBoard();
const [, nav] = useLocation();
const [manage, setManage] = useState(false);
if (board.boards.length === 0) return null;
return (
<>
<Select value={board.boardSlug} onValueChange={(slug) => nav(slug === board.boards[0]?.slug ? "/" : `/b/${slug}`)}>
<SelectTrigger className="h-8 w-auto min-w-[110px] gap-1 border-none bg-transparent px-2 text-[13px] font-semibold shadow-none">
<SelectValue />
<ChevronsUpDown size={12} className="text-text-faint" />
</SelectTrigger>
<SelectContent>
{board.boards.map((b) => (
<SelectItem key={b.id} value={b.slug}>{b.name}</SelectItem>
))}
<button
type="button"
className="mt-1 w-full rounded-[6px] border-t border-border px-2 py-1.5 text-left text-[12px] text-text-faint hover:text-text"
onClick={() => setManage(true)}
>
{t("boards.manage")}
</button>
</SelectContent>
</Select>
<BoardManageDialog open={manage} onOpenChange={setManage} />
</>
);
}
function BoardManageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
const board = useBoard();
const [, nav] = useLocation();
const [name, setName] = useState("");
const [renaming, setRenaming] = useState<string | null>(null);
const [renameTo, setRenameTo] = useState("");
async function create() {
const n = name.trim();
if (!n) return;
const b = await board.addBoard(n);
setName("");
if (b) {
onOpenChange(false);
nav(`/b/${b.slug}`);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("boards.title")}</DialogTitle>
</DialogHeader>
<div className="grid gap-2">
{board.boards.map((b) => (
<div key={b.id} className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
{renaming === b.id ? (
<>
<Input
value={renameTo} autoFocus
onChange={(e) => setRenameTo(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void board.renameBoard(b.id, renameTo.trim()).then(() => setRenaming(null));
if (e.key === "Escape") setRenaming(null);
}}
className="h-7 text-[13px]"
/>
<Button
variant="ghost" size="icon" className="h-7 w-7" aria-label={t("common.save")}
onClick={() => void board.renameBoard(b.id, renameTo.trim()).then(() => setRenaming(null))}
>
<Check size={13} />
</Button>
</>
) : (
<>
<span className="flex-1 text-[13px]">{b.name}</span>
<span className="font-mono text-[10px] text-text-faint">/b/{b.slug}</span>
<Button
variant="ghost" size="icon" className="h-7 w-7" aria-label={t("common.edit")}
onClick={() => { setRenaming(b.id); setRenameTo(b.name); }}
>
<Pencil size={12} />
</Button>
{board.boards.length > 1 && (
<Button
variant="ghost" size="icon" className="h-7 w-7 text-danger" aria-label={t("common.delete")}
onClick={() => void board.deleteBoard(b.id).then(() => {
if (b.slug === board.boardSlug) nav("/");
})}
>
<Trash2 size={12} />
</Button>
)}
</>
)}
</div>
))}
<div className="mt-1 flex gap-2">
<Input
value={name} placeholder={t("boards.newPlaceholder")}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void create(); }}
className="h-8 text-[13px]"
/>
<Button size="sm" disabled={!name.trim()} onClick={() => void create()}>{t("common.add")}</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+23 -22
View File
@@ -7,13 +7,14 @@ import { rendererLabels, rendererList } from "@/renderers";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import type { Item } from "@/types";
import { t } from "@/i18n";
// ⌘K palette — flat filtered list over actions + service endpoints.
// No cmdk dep: an input, a list, and an active-index is the whole mechanic.
interface Command {
id: string;
group: "Actions" | "Services";
group: "actions" | "services";
icon?: Item;
glyph?: typeof LayoutGrid;
label: string;
@@ -42,28 +43,28 @@ export function CommandPalette({
const commands = useMemo<Command[]>(() => {
const cmds: Command[] = [
{ id: "nav-board", group: "Actions", glyph: LayoutGrid, label: "Go to Board", run: () => navigate("/") },
{ id: "nav-monitors", group: "Actions", glyph: Activity, label: "Go to Monitors", run: () => navigate("/monitors") },
{ id: "nav-domains", group: "Actions", glyph: Globe, label: "Go to Domains", run: () => navigate("/domains") },
{ id: "nav-systems", group: "Actions", glyph: Server, label: "Go to Systems", run: () => navigate("/systems") },
{ id: "nav-incidents", group: "Actions", glyph: AlertTriangle, label: "Go to Incidents", run: () => navigate("/incidents") },
{ id: "nav-status", group: "Actions", glyph: Megaphone, label: "Go to Status pages", run: () => navigate("/status") },
{ id: "add-service", group: "Actions", glyph: Plus, label: "Add service", hint: "new", run: onAddService },
{ id: "add-widget", group: "Actions", glyph: Plus, label: "Add widget", hint: "new", run: onAddWidget },
{ id: "settings", group: "Actions", glyph: Settings, label: "Open settings", run: onSettings },
{ id: "nav-board", group: "actions", glyph: LayoutGrid, label: t("palette.goTo", { page: t("nav.board") }), run: () => navigate("/") },
{ id: "nav-monitors", group: "actions", glyph: Activity, label: t("palette.goTo", { page: t("nav.monitors") }), run: () => navigate("/monitors") },
{ id: "nav-domains", group: "actions", glyph: Globe, label: t("palette.goTo", { page: t("nav.domains") }), run: () => navigate("/domains") },
{ id: "nav-systems", group: "actions", glyph: Server, label: t("palette.goTo", { page: t("nav.systems") }), run: () => navigate("/systems") },
{ id: "nav-incidents", group: "actions", glyph: AlertTriangle, label: t("palette.goTo", { page: t("nav.incidents") }), run: () => navigate("/incidents") },
{ id: "nav-status", group: "actions", glyph: Megaphone, label: t("palette.goTo", { page: t("statusPages.title") }), run: () => navigate("/status") },
{ id: "add-service", group: "actions", glyph: Plus, label: t("header.addService"), hint: t("palette.hintNew"), run: onAddService },
{ id: "add-widget", group: "actions", glyph: Plus, label: t("header.addWidget"), hint: t("palette.hintNew"), run: onAddWidget },
{ id: "settings", group: "actions", glyph: Settings, label: t("palette.openSettings"), run: onSettings },
{
id: "theme", group: "Actions", glyph: board.theme === "dark" ? Sun : Moon,
label: `Switch to ${board.theme === "dark" ? "light" : "dark"} theme`,
id: "theme", group: "actions", glyph: board.theme === "dark" ? Sun : Moon,
label: t("palette.themeSwitch", { theme: board.theme === "dark" ? t("settings.light") : t("settings.dark") }),
run: () => void board.setSetting("theme", board.theme === "dark" ? "light" : "dark"),
},
...rendererList.map((r): Command => ({
id: `renderer-${r}`, group: "Actions", glyph: LayoutGrid,
label: `Renderer: ${rendererLabels[r]}`,
hint: board.renderer === r ? "active" : undefined,
id: `renderer-${r}`, group: "actions", glyph: LayoutGrid,
label: t("palette.renderer", { name: rendererLabels[r] }),
hint: board.renderer === r ? t("palette.hintActive") : undefined,
run: () => void board.setSetting("renderer", r),
})),
{
id: "export", group: "Actions", glyph: Download, label: "Export board JSON",
id: "export", group: "actions", glyph: Download, label: t("palette.exportJson"),
run: () => { window.open("/api/export", "_blank", "noopener"); },
},
];
@@ -72,7 +73,7 @@ export function CommandPalette({
if (it.kind === "widget") continue;
for (const u of it.urls) {
cmds.push({
id: `svc-${u.id}`, group: "Services", icon: it,
id: `svc-${u.id}`, group: "services", icon: it,
label: u.label ? `${it.name} — ${u.label}` : it.name,
hint: hostOf(u.url),
run: () => window.open(u.url, "_blank", "noopener"),
@@ -105,7 +106,7 @@ export function CommandPalette({
c.run();
}
const groups: { name: string; items: { c: Command; i: number }[] }[] = [];
const groups: { name: Command["group"]; items: { c: Command; i: number }[] }[] = [];
filtered.forEach((c, i) => {
const g = groups.find((g) => g.name === c.group);
if (g) g.items.push({ c, i });
@@ -115,7 +116,7 @@ export function CommandPalette({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="top-[18%] max-w-[520px] translate-y-0 gap-0 overflow-hidden p-0" showCloseButton={false}>
<DialogTitle className="sr-only">Command palette</DialogTitle>
<DialogTitle className="sr-only">{t("palette.title")}</DialogTitle>
<div className="flex items-center gap-2.5 border-b border-border px-4">
<input
autoFocus
@@ -126,18 +127,18 @@ export function CommandPalette({
else if (e.key === "ArrowUp") { e.preventDefault(); setActive(clamped - 1); }
else if (e.key === "Enter") { e.preventDefault(); pick(clamped); }
}}
placeholder="Search services and actions…"
placeholder={t("palette.placeholder")}
className="h-12 w-full bg-transparent text-[14px] outline-none placeholder:text-text-faint focus-visible:outline-none"
/>
<kbd className="shrink-0 rounded border border-border px-1.5 py-0.5 font-mono text-[10px] text-text-faint">esc</kbd>
</div>
<div ref={listRef} className="max-h-[340px] overflow-y-auto p-1.5">
{filtered.length === 0 ? (
<p className="px-3 py-6 text-center text-[12.5px] text-text-faint">No matches.</p>
<p className="px-3 py-6 text-center text-[12.5px] text-text-faint">{t("palette.noResults")}</p>
) : groups.map((g) => (
<div key={g.name}>
<div className="px-2.5 pb-1 pt-2 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
{g.name}
{t(`palette.${g.name}`)}
</div>
{g.items.map(({ c, i }) => (
<button
+35 -12
View File
@@ -7,6 +7,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { t } from "@/i18n";
// Add/edit a tracked domain. The first WHOIS/DNS/TLS lookup runs on create —
// expect a few seconds of spinner.
@@ -26,6 +27,8 @@ export function DomainDialog({
const [intervalH, setIntervalH] = useState("24");
const [alertDays, setAlertDays] = useState("30");
const [autoRenew, setAutoRenew] = useState(false);
const [certDays, setCertDays] = useState("");
const [mute, setMute] = useState(false);
const [notes, setNotes] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
@@ -37,11 +40,13 @@ export function DomainDialog({
setIntervalH(String(domain?.intervalH ?? 24));
setAlertDays(String(domain?.alertDaysBefore ?? 30));
setAutoRenew(domain?.autoRenew ?? false);
setCertDays(domain?.alerts?.certDays ? String(domain.alerts.certDays) : "");
setMute(domain?.alerts?.mute ?? false);
setNotes(domain?.notes ?? "");
}, [open, domain]);
async function submit() {
if (!name.trim()) return setErr("Domain is required"), undefined;
if (!name.trim()) return setErr(t("domains.nameRequired")), undefined;
setBusy(true);
const body: DomainInput = {
name: name.trim(),
@@ -49,6 +54,10 @@ export function DomainDialog({
alertDaysBefore: Math.max(0, parseInt(alertDays) || 30),
autoRenew,
notes,
alerts: {
certDays: Math.max(0, parseInt(certDays) || 0),
mute,
},
};
try {
if (editing && domain) {
@@ -60,7 +69,7 @@ export function DomainDialog({
onSaved();
onOpenChange(false);
} catch (e) {
setErr(e instanceof Error ? e.message : "save failed");
setErr(e instanceof Error ? e.message : t("domains.saveFailed"));
} finally {
setBusy(false);
}
@@ -70,37 +79,51 @@ export function DomainDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editing ? "Edit domain" : "Track a domain"}</DialogTitle>
<DialogTitle>{editing ? t("domains.editTitle") : t("domains.addTitle")}</DialogTitle>
<DialogDescription>
WHOIS, DNS, TLS certificate, and host info — refreshed on your interval.
{t("domains.addDesc")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-name">Domain</Label>
<Label htmlFor="dom-name">{t("domains.domain")}</Label>
<Input id="dom-name" value={name} onChange={(e) => setName(e.target.value)}
placeholder="example.com" className="font-mono text-[12px]" autoFocus />
placeholder={t("domains.namePlaceholder")} className="font-mono text-[12px]" autoFocus />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-int">Refresh every (hours)</Label>
<Label htmlFor="dom-int">{t("domains.refreshEvery")}</Label>
<Input id="dom-int" value={intervalH} onChange={(e) => setIntervalH(e.target.value)} className="font-mono text-[12px]" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-alert">Alert days before expiry</Label>
<Label htmlFor="dom-alert">{t("domains.alertDays")}</Label>
<Input id="dom-alert" value={alertDays} onChange={(e) => setAlertDays(e.target.value)} className="font-mono text-[12px]" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-cert">{t("domains.certDaysLbl")}</Label>
<Input id="dom-cert" value={certDays} onChange={(e) => setCertDays(e.target.value)}
placeholder={t("domains.certDaysPlaceholder")} className="font-mono text-[12px]" />
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-[12px] text-text-dim">
<input type="checkbox" checked={mute} onChange={(e) => setMute(e.target.checked)} />
{t("monitors.mute")}
</label>
</div>
</div>
<label className="flex items-center gap-2 text-[12.5px] text-text-dim">
<input type="checkbox" checked={autoRenew} onChange={(e) => setAutoRenew(e.target.checked)} />
Auto-renew enabled at the registrar
{t("domains.autoRenewLbl")}
</label>
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-notes">Notes (optional)</Label>
<Label htmlFor="dom-notes">{t("domains.notesOptional")}</Label>
<Input id="dom-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
@@ -109,10 +132,10 @@ export function DomainDialog({
<DialogFooter className="items-center">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Cancel
{t("common.cancel")}
</Button>
<Button type="button" onClick={() => void submit()} disabled={busy}>
{busy ? "Looking up…" : editing ? "Save" : "Track domain"}
{busy ? t("domains.lookingUp") : editing ? t("common.save") : t("domains.new")}
</Button>
</DialogFooter>
</DialogContent>
+52 -26
View File
@@ -8,6 +8,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { t } from "@/i18n";
const TYPES = ["http", "tcp", "ping", "dns", "keyword", "json", "push"] as const;
type MonType = (typeof TYPES)[number];
@@ -48,6 +49,9 @@ export function MonitorDialog({
intervalS: String(monitor?.intervalS ?? 60),
timeoutS: String(monitor?.timeoutS ?? 10),
retries: String(monitor?.retries ?? 0),
consecFails: String(monitor?.alerts?.consecutiveFailures ?? ""),
latencyWarn: String(monitor?.alerts?.latencyWarnMs ?? ""),
mute: monitor?.alerts?.mute ? "1" : "",
notes: monitor?.notes ?? "",
});
}, [open, monitor]);
@@ -59,11 +63,11 @@ export function MonitorDialog({
const type = (f.type || "http") as MonType;
async function submit() {
if (!f.name?.trim()) return setErr("Name is required"), undefined;
if (!f.name?.trim()) return setErr(t("monitors.nameRequired")), undefined;
if (type !== "push" && type !== "tcp" && type !== "ping" && type !== "dns" && !f.url?.trim())
return setErr("URL is required"), undefined;
return setErr(t("monitors.urlRequired")), undefined;
if ((type === "tcp" || type === "ping" || type === "dns") && !f.hostname?.trim() && !f.url?.trim())
return setErr("Hostname is required"), undefined;
return setErr(t("monitors.hostnameRequired")), undefined;
setBusy(true);
const body: MonitorInput = {
name: f.name.trim(),
@@ -81,6 +85,11 @@ export function MonitorDialog({
timeoutS: Math.max(1, parseInt(f.timeoutS ?? "10") || 10),
retries: Math.max(0, parseInt(f.retries ?? "0") || 0),
notes: f.notes ?? "",
alerts: {
consecutiveFailures: Math.max(1, parseInt(f.consecFails ?? "") || 1),
latencyWarnMs: Math.max(0, parseInt(f.latencyWarn ?? "") || 0),
mute: f.mute === "1",
},
};
try {
if (editing && monitor) {
@@ -91,7 +100,7 @@ export function MonitorDialog({
onSaved();
onOpenChange(false);
} catch {
setErr("save failed");
setErr(t("monitors.saveFailed"));
} finally {
setBusy(false);
}
@@ -101,18 +110,18 @@ export function MonitorDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editing ? "Edit monitor" : "New monitor"}</DialogTitle>
<DialogDescription>Uptime check on a schedule.</DialogDescription>
<DialogTitle>{editing ? t("monitors.editTitle") : t("monitors.newTitle")}</DialogTitle>
<DialogDescription>{t("monitors.desc")}</DialogDescription>
</DialogHeader>
<div className="flex max-h-[60vh] flex-col gap-4 overflow-y-auto pr-1">
<div className="grid grid-cols-[1fr_130px] gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-name">Name</Label>
<Label htmlFor="mon-name">{t("common.name")}</Label>
<Input id="mon-name" value={f.name ?? ""} onChange={set("name")} placeholder="Plex" autoFocus />
</div>
<div className="flex flex-col gap-1.5">
<Label>Type</Label>
<Label>{t("common.type")}</Label>
<Select value={type} onValueChange={(v) => setF((s) => ({ ...s, type: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -126,11 +135,11 @@ export function MonitorDialog({
<>
<div className="grid grid-cols-[1fr_110px] gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-url">URL</Label>
<Label htmlFor="mon-url">{t("common.url")}</Label>
<Input id="mon-url" value={f.url ?? ""} onChange={set("url")} placeholder="https://plex.local" className="font-mono text-[12px]" />
</div>
<div className="flex flex-col gap-1.5">
<Label>Method</Label>
<Label>{t("monitors.method")}</Label>
<Select value={f.method || "GET"} onValueChange={(v) => setF((s) => ({ ...s, method: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -141,23 +150,23 @@ export function MonitorDialog({
</div>
{type === "keyword" && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-kw">Keyword</Label>
<Label htmlFor="mon-kw">{t("monitors.keyword")}</Label>
<Input id="mon-kw" value={f.keyword ?? ""} onChange={set("keyword")} placeholder="text that must appear" className="font-mono text-[12px]" />
<label className="flex items-center gap-2 text-[12px] text-text-dim">
<input type="checkbox" checked={f.keywordInvert === "1"}
onChange={(e) => setF((s) => ({ ...s, keywordInvert: e.target.checked ? "1" : "" }))} />
Invert (down when found)
{t("monitors.keywordInvert")}
</label>
</div>
)}
{type === "json" && (
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-jq">JSON path</Label>
<Label htmlFor="mon-jq">{t("monitors.jsonPath")}</Label>
<Input id="mon-jq" value={f.jsonQuery ?? ""} onChange={set("jsonQuery")} placeholder="data.status" className="font-mono text-[12px]" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-exp">Expected</Label>
<Label htmlFor="mon-exp">{t("monitors.expected")}</Label>
<Input id="mon-exp" value={f.expected ?? ""} onChange={set("expected")} placeholder="ok" className="font-mono text-[12px]" />
</div>
</div>
@@ -168,17 +177,17 @@ export function MonitorDialog({
{(type === "tcp" || type === "ping" || type === "dns") && (
<div className="grid grid-cols-[1fr_110px] gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-host">Hostname</Label>
<Label htmlFor="mon-host">{t("common.hostname")}</Label>
<Input id="mon-host" value={f.hostname ?? ""} onChange={set("hostname")} placeholder="nas.local" className="font-mono text-[12px]" />
</div>
{type === "tcp" ? (
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-port">Port</Label>
<Label htmlFor="mon-port">{t("common.port")}</Label>
<Input id="mon-port" value={f.port ?? ""} onChange={set("port")} placeholder="443" className="font-mono text-[12px]" />
</div>
) : type === "dns" ? (
<div className="flex flex-col gap-1.5">
<Label>Record</Label>
<Label>{t("monitors.record")}</Label>
<Select value={f.dnsType || "A"} onValueChange={(v) => setF((s) => ({ ...s, dnsType: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -191,36 +200,53 @@ export function MonitorDialog({
)}
{type === "dns" && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-dnsexp">Expected record contains (optional)</Label>
<Label htmlFor="mon-dnsexp">{t("monitors.dnsExpected")}</Label>
<Input id="mon-dnsexp" value={f.expected ?? ""} onChange={set("expected")} className="font-mono text-[12px]" />
</div>
)}
{type === "push" && (
<p className="rounded-[8px] border border-border bg-surface px-3 py-2 text-[12px] text-text-dim">
After saving, your jobs call the shown push URL on a schedule.
If it goes quiet for 2× the interval, the monitor flips down.
{t("monitors.pushHint")}
</p>
)}
<div className="grid grid-cols-3 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-int">Interval (s)</Label>
<Label htmlFor="mon-int">{t("monitors.interval")}</Label>
<Input id="mon-int" value={f.intervalS ?? "60"} onChange={set("intervalS")} className="font-mono text-[12px]" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-to">Timeout (s)</Label>
<Label htmlFor="mon-to">{t("monitors.timeout")}</Label>
<Input id="mon-to" value={f.timeoutS ?? "10"} onChange={set("timeoutS")} className="font-mono text-[12px]" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-re">Retries</Label>
<Label htmlFor="mon-re">{t("monitors.retries")}</Label>
<Input id="mon-re" value={f.retries ?? "0"} onChange={set("retries")} className="font-mono text-[12px]" />
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-cf">{t("monitors.failsBeforeDown")}</Label>
<Input id="mon-cf" value={f.consecFails ?? ""} onChange={set("consecFails")} placeholder="1" className="font-mono text-[12px]" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-lw">{t("monitors.latencyWarn")}</Label>
<Input id="mon-lw" value={f.latencyWarn ?? ""} onChange={set("latencyWarn")} placeholder="off" className="font-mono text-[12px]" />
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-[12px] text-text-dim">
<input type="checkbox" checked={f.mute === "1"}
onChange={(e) => setF((s) => ({ ...s, mute: e.target.checked ? "1" : "" }))} />
{t("monitors.mute")}
</label>
</div>
</div>
{monitor?.pushToken ? (
<div className="flex flex-col gap-1.5">
<Label>Push URL</Label>
<Label>{t("monitors.pushUrl")}</Label>
<code className="rounded-[8px] border border-border bg-surface px-3 py-2 font-mono text-[11px] text-text-dim">
{location.origin}/api/push/{monitor.pushToken}
</code>
@@ -232,10 +258,10 @@ export function MonitorDialog({
<DialogFooter className="items-center">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Cancel
{t("common.cancel")}
</Button>
<Button type="button" onClick={() => void submit()} disabled={busy}>
{busy ? "Saving…" : editing ? "Save" : "Add monitor"}
{busy ? t("common.saving") : editing ? t("common.save") : t("monitors.addMonitor")}
</Button>
</DialogFooter>
</DialogContent>
+21 -20
View File
@@ -10,6 +10,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { t } from "@/i18n";
// dashboard-icons CDN serves png/<slug>.png — slugify the service name for
// the one-click suggestion under the icon field.
@@ -58,9 +59,9 @@ export function ServiceDialog({
async function submit() {
const cleanUrls = urls.filter((u) => u.url.trim() !== "");
if (!name.trim()) return setErr("Name is required"), undefined;
if (!name.trim()) return setErr(t("service.nameRequired")), undefined;
if (cleanUrls.some((u) => !/^https?:\/\/.+/.test(u.url))) {
return setErr("URLs must start with http:// or https://"), undefined;
return setErr(t("service.urlRequired")), undefined;
}
setBusy(true);
try {
@@ -72,7 +73,7 @@ export function ServiceDialog({
let sid = sectionId;
if (!sid) {
// First service on an empty board — create the home section.
const sec = await board.addSection("Services");
const sec = await board.addSection(t("statusPages.services"));
if (!sec) throw new Error("could not create section");
sid = sec.id;
}
@@ -93,7 +94,7 @@ export function ServiceDialog({
}
onOpenChange(false);
} catch (e) {
setErr(e instanceof Error ? e.message : "save failed");
setErr(e instanceof Error ? e.message : t("service.saveFailed"));
} finally {
setBusy(false);
}
@@ -111,21 +112,21 @@ export function ServiceDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? "Edit service" : "Add service"}</DialogTitle>
<DialogTitle>{editing ? t("service.editTitle") : t("service.newTitle")}</DialogTitle>
<DialogDescription>
{editing ? "Update name, icon, or launch URLs." : "Name it, give it an icon, add one or more URLs."}
{editing ? t("service.editDesc") : t("service.newDesc")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-[1fr_150px] gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="svc-name">Name</Label>
<Label htmlFor="svc-name">{t("common.name")}</Label>
<Input id="svc-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Pi-hole" autoFocus />
</div>
{!editing && board.sections.length > 0 && (
<div className="flex flex-col gap-1.5">
<Label>Section</Label>
<Label>{t("widget.section")}</Label>
<Select value={sectionId} onValueChange={setSectionId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -139,7 +140,7 @@ export function ServiceDialog({
</div>
<div className="flex flex-col gap-1.5">
<Label>URLs</Label>
<Label>{t("service.urls")}</Label>
{urls.map((u, i) => (
<div key={i} className="flex gap-2">
<Input
@@ -151,13 +152,13 @@ export function ServiceDialog({
<Input
value={u.label ?? ""}
onChange={(e) => setUrl(i, { label: e.target.value })}
placeholder="label"
placeholder={t("service.labelPlaceholder")}
className="w-[110px] shrink-0"
list="url-labels"
/>
<Button
type="button" variant="ghost" size="icon"
aria-label="Remove URL"
aria-label={t("service.removeUrl")}
disabled={urls.length <= 1}
onClick={() => setUrls((us) => us.filter((_, j) => j !== i))}
>
@@ -173,16 +174,16 @@ export function ServiceDialog({
className="mt-1 w-fit"
onClick={() => setUrls((us) => [...us, { url: "", label: "" }])}
>
<Plus size={12} /> Add URL
<Plus size={12} /> {t("service.addUrl")}
</Button>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="svc-icon">Icon URL</Label>
<Label htmlFor="svc-icon">{t("service.icon")}</Label>
<div className="flex items-center gap-2">
<IconPreview url={icon} />
<Input id="svc-icon" value={icon} onChange={(e) => setIcon(e.target.value)} placeholder="https://…/icon.png" className="font-mono text-[12px]" />
<Button type="button" variant="outline" size="icon" aria-label="Upload icon file" onClick={() => fileRef.current?.click()}>
<Button type="button" variant="outline" size="icon" aria-label={t("service.upload")} onClick={() => fileRef.current?.click()}>
<Upload size={13} />
</Button>
<input
@@ -196,10 +197,10 @@ export function ServiceDialog({
onClick={() => setIcon(`${DI_BASE}/${diSlug(name)}.png`)}
className="w-fit rounded border border-border px-1.5 py-0.5 font-mono text-[10.5px] text-text-faint transition-colors hover:border-border-strong hover:text-text"
>
suggest: {diSlug(name)}.png
{t("service.suggest", { slug: diSlug(name) })}
</button>
) : null}
<p className="text-[11px] text-text-faint">URL or uploaded file; blank shows a letter tile.</p>
<p className="text-[11px] text-text-faint">{t("service.iconHint")}</p>
</div>
{!editing && (
@@ -209,7 +210,7 @@ export function ServiceDialog({
checked={alsoMonitor}
onChange={(e) => setAlsoMonitor(e.target.checked)}
/>
Also create an uptime monitor for the first URL
{t("service.alsoMonitor")}
</label>
)}
@@ -219,14 +220,14 @@ export function ServiceDialog({
<DialogFooter className="items-center">
{editing ? (
<Button type="button" variant="destructive" size="sm" className="mr-auto" onClick={() => void remove()} disabled={busy}>
<Trash2 size={12} /> Delete
<Trash2 size={12} /> {t("common.delete")}
</Button>
) : null}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Cancel
{t("common.cancel")}
</Button>
<Button type="button" onClick={() => void submit()} disabled={busy}>
{busy ? "Saving…" : editing ? "Save" : "Add service"}
{busy ? t("common.saving") : editing ? t("common.save") : t("service.addService")}
</Button>
</DialogFooter>
</DialogContent>
+205 -41
View File
@@ -1,9 +1,10 @@
import { useEffect, useRef, useState } from "react";
import { Bell, Download, Upload } from "lucide-react";
import { Bell, Download, Lock, Upload } from "lucide-react";
import { useBoard } from "@/board/store";
import { api } from "@/api";
import { rendererDescriptions, rendererLabels, rendererList } from "@/renderers";
import { cn } from "@/lib/utils";
import { LOCALES, t } from "@/i18n";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
@@ -12,26 +13,100 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
// Notification transports — mirrors the backend registry. Config is stored
// under notify_<name> as a JSON object; legacy notify_webhook strings map to
// {url}.
const TRANSPORTS = {
webhook: { label: "Webhook", fields: [{ k: "url", l: "URL", ph: "https://…" }] },
slack: { label: "Slack", fields: [{ k: "url", l: "Webhook URL", ph: "https://hooks.slack.com/…" }] },
discord: { label: "Discord", fields: [{ k: "url", l: "Webhook URL", ph: "https://discord.com/api/webhooks/…" }] },
telegram: { label: "Telegram", fields: [
{ k: "token", l: "Bot token", ph: "123456:ABC…", secret: true },
{ k: "chat_id", l: "Chat ID", ph: "-100…" },
{ k: "server", l: "API server (optional)", ph: "https://api.telegram.org" },
] },
gotify: { label: "Gotify", fields: [
{ k: "server", l: "Server", ph: "https://gotify.local" },
{ k: "token", l: "App token", secret: true },
] },
ntfy: { label: "ntfy", fields: [
{ k: "server", l: "Server", ph: "https://ntfy.sh" },
{ k: "topic", l: "Topic", ph: "dash-alerts" },
] },
smtp: { label: "SMTP", fields: [
{ k: "host", l: "Host", ph: "smtp.local" },
{ k: "port", l: "Port", ph: "587" },
{ k: "user", l: "User (optional)" },
{ k: "pass", l: "Password", secret: true },
{ k: "from", l: "From", ph: "dash@local" },
{ k: "to", l: "To", ph: "me@local, oncall@local" },
] },
} as const;
type TransportName = keyof typeof TRANSPORTS;
export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
const board = useBoard();
const fileRef = useRef<HTMLInputElement>(null);
const [msg, setMsg] = useState("");
const [webhook, setWebhook] = useState("");
const [webhookMsg, setWebhookMsg] = useState("");
const [transport, setTransport] = useState<TransportName>("webhook");
const [notifyCfg, setNotifyCfg] = useState<Record<string, Record<string, string>>>({});
const [notifyMsg, setNotifyMsg] = useState("");
const [auth, setAuth] = useState<{ enabled?: boolean; authed?: boolean; username?: string }>({});
useEffect(() => {
if (!open) return;
void api.GET("/api/auth/session").then(({ data }) => setAuth(data ?? {}));
void api.GET("/api/settings").then(({ data }) => {
const v = data?.notify_webhook;
setWebhook(typeof v === "string" ? v : "");
setWebhookMsg("");
const cfg: Record<string, Record<string, string>> = {};
for (const name of Object.keys(TRANSPORTS)) {
const v = data?.["notify_" + name];
if (typeof v === "string") cfg[name] = { url: v }; // legacy webhook
else if (v !== null && typeof v === "object" && !Array.isArray(v)) {
const obj: Record<string, string> = {};
for (const [k, val] of Object.entries(v)) if (typeof val === "string") obj[k] = val;
cfg[name] = obj;
}
}
setNotifyCfg(cfg);
setNotifyMsg("");
});
}, [open]);
async function saveWebhook() {
setWebhookMsg("");
await api.PUT("/api/settings", { body: { notify_webhook: webhook.trim() } });
setWebhookMsg("saved");
const fields = TRANSPORTS[transport].fields;
const cfg = notifyCfg[transport] ?? {};
const setField = (k: string, v: string) => {
setNotifyCfg((s) => ({ ...s, [transport]: { ...s[transport], [k]: v } }));
setNotifyMsg("");
};
async function saveNotify() {
setNotifyMsg("");
await api.PUT("/api/settings", { body: { ["notify_" + transport]: cfg } });
setNotifyMsg(t("settings.saved"));
}
async function testNotify() {
setNotifyMsg("");
const { data, error } = await api.POST("/api/notify/test", { body: { transport } });
const r = data?.results?.[transport];
setNotifyMsg(error ? t("settings.failed") : r === "ok" ? t("settings.sent") : (r ?? t("settings.sent")));
}
// Enabling auth flips the whole app to the setup screen — reload so the
// gate picks it up. Same on sign-out/disable.
async function enableAuth() {
await api.PUT("/api/settings", { body: { auth_enabled: true } });
location.reload();
}
async function signOut() {
await api.POST("/api/auth/logout");
location.reload();
}
async function disableAuth() {
await api.POST("/api/auth/disable");
location.reload();
}
async function exportJson() {
@@ -62,7 +137,7 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
} else {
// SAFETY: error responses always carry {error: string} per openapi.yaml Error schema.
const body = (await res.json().catch(() => null)) as { error?: string } | null;
setMsg(body?.error ?? "Import failed");
setMsg(body?.error ?? t("settings.importFailed"));
}
}
@@ -70,14 +145,14 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
<DialogDescription>Board layout, theme, and backup.</DialogDescription>
<DialogTitle>{t("settings.title")}</DialogTitle>
<DialogDescription>{t("settings.desc")}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex max-h-[70vh] flex-col gap-4 overflow-y-auto pr-1">
<div className="flex flex-col gap-1.5">
<Label>Renderer</Label>
<div className="grid grid-cols-2 gap-1.5" role="radiogroup" aria-label="Renderer">
<Label>{t("settings.renderer")}</Label>
<div className="grid grid-cols-2 gap-1.5" role="radiogroup" aria-label={t("settings.renderer")}>
{rendererList.map((r) => (
<button
key={r}
@@ -100,56 +175,145 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
</div>
<div className="flex flex-col gap-1.5">
<Label>Theme</Label>
<Label>{t("settings.theme")}</Label>
<Select value={board.theme} onValueChange={(v) => {
if (v === "dark" || v === "light") void board.setSetting("theme", v);
}}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">{t("settings.dark")}</SelectItem>
<SelectItem value="light">{t("settings.light")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="notify-webhook">Notification webhook</Label>
<Label>{t("settings.language")}</Label>
<Select
value={board.locale || "en"}
onValueChange={(v) => void board.setSetting("locale", v)}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(LOCALES).map(([k, name]) => (
<SelectItem key={k} value={k}>{name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="accent">{t("settings.accent")}</Label>
<div className="flex gap-2">
<Input
id="notify-webhook"
value={webhook}
onChange={(e) => { setWebhook(e.target.value); setWebhookMsg(""); }}
placeholder="https://ntfy.sh/dash or Slack/Discord hook"
className="font-mono text-[12px]"
<input
id="accent" type="color" aria-label={t("settings.accent")}
value={/^#[0-9a-fA-F]{6}$/.test(board.accent) ? board.accent : "#3b82f6"}
onChange={(e) => void board.setSetting("accent", e.target.value)}
className="h-8 w-12 cursor-pointer rounded-[6px] border border-border bg-surface p-0.5"
/>
<Button type="button" variant="outline" size="sm" onClick={() => void saveWebhook()}>
Save
<Button type="button" variant="outline" size="sm" onClick={() => void board.setSetting("accent", "")}>
{t("common.reset")}
</Button>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="wallpaper">{t("settings.wallpaper")}</Label>
<Input
id="wallpaper"
value={board.wallpaper}
onChange={(e) => void board.setSetting("wallpaper", e.target.value)}
placeholder={t("settings.wallpaperPh")}
className="font-mono text-[12px]"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="custom-css">{t("settings.customCss")}</Label>
<textarea
id="custom-css"
value={board.customCss}
onChange={(e) => void board.setSetting("customCss", e.target.value)}
placeholder=".item-tile { border-radius: 0; }"
rows={3}
className="w-full resize-y rounded-[7px] border border-border bg-bg px-2.5 py-2 font-mono text-[11.5px] outline-none focus:border-border-strong"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t("settings.notifications")}</Label>
<div className="flex gap-2">
<Select value={transport} onValueChange={(v) => {
// SAFETY: Select only yields TRANSPORTS keys.
setTransport(v as TransportName); setNotifyMsg("");
}}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(TRANSPORTS).map(([k, v]) => (
<SelectItem key={k} value={k}>
{v.label}{notifyCfg[k] ? " ·" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="button" variant="outline" size="sm" onClick={() => void saveNotify()}>{t("common.save")}</Button>
<Button
type="button" variant="outline" size="icon" aria-label="Send test notification"
disabled={!webhook.trim()}
onClick={() => {
setWebhookMsg("");
void api.POST("/api/notify/test").then(({ error }) =>
setWebhookMsg(error ? "failed" : "sent"));
}}
type="button" variant="outline" size="icon" aria-label={t("settings.testNotify")}
onClick={() => void testNotify()}
>
<Bell size={12} />
</Button>
</div>
{fields.map((f) => (
<div key={f.k} className="flex flex-col gap-1">
<Label htmlFor={`ntf-${f.k}`} className="text-[11px] text-text-faint">{f.l}</Label>
<Input
id={`ntf-${f.k}`}
type={"secret" in f && f.secret ? "password" : "text"}
value={cfg[f.k] ?? ""}
onChange={(e) => setField(f.k, e.target.value)}
placeholder={"ph" in f ? f.ph : ""}
className="font-mono text-[12px]"
/>
</div>
))}
<p className="text-[11px] text-text-faint">
Monitor up/down flips and domain/certificate expiry post JSON here.
Works with Slack, Discord, ntfy, or any JSON receiver.
{t("settings.notifyFanout")}
</p>
{notifyMsg ? <p className="text-[11px] text-text-faint">{notifyMsg}</p> : null}
</div>
<div className="flex flex-col gap-2.5 rounded-lg border border-border p-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-1.5">
<Lock size={12} /> {t("auth.section")}
</Label>
{auth.enabled ? (
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => void signOut()}>
{t("auth.signOut")}
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => void disableAuth()}>
{t("auth.disable")}
</Button>
</div>
) : (
<Button type="button" variant="outline" size="sm" onClick={() => void enableAuth()}>
{t("auth.enable")}
</Button>
)}
</div>
<p className="text-[11px] text-text-faint">
{auth.enabled ? `${t("auth.onHint")} ${auth.username ?? ""}` : t("auth.offHint")}
</p>
{webhookMsg ? <p className="text-[11px] text-text-faint">{webhookMsg}</p> : null}
</div>
<div className="flex gap-2 pt-1">
<Button type="button" variant="outline" size="sm" onClick={() => void exportJson()}>
<Download size={12} /> Export JSON
<Download size={12} /> {t("settings.exportJson")}
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
<Upload size={12} /> Import
<Upload size={12} /> {t("settings.import")}
</Button>
<input
ref={fileRef} type="file" accept=".json,.yml,.yaml" className="hidden"
@@ -160,7 +324,7 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
}}
/>
</div>
<p className="text-[11px] text-text-faint">Import accepts Dash exports plus Homepage, Homarr, and Dashy configs.</p>
<p className="text-[11px] text-text-faint">{t("settings.importHint")}</p>
{msg ? <p className="text-[12px] text-destructive">{msg}</p> : null}
</div>
</DialogContent>
+16 -15
View File
@@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { components } from "@dash/api-client";
import { t } from "@/i18n";
type WidgetType = components["schemas"]["WidgetType"];
@@ -66,10 +67,10 @@ export function WidgetDialog({
const meta = types.find((t) => t.type === type);
async function submit() {
if (!type) return setErr("Pick a widget type"), undefined;
if (!name.trim()) return setErr("Name is required"), undefined;
if (!type) return setErr(t("widget.pickType")), undefined;
if (!name.trim()) return setErr(t("service.nameRequired")), undefined;
if (meta?.fields.some((f) => f.required && !values[f.key]?.trim())) {
return setErr("Fill in the required fields"), undefined;
return setErr(t("widget.requiredFields")), undefined;
}
setBusy(true);
try {
@@ -83,7 +84,7 @@ export function WidgetDialog({
} else {
let sid = sectionId;
if (!sid) {
const sec = await board.addSection("Widgets");
const sec = await board.addSection(t("widget.defaultSection"));
if (!sec) throw new Error("could not create section");
sid = sec.id;
}
@@ -91,7 +92,7 @@ export function WidgetDialog({
}
onOpenChange(false);
} catch (e) {
setErr(e instanceof Error ? e.message : "save failed");
setErr(e instanceof Error ? e.message : t("service.saveFailed"));
} finally {
setBusy(false);
}
@@ -101,21 +102,21 @@ export function WidgetDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? "Edit widget" : "Add widget"}</DialogTitle>
<DialogTitle>{editing ? t("widget.editTitle") : t("widget.newTitle")}</DialogTitle>
<DialogDescription>
{editing ? "Update widget settings." : "Pick a type and point it at your service."}
{editing ? t("widget.editDesc") : t("widget.newDesc")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-[1fr_150px] gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="wdg-name">Name</Label>
<Label htmlFor="wdg-name">{t("common.name")}</Label>
<Input id="wdg-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Pi-hole" autoFocus />
</div>
{!editing && board.sections.length > 0 && (
<div className="flex flex-col gap-1.5">
<Label>Section</Label>
<Label>{t("widget.section")}</Label>
<Select value={sectionId} onValueChange={setSectionId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -129,9 +130,9 @@ export function WidgetDialog({
</div>
<div className="flex flex-col gap-1.5">
<Label>Type</Label>
<Label>{t("common.type")}</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger><SelectValue placeholder="Pick one…" /></SelectTrigger>
<SelectTrigger><SelectValue placeholder={t("widget.pickOne")} /></SelectTrigger>
<SelectContent>
{types.map((t) => (
<SelectItem key={t.type} value={t.type}>{t.name}</SelectItem>
@@ -146,14 +147,14 @@ export function WidgetDialog({
{meta?.fields.map((f) => (
<div key={f.key} className="flex flex-col gap-1.5">
<Label htmlFor={`wdg-${f.key}`}>
{f.label}{f.required ? "" : " (optional)"}
{f.label}{f.required ? "" : ` (${t("common.optional")})`}
</Label>
{f.key === "monitorId" || f.key === "domainId" || f.key === "systemId" ? (
<Select
value={values[f.key] ?? ""}
onValueChange={(v) => setValues((s) => ({ ...s, [f.key]: v }))}
>
<SelectTrigger><SelectValue placeholder="Pick one…" /></SelectTrigger>
<SelectTrigger><SelectValue placeholder={t("widget.pickOne")} /></SelectTrigger>
<SelectContent>
{(f.key === "monitorId" ? monitors : f.key === "domainId" ? domains : systems).map((m) => (
<SelectItem key={m.id} value={m.id ?? ""}>{m.name}</SelectItem>
@@ -178,10 +179,10 @@ export function WidgetDialog({
<DialogFooter className="items-center">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Cancel
{t("common.cancel")}
</Button>
<Button type="button" onClick={() => void submit()} disabled={busy}>
{busy ? "Saving…" : editing ? "Save" : "Add widget"}
{busy ? t("common.saving") : editing ? t("common.save") : t("header.addWidget")}
</Button>
</DialogFooter>
</DialogContent>
+2 -1
View File
@@ -3,6 +3,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
@@ -27,7 +28,7 @@ function DialogContent({
{showCloseButton ? (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-text-faint transition-colors hover:bg-surface-hover hover:text-text focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
<X size={14} />
<span className="sr-only">Close</span>
<span className="sr-only">{t("common.close")}</span>
</DialogPrimitive.Close>
) : null}
</DialogPrimitive.Content>
+414
View File
@@ -0,0 +1,414 @@
import type { Key } from "@/i18n/en";
// Czech strings. Partial — missing keys fall back to English in t().
export const cs: Partial<Record<Key, string>> = {
"nav.board": "Nástěnka",
"nav.monitors": "Monitory",
"nav.domains": "Domény",
"nav.systems": "Systémy",
"nav.incidents": "Incidenty",
"nav.status": "Stav",
"header.search": "Hledat (⌘K)",
"header.settings": "Nastavení",
"header.theme": "Přepnout motiv",
"header.addWidget": "Přidat widget",
"header.addService": "Přidat službu",
"header.widget": "Widget",
"common.add": "Přidat",
"common.edit": "Upravit",
"common.delete": "Smazat",
"common.cancel": "Zrušit",
"common.save": "Uložit",
"common.create": "Vytvořit",
"common.creating": "Vytvářím…",
"common.saving": "Ukládám…",
"common.test": "Test",
"common.loading": "Načítám…",
"common.name": "Název",
"common.title": "Titulek",
"common.description": "Popis",
"common.slug": "Slug",
"common.url": "URL",
"common.type": "Typ",
"common.hostname": "Hostname",
"common.port": "Port",
"common.optional": "volitelné",
"common.enabled": "Zapnuto",
"common.refresh": "Obnovit",
"common.never": "nikdy",
"common.all": "Vše",
"common.reset": "Resetovat",
"common.close": "Zavřít",
"auth.setupTitle": "Vytvořte si účet správce",
"auth.setupHint": "První uživatel se stane správcem. Pouze lokální účet — žádná externí identita.",
"auth.signInTitle": "Přihlásit se",
"auth.signInHint": "Tato instance Dash vyžaduje přihlášení.",
"auth.username": "Uživatelské jméno nebo e-mail",
"auth.password": "Heslo",
"auth.createAccount": "Vytvořit účet",
"auth.signIn": "Přihlásit se",
"auth.invalid": "Neplatné jméno nebo heslo.",
"auth.rateLimited": "Příliš mnoho pokusů — počkejte minutu.",
"auth.section": "Ověřování",
"auth.offHint": "Kdokoli, kdo dosáhne na tuto instanci, má plný přístup. Zapněte pro vyžadování přihlášení.",
"auth.enable": "Zapnout ověřování",
"auth.onHint": "Přihlášen jako",
"auth.signOut": "Odhlásit se",
"auth.disable": "Vypnout ověřování",
"auth.disabledNote": "Ověřování je vypnuté — nástěnka je otevřená vaší síti.",
"boards.title": "Nástěnky",
"boards.manage": "Spravovat nástěnky…",
"boards.newPlaceholder": "Název nové nástěnky",
"board.empty": "Zatím žádné sekce.",
"board.emptyHint": "Přidejte službu nebo widget.",
"board.addSection": "Přidat sekci",
"board.sectionNamePlaceholder": "Název sekce",
"board.deleteSectionConfirm": "Smazat sekci \"{name}\" a jejích {count} položek?",
"palette.title": "Příkazová paleta",
"palette.placeholder": "Hledat služby a akce…",
"palette.noResults": "Nic nenalezeno.",
"palette.pages": "Stránky",
"palette.services": "Služby",
"palette.actions": "Akce",
"palette.goTo": "Přejít na {page}",
"palette.openSettings": "Otevřít nastavení",
"palette.themeSwitch": "Přepnout na motiv {theme}",
"palette.renderer": "Vzhled: {name}",
"palette.exportJson": "Exportovat nástěnku do JSON",
"palette.hintNew": "nové",
"palette.hintActive": "aktivní",
"settings.title": "Nastavení",
"settings.desc": "Rozvržení nástěnky, motiv a záloha.",
"settings.theme": "Motiv",
"settings.language": "Jazyk",
"settings.dark": "Tmavý",
"settings.light": "Světlý",
"settings.renderer": "Vzhled nástěnky",
"settings.appearance": "Vzhled",
"settings.accent": "Akcentní barva",
"settings.accentHint": "Libovolná CSS barva — hex, hsl, pojmenovaná.",
"settings.wallpaper": "URL tapety",
"settings.wallpaperHint": "Pozadí přes celou nástěnku.",
"settings.customCss": "Vlastní CSS",
"settings.customCssHint": "Aplikuje se naposled — přebije proměnné motivu.",
"settings.notifications": "Notifikace",
"settings.notifyFanout": "Přepnutí monitorů a expirace domén/certifikátů se rozešlou všem nastaveným transportům.",
"settings.exportJson": "Export JSON",
"settings.import": "Import",
"settings.importHint": "Import umí Dash exporty plus konfigurace Homepage, Homarr a Dashy.",
"settings.saved": "uloženo",
"settings.sent": "odesláno",
"settings.failed": "selhalo",
"settings.testNotify": "Odeslat testovací notifikaci",
"settings.wallpaperPh": "https://… — prázdné = žádná",
"settings.importFailed": "Import selhal",
"service.editTitle": "Upravit službu",
"service.newTitle": "Přidat službu",
"service.desc": "Záložka na nástěnce — název, ikona, jedna nebo více URL.",
"service.newDesc": "Pojmenujte ji, dejte ikonu, přidejte jednu nebo více URL.",
"service.editDesc": "Upravte název, ikonu nebo URL.",
"service.icon": "URL ikony",
"service.iconHint": "URL nebo nahraný soubor; prázdné zobrazí dlaždici s písmenem.",
"service.upload": "Nahrát",
"service.urls": "URL",
"service.addUrl": "Přidat URL",
"service.labelPlaceholder": "štítek",
"service.removeUrl": "Odebrat URL",
"service.suggest": "návrh: {slug}.png",
"service.alsoMonitor": "Vytvořit i uptime monitor pro první URL",
"service.nameRequired": "Název je povinný",
"service.urlRequired": "Je potřeba alespoň jedna platná http(s) URL",
"service.saveFailed": "uložení selhalo",
"service.addService": "Přidat službu",
"widget.editTitle": "Upravit widget",
"widget.newTitle": "Přidat widget",
"widget.desc": "Živá data na nástěnce.",
"widget.newDesc": "Vyberte typ a nasměrujte na svou službu.",
"widget.editDesc": "Upravte nastavení widgetu.",
"widget.type": "Typ widgetu",
"widget.section": "Sekce",
"widget.pickOne": "Vyberte…",
"widget.pickType": "Vyberte typ widgetu",
"widget.requiredFields": "Vyplňte povinná pole",
"widget.defaultSection": "Widgety",
"widget.err": "chyba",
"widget.offline": "offline",
"widget.fetchFailed": "načtení selhalo",
"widget.queries": "{n} dotazů",
"widget.blocked": "{n} blokováno",
"widget.msAvg": "{n} ms prům.",
"widget.videos": "{n} videí",
"widget.blockedLbl": "blokováno",
"widget.photosLbl": "fotek",
"widget.embedHint": "Nastavte URL v konfiguraci widgetu.",
"renderer.localTime": "Místní čas",
"renderer.down": "Nedostupné",
"renderer.allGreen": "vše v pořádku",
"renderer.offline": "offline",
"renderer.needsAttention": "vyžaduje pozornost",
"renderer.downCount": "{n} nedostupných",
"renderer.entry": "položka",
"renderer.entries": "položek",
"renderer.dragSection": "Přesunout sekci",
"renderer.deleteSection": "Smazat sekci",
"renderer.live": "živě",
"monitors.title": "Monitory",
"monitors.subtitle": "HTTP, TCP, ping, DNS, klíčové slovo, JSON a push kontroly.",
"monitors.new": "Nový monitor",
"monitors.empty": "Zatím žádné monitory.",
"monitors.emptyHint": "Přidejte jeden pro sledování dostupnosti.",
"monitors.deleteConfirm": "Smazat monitor \"{name}\" a jeho historii?",
"monitors.checkNow": "Zkontrolovat nyní",
"monitors.pause": "Pozastavit",
"monitors.resume": "Obnovit",
"monitors.editTitle": "Upravit monitor",
"monitors.newTitle": "Nový monitor",
"monitors.desc": "Kontrola dostupnosti v intervalu.",
"monitors.method": "Metoda",
"monitors.keyword": "Klíčové slovo",
"monitors.keywordInvert": "Invertovat (down když nalezeno)",
"monitors.jsonPath": "JSON cesta",
"monitors.expected": "Očekávaná hodnota",
"monitors.record": "Záznam",
"monitors.dnsExpected": "Záznam obsahuje (volitelné)",
"monitors.pushHint": "Po uložení volají vaše úlohy zobrazenou push URL. Ztichne-li se na 2× interval, monitor přejde do down.",
"monitors.interval": "Interval (s)",
"monitors.timeout": "Timeout (s)",
"monitors.retries": "Opakování",
"monitors.failsBeforeDown": "Selhání před down",
"monitors.latencyWarn": "Varování latence (ms)",
"monitors.mute": "Ztišit notifikace",
"monitors.pushUrl": "Push URL",
"monitors.addMonitor": "Přidat monitor",
"monitors.nameRequired": "Název je povinný",
"monitors.urlRequired": "URL je povinná",
"monitors.hostnameRequired": "Hostname je povinný",
"monitors.saveFailed": "uložení selhalo",
"monitors.uptime24h": "24h dostupnost",
"monitors.avgLatency": "Prům. latence",
"monitors.lastCheck": "Poslední kontrola",
"monitors.latencyHistory": "Latence",
"monitors.uptimeHistory": "Historie dostupnosti",
"monitors.noHeartbeats": "Zatím žádné odezvy.",
"monitors.everyInterval": "každých {interval}s",
"monitors.status": "Stav",
"monitors.uptime30d": "Dostupnost 30d",
"monitors.ping": "Ping",
"monitors.every": "Každých",
"monitors.checksHint": "HTTP, TCP, ping, DNS, klíčové slovo, JSON cesta nebo push — kontroly běží na serveru ve vašem intervalu.",
"monitors.uptime": "Dostupnost",
"monitors.checks": "Kontroly",
"monitors.responseTime": "Doba odezvy",
"monitors.recentChecks": "Nedávné kontroly",
"monitors.noChecks": "Žádné kontroly v rozsahu.",
"monitors.noChecksBucket": "žádné kontroly",
"monitors.bucketChecks": "{status} ({n} kontrol)",
"domains.title": "Domény",
"domains.subtitle": "WHOIS, DNS a hlídání certifikátů.",
"domains.new": "Sledovat doménu",
"domains.empty": "Zatím žádné sledované domény.",
"domains.emptyHint": "Expirace, registrátor, DNS záznamy, SSL certifikáty a detekce poskytovatelů.",
"domains.domain": "Doména",
"domains.ssl": "SSL",
"domains.providers": "Poskytovatelé",
"domains.lookupError": "chyba dotazu",
"domains.refreshNow": "Obnovit nyní",
"domains.deleteConfirm": "Přestat sledovat \"{name}\"?",
"domains.refresh": "Překontrolovat",
"domains.expiresIn": "expiruje za {days}d",
"domains.expired": "expirováno",
"domains.certDays": "cert {days}d",
"domains.registrar": "Registrátor",
"domains.expires": "Expirace",
"domains.cert": "Certifikát",
"domains.nameservers": "Nameservery",
"domains.hosting": "Hosting",
"domains.subdomains": "Subdomény",
"domains.subdomainsHint": "Objevené přes Certificate Transparency logy; resolvováno přes DNS.",
"domains.scan": "Skenovat",
"domains.scanning": "Skenuji…",
"domains.noSubdomains": "Zatím žádné subdomény — spusťte sken.",
"domains.lastSeen": "naposledy viděna",
"domains.namePlaceholder": "example.com",
"domains.addTitle": "Sledovat doménu",
"domains.editTitle": "Upravit doménu",
"domains.addDesc": "WHOIS, DNS, TLS certifikát a host — obnoveno ve vašem intervalu.",
"domains.refreshEvery": "Obnovovat každých (hodin)",
"domains.alertDays": "Dní před expirací pro alert",
"domains.certDaysLbl": "Práh TLS alertu (dny)",
"domains.certDaysPlaceholder": "stejné jako expirace",
"domains.autoRenewLbl": "Auto-renew zapnutý u registrátora",
"domains.notesOptional": "Poznámky (volitelné)",
"domains.lookingUp": "Dotazuji…",
"domains.nameRequired": "Doména je povinná",
"domains.saveFailed": "uložení selhalo",
"domains.add": "Sledovat",
"domains.domainExpires": "Doména expiruje",
"domains.sslExpires": "SSL expiruje",
"domains.registration": "Registrace",
"domains.dns": "DNS",
"domains.tlsCert": "TLS certifikát",
"domains.host": "Host",
"domains.checkHistory": "Historie kontrol",
"domains.noSnapshots": "Zatím žádné snímky.",
"domains.created": "Vytvořeno",
"domains.updated": "Aktualizováno",
"domains.registrant": "Registrant",
"domains.country": "Země",
"domains.abuse": "Abuse",
"domains.statusLbl": "Stav",
"domains.location": "Lokalita",
"domains.isp": "ISP",
"domains.org": "Organizace",
"domains.checked": "Zkontrolováno",
"domains.dnsProvider": "DNS poskytovatel",
"domains.email": "E-mail",
"domains.issuer": "Vydavatel",
"domains.valid": "Platnost",
"domains.subject": "Subjekt",
"domains.key": "Klíč",
"domains.algorithm": "Algoritmus",
"domains.responseHeaders": "Hlavičky odpovědi ({n})",
"domains.transferLock": "transfer lock",
"domains.whoisPrivacy": "whois ochrana",
"domains.autoRenew": "auto-renew",
"domains.scanShort": "Sken",
"domains.noSubsFound": "Žádné subdomény nenalezeny — sken dotazuje certificate transparency.",
"domains.expPrefix": "exp",
"domains.sslPrefix": "ssl",
"systems.title": "Systémy",
"systems.subtitle": "Servery a PC reportující přes dash-agent.",
"systems.new": "Nový systém",
"systems.empty": "Zatím žádné systémy.",
"systems.emptyHintPre": "Přidejte jeden, poté spusťte",
"systems.emptyHintPost": "na hostiteli s vydaným tokenem.",
"systems.waiting": "čeká se na agenta…",
"systems.cores": "{n} jader",
"systems.newTitle": "Nový systém",
"systems.newDesc": "Pojmenujte hostitele, poté na něm spusťte dash-agent s vydaným tokenem.",
"systems.agentCmdFor": "Příkaz agenta pro",
"systems.tokenWarning": "Token identifikuje tento systém — nedávejte ho do veřejných repozitářů.",
"systems.done": "Hotovo",
"systems.copy": "Kopírovat",
"systems.deleteConfirm": "Smazat systém \"{name}\"?",
"systems.agentHint": "Spusťte agenta s tímto tokenem; statistiky dorazí sem.",
"systems.token": "Bearer token",
"systems.containers": "Kontejnery",
"systems.drives": "Disky",
"systems.zfs": "ZFS pooly",
"systems.gpu": "GPU",
"systems.cpu": "CPU",
"systems.mem": "Paměť",
"systems.disk": "Disk",
"systems.net": "Síť",
"systems.temp": "Teplota",
"systems.load": "Zátěž",
"systems.up": "up",
"systems.down": "down",
"systems.namePlaceholder": "nas",
"systems.addTitle": "Přidat systém",
"systems.addDesc": "Vytvoří bearer token — spusťte s ním dash-agent.",
"systems.notFound": "Systém nenalezen.",
"systems.usage": "Využití",
"systems.cpuPct": "CPU %",
"systems.memPct": "Paměť %",
"systems.netKbs": "Síť KB/s",
"systems.load1m": "Load 1m",
"systems.temperatures": "Teploty",
"systems.drivesSmart": "Disky (SMART)",
"systems.agentCmd": "Příkaz agenta",
"systems.neverSeen": "nikdy neviděn",
"systems.seenAt": "viděn {time}",
"systems.failing": "selhává",
"systems.uptime": "Doba běhu",
"incidents.title": "Incidenty",
"incidents.subtitle": "Automaticky otevřené při výpadcích monitorů, nebo vedené ručně.",
"incidents.new": "Nový incident",
"incidents.empty": "Žádné otevřené incidenty.",
"incidents.resolve": "Vyřešit",
"incidents.acknowledge": "Potvrdit",
"incidents.close": "Zavřít",
"incidents.reopen": "Znovu otevřít",
"incidents.deleteConfirm": "Smazat incident \"{title}\"?",
"incidents.openOnly": "Jen otevřené",
"incidents.showAll": "Zobrazit vše",
"incidents.noUpdates": "Zatím žádné aktualizace.",
"incidents.notePlaceholder": "Přidat poznámku…",
"incidents.post": "Odeslat",
"incidents.open": "otevřený",
"incidents.resolved": "vyřešený",
"incidents.editTitle": "Upravit incident",
"incidents.newTitle": "Nový incident",
"incidents.desc": "Ručně evidujte výpadek nebo degradaci.",
"incidents.severity": "Závažnost",
"incidents.sevMinor": "menší",
"incidents.sevMajor": "významný",
"incidents.sevCritical": "kritický",
"incidents.status": "Stav",
"incidents.monitor": "Monitor",
"incidents.bodyLabel": "Podrobnosti",
"incidents.noteOptional": "Poznámka (volitelná)",
"incidents.titlePlaceholder": "NAS offline",
"incidents.noteFirst": "Poprvé pozorováno…",
"incidents.createError": "Incident se nepodařilo vytvořit.",
"statusPages.title": "Stavové stránky",
"statusPages.publicAt": "Veřejné živé náhledy na",
"statusPages.new": "Nová stránka",
"statusPages.empty": "Zatím žádné stavové stránky.",
"statusPages.open": "Otevřít veřejnou stránku",
"statusPages.deleteConfirm": "Smazat stavovou stránku \"{title}\"?",
"statusPages.createError": "Nepodařilo se vytvořit — slug může být obsazený.",
"statusPages.newTitle": "Nová stavová stránka",
"statusPages.newDesc": "Veřejný živý náhled nad vybranými monitory.",
"statusPages.monitors": "Monitory",
"statusPages.services": "Služby",
"statusPages.noMonitors": "Zatím žádné monitory — prázdný výběr zobrazí vše.",
"statusPages.allHint": "Nechte vše nezaškrtnuté pro zahrnutí všech aktivních monitorů.",
"statusPages.slugPlaceholder": "auto",
"statusPages.titlePlaceholder": "Stav homelab",
"maintenance.title": "Okna údržby",
"maintenance.subtitle": "Aktivní okna ztišují alerty + auto-incidenty a označují pokryté monitory.",
"maintenance.schedule": "Naplánovat",
"maintenance.empty": "Žádná naplánovaná údržba.",
"maintenance.deleteConfirm": "Smazat okno \"{title}\"?",
"maintenance.active": "aktivní",
"maintenance.scheduled": "naplánované",
"maintenance.dialogTitle": "Naplánovat údržbu",
"maintenance.dialogDesc": "Potlačí alerty a auto-incidenty po dobu okna.",
"maintenance.starts": "Začátek",
"maintenance.ends": "Konec",
"maintenance.titlePlaceholder": "Upgrade sítě",
"maintenance.createError": "Neplatné okno — zkontrolujte časy.",
"statusPublic.operational": "Všechny systémy v provozu",
"statusPublic.degraded": "Částečný výpadek",
"statusPublic.outage": "Velký výpadek",
"statusPublic.underMaintenance": "Probíhá údržba",
"statusPublic.poweredBy": "Běží na Dash",
"statusPublic.updated": "Aktualizováno {time}",
"statusPublic.noMonitors": "Tato stránka zatím nemá monitory.",
"statusPublic.uptime90": "90denní dostupnost",
"statusPublic.services": "Služby",
"statusPublic.systems": "Systémy",
"statusPublic.maintenance": "Údržba",
"statusPublic.incidents": "Incidenty",
"statusPublic.checking": "Kontroluji…",
"statusPublic.notFound": "Stavová stránka nenalezena.",
"statusPublic.footer": "běží na dash",
};
+388
View File
@@ -20,8 +20,396 @@ export const en = {
"common.delete": "Delete",
"common.cancel": "Cancel",
"common.save": "Save",
"common.create": "Create",
"common.creating": "Creating…",
"common.saving": "Saving…",
"common.test": "Test",
"common.loading": "Loading…",
"common.name": "Name",
"common.title": "Title",
"common.description": "Description",
"common.slug": "Slug",
"common.url": "URL",
"common.type": "Type",
"common.hostname": "Hostname",
"common.port": "Port",
"common.optional": "optional",
"common.enabled": "Enabled",
"common.refresh": "Refresh",
"common.never": "never",
"common.all": "All",
"common.reset": "Reset",
"common.close": "Close",
"auth.setupTitle": "Create your admin account",
"auth.setupHint": "First user becomes the admin. Local account only — no external identity.",
"auth.signInTitle": "Sign in",
"auth.signInHint": "This Dash instance requires authentication.",
"auth.username": "Username or email",
"auth.password": "Password",
"auth.createAccount": "Create account",
"auth.signIn": "Sign in",
"auth.invalid": "Invalid username or password.",
"auth.rateLimited": "Too many attempts — wait a minute.",
"auth.section": "Authentication",
"auth.offHint": "Anyone who can reach this instance has full access. Enable to require sign-in.",
"auth.enable": "Enable authentication",
"auth.onHint": "Signed in as",
"auth.signOut": "Sign out",
"auth.disable": "Disable authentication",
"auth.disabledNote": "Auth is off — the dashboard is open to your network.",
"boards.title": "Boards",
"boards.manage": "Manage boards…",
"boards.newPlaceholder": "New board name",
"board.empty": "No sections yet.",
"board.emptyHint": "Add a service or a widget to get started.",
"board.addSection": "Add section",
"board.sectionNamePlaceholder": "Section name",
"board.deleteSectionConfirm": "Delete section \"{name}\" and its {count} items?",
"palette.title": "Command palette",
"palette.placeholder": "Search services and actions…",
"palette.noResults": "No matches.",
"palette.pages": "Pages",
"palette.services": "Services",
"palette.actions": "Actions",
"palette.goTo": "Go to {page}",
"palette.openSettings": "Open settings",
"palette.themeSwitch": "Switch to {theme} theme",
"palette.renderer": "Renderer: {name}",
"palette.exportJson": "Export board JSON",
"palette.hintNew": "new",
"palette.hintActive": "active",
"settings.title": "Settings",
"settings.desc": "Board layout, theme, and backup.",
"settings.theme": "Theme",
"settings.language": "Language",
"settings.dark": "Dark",
"settings.light": "Light",
"settings.renderer": "Board renderer",
"settings.appearance": "Appearance",
"settings.accent": "Accent color",
"settings.accentHint": "Any CSS color — hex, hsl, named.",
"settings.wallpaper": "Wallpaper URL",
"settings.wallpaperHint": "Full-bleed background image behind the board.",
"settings.customCss": "Custom CSS",
"settings.customCssHint": "Injected last — wins over theme variables.",
"settings.notifications": "Notifications",
"settings.notifyFanout": "Monitor flips and domain/certificate expiry fan out to every configured transport.",
"settings.exportJson": "Export JSON",
"settings.import": "Import",
"settings.importHint": "Import accepts Dash exports plus Homepage, Homarr, and Dashy configs.",
"settings.saved": "saved",
"settings.sent": "sent",
"settings.failed": "failed",
"settings.testNotify": "Send test notification",
"settings.wallpaperPh": "https://… — blank for none",
"settings.importFailed": "Import failed",
"service.editTitle": "Edit service",
"service.newTitle": "Add service",
"service.desc": "A bookmark on the board — name, icon, one or more URLs.",
"service.newDesc": "Name it, give it an icon, add one or more URLs.",
"service.editDesc": "Update name, icon, or launch URLs.",
"service.icon": "Icon URL",
"service.iconHint": "URL or uploaded file; blank shows a letter tile.",
"service.upload": "Upload",
"service.urls": "URLs",
"service.addUrl": "Add URL",
"service.labelPlaceholder": "label",
"service.removeUrl": "Remove URL",
"service.suggest": "suggest: {slug}.png",
"service.alsoMonitor": "Also create an uptime monitor for the first URL",
"service.nameRequired": "Name is required",
"service.urlRequired": "At least one valid http(s) URL is required",
"service.saveFailed": "save failed",
"service.addService": "Add service",
"widget.editTitle": "Edit widget",
"widget.newTitle": "Add widget",
"widget.desc": "Live data on the board.",
"widget.newDesc": "Pick a type and point it at your service.",
"widget.editDesc": "Update widget settings.",
"widget.type": "Widget type",
"widget.section": "Section",
"widget.pickOne": "Pick one…",
"widget.pickType": "Pick a widget type",
"widget.requiredFields": "Fill in the required fields",
"widget.defaultSection": "Widgets",
"widget.err": "err",
"widget.offline": "offline",
"widget.fetchFailed": "fetch failed",
"widget.queries": "{n} queries",
"widget.blocked": "{n} blocked",
"widget.msAvg": "{n} ms avg",
"widget.videos": "{n} videos",
"widget.blockedLbl": "blocked",
"widget.photosLbl": "photos",
"widget.embedHint": "Set a URL in the widget config.",
"renderer.localTime": "Local time",
"renderer.down": "Down",
"renderer.allGreen": "all green",
"renderer.offline": "offline",
"renderer.needsAttention": "needs attention",
"renderer.downCount": "{n} down",
"renderer.entry": "entry",
"renderer.entries": "entries",
"renderer.dragSection": "Drag section",
"renderer.deleteSection": "Delete section",
"renderer.live": "live",
"monitors.title": "Monitors",
"monitors.subtitle": "HTTP, TCP, ping, DNS, keyword, JSON, and push checks.",
"monitors.new": "New monitor",
"monitors.empty": "No monitors yet.",
"monitors.emptyHint": "Add one to start checking uptime.",
"monitors.deleteConfirm": "Delete monitor \"{name}\" and its history?",
"monitors.checkNow": "Check now",
"monitors.pause": "Pause",
"monitors.resume": "Resume",
"monitors.editTitle": "Edit monitor",
"monitors.newTitle": "New monitor",
"monitors.desc": "Uptime check on a schedule.",
"monitors.method": "Method",
"monitors.keyword": "Keyword",
"monitors.keywordInvert": "Invert (down when found)",
"monitors.jsonPath": "JSON path",
"monitors.expected": "Expected",
"monitors.record": "Record",
"monitors.dnsExpected": "Expected record contains (optional)",
"monitors.pushHint": "After saving, your jobs call the shown push URL on a schedule. If it goes quiet for 2× the interval, the monitor flips down.",
"monitors.interval": "Interval (s)",
"monitors.timeout": "Timeout (s)",
"monitors.retries": "Retries",
"monitors.failsBeforeDown": "Fails before down",
"monitors.latencyWarn": "Latency warn (ms)",
"monitors.mute": "Mute alerts",
"monitors.pushUrl": "Push URL",
"monitors.addMonitor": "Add monitor",
"monitors.nameRequired": "Name is required",
"monitors.urlRequired": "URL is required",
"monitors.hostnameRequired": "Hostname is required",
"monitors.saveFailed": "save failed",
"monitors.uptime24h": "24h uptime",
"monitors.avgLatency": "Avg latency",
"monitors.lastCheck": "Last check",
"monitors.latencyHistory": "Latency",
"monitors.uptimeHistory": "Uptime history",
"monitors.noHeartbeats": "No heartbeats yet.",
"monitors.everyInterval": "every {interval}s",
"monitors.status": "Status",
"monitors.uptime30d": "Uptime 30d",
"monitors.ping": "Ping",
"monitors.every": "Every",
"monitors.checksHint": "HTTP, TCP, ping, DNS, keyword, JSON path, or push — checks run server-side on your interval.",
"monitors.uptime": "Uptime",
"monitors.checks": "Checks",
"monitors.responseTime": "Response time",
"monitors.recentChecks": "Recent checks",
"monitors.noChecks": "No checks in range.",
"monitors.noChecksBucket": "no checks",
"monitors.bucketChecks": "{status} ({n} checks)",
"domains.title": "Domains",
"domains.subtitle": "WHOIS, DNS, and certificate watch.",
"domains.new": "Track domain",
"domains.empty": "No domains tracked yet.",
"domains.emptyHint": "Expiry, registrar, DNS records, SSL certificates, and provider detection.",
"domains.domain": "Domain",
"domains.ssl": "SSL",
"domains.providers": "Providers",
"domains.lookupError": "lookup error",
"domains.refreshNow": "Refresh now",
"domains.deleteConfirm": "Stop tracking \"{name}\"?",
"domains.refresh": "Re-check",
"domains.expiresIn": "expires in {days}d",
"domains.expired": "expired",
"domains.certDays": "cert {days}d",
"domains.registrar": "Registrar",
"domains.expires": "Expires",
"domains.cert": "Certificate",
"domains.nameservers": "Nameservers",
"domains.hosting": "Hosting",
"domains.subdomains": "Subdomains",
"domains.subdomainsHint": "Discovered via Certificate Transparency logs; resolved over DNS.",
"domains.scan": "Scan now",
"domains.scanning": "Scanning…",
"domains.noSubdomains": "No subdomains discovered yet — run a scan.",
"domains.lastSeen": "last seen",
"domains.namePlaceholder": "example.com",
"domains.addTitle": "Track a domain",
"domains.editTitle": "Edit domain",
"domains.addDesc": "WHOIS, DNS, TLS certificate, and host info — refreshed on your interval.",
"domains.refreshEvery": "Refresh every (hours)",
"domains.alertDays": "Alert days before expiry",
"domains.certDaysLbl": "TLS alert threshold (days)",
"domains.certDaysPlaceholder": "same as expiry",
"domains.autoRenewLbl": "Auto-renew enabled at the registrar",
"domains.notesOptional": "Notes (optional)",
"domains.lookingUp": "Looking up…",
"domains.nameRequired": "Domain is required",
"domains.saveFailed": "save failed",
"domains.add": "Track",
"domains.domainExpires": "Domain expires",
"domains.sslExpires": "SSL expires",
"domains.registration": "Registration",
"domains.dns": "DNS",
"domains.tlsCert": "TLS certificate",
"domains.host": "Host",
"domains.checkHistory": "Check history",
"domains.noSnapshots": "No snapshots yet.",
"domains.created": "Created",
"domains.updated": "Updated",
"domains.registrant": "Registrant",
"domains.country": "Country",
"domains.abuse": "Abuse",
"domains.statusLbl": "Status",
"domains.location": "Location",
"domains.isp": "ISP",
"domains.org": "Org",
"domains.checked": "Checked",
"domains.dnsProvider": "DNS provider",
"domains.email": "Email",
"domains.issuer": "Issuer",
"domains.valid": "Valid",
"domains.subject": "Subject",
"domains.key": "Key",
"domains.algorithm": "Algorithm",
"domains.responseHeaders": "Response headers ({n})",
"domains.transferLock": "transfer-locked",
"domains.whoisPrivacy": "whois privacy",
"domains.autoRenew": "auto-renew",
"domains.scanShort": "Scan",
"domains.noSubsFound": "No subdomains discovered — scan queries certificate transparency.",
"domains.expPrefix": "exp",
"domains.sslPrefix": "ssl",
"systems.title": "Systems",
"systems.subtitle": "Servers and PCs reporting via dash-agent.",
"systems.new": "New system",
"systems.empty": "No systems yet.",
"systems.emptyHintPre": "Add one, then run",
"systems.emptyHintPost": "on the host with the issued token.",
"systems.waiting": "waiting for agent…",
"systems.cores": "{n} cores",
"systems.newTitle": "New system",
"systems.newDesc": "Name the host, then run dash-agent on it with the issued token.",
"systems.agentCmdFor": "Agent command for",
"systems.tokenWarning": "The token identifies this system — keep it out of public repos.",
"systems.done": "Done",
"systems.copy": "Copy",
"systems.deleteConfirm": "Delete system \"{name}\"?",
"systems.agentHint": "Run the agent with this token; stats land here.",
"systems.token": "Bearer token",
"systems.containers": "Containers",
"systems.drives": "Drives",
"systems.zfs": "ZFS pools",
"systems.gpu": "GPU",
"systems.cpu": "CPU",
"systems.mem": "Memory",
"systems.disk": "Disk",
"systems.net": "Network",
"systems.temp": "Temp",
"systems.load": "Load",
"systems.up": "up",
"systems.down": "down",
"systems.namePlaceholder": "nas",
"systems.addTitle": "Add a system",
"systems.addDesc": "Creates a bearer token — run dash-agent with it.",
"systems.notFound": "System not found.",
"systems.usage": "Usage",
"systems.cpuPct": "CPU %",
"systems.memPct": "Memory %",
"systems.netKbs": "Network KB/s",
"systems.load1m": "Load 1m",
"systems.temperatures": "Temperatures",
"systems.drivesSmart": "Drives (SMART)",
"systems.agentCmd": "Agent command",
"systems.neverSeen": "never seen",
"systems.seenAt": "seen {time}",
"systems.failing": "failing",
"systems.uptime": "Uptime",
"incidents.title": "Incidents",
"incidents.subtitle": "Auto-opened on monitor outages, or tracked manually.",
"incidents.new": "New incident",
"incidents.empty": "No open incidents.",
"incidents.resolve": "Resolve",
"incidents.acknowledge": "Acknowledge",
"incidents.close": "Close",
"incidents.reopen": "Reopen",
"incidents.deleteConfirm": "Delete incident \"{title}\"?",
"incidents.openOnly": "Open only",
"incidents.showAll": "Show all",
"incidents.noUpdates": "No updates yet.",
"incidents.notePlaceholder": "Add a note…",
"incidents.post": "Post",
"incidents.open": "open",
"incidents.resolved": "resolved",
"incidents.editTitle": "Edit incident",
"incidents.newTitle": "New incident",
"incidents.desc": "Track an outage or degradation manually.",
"incidents.severity": "Severity",
"incidents.sevMinor": "minor",
"incidents.sevMajor": "major",
"incidents.sevCritical": "critical",
"incidents.status": "Status",
"incidents.monitor": "Monitor",
"incidents.bodyLabel": "Details",
"incidents.noteOptional": "Note (optional)",
"incidents.titlePlaceholder": "NAS offline",
"incidents.noteFirst": "First observed…",
"incidents.createError": "Could not create incident.",
"statusPages.title": "Status pages",
"statusPages.publicAt": "Public live views at",
"statusPages.new": "New page",
"statusPages.empty": "No status pages yet.",
"statusPages.open": "Open public page",
"statusPages.deleteConfirm": "Delete status page \"{title}\"?",
"statusPages.createError": "Could not create — slug may be taken.",
"statusPages.newTitle": "New status page",
"statusPages.newDesc": "Public live view over selected monitors.",
"statusPages.monitors": "Monitors",
"statusPages.services": "Services",
"statusPages.noMonitors": "No monitors yet — empty selection shows all.",
"statusPages.allHint": "Leave all unchecked to include every active monitor.",
"statusPages.slugPlaceholder": "auto",
"statusPages.titlePlaceholder": "Homelab status",
"maintenance.title": "Maintenance windows",
"maintenance.subtitle": "Active windows mute alerts + auto-incidents and mark covered monitors.",
"maintenance.schedule": "Schedule",
"maintenance.empty": "No maintenance scheduled.",
"maintenance.deleteConfirm": "Delete window \"{title}\"?",
"maintenance.active": "active",
"maintenance.scheduled": "scheduled",
"maintenance.dialogTitle": "Schedule maintenance",
"maintenance.dialogDesc": "Suppresses alerts and auto-incidents for the window.",
"maintenance.starts": "Starts",
"maintenance.ends": "Ends",
"maintenance.titlePlaceholder": "Network upgrade",
"maintenance.createError": "Invalid window — check times.",
"statusPublic.operational": "All systems operational",
"statusPublic.degraded": "Partial outage",
"statusPublic.outage": "Major outage",
"statusPublic.underMaintenance": "Under maintenance",
"statusPublic.poweredBy": "Powered by Dash",
"statusPublic.updated": "Updated {time}",
"statusPublic.noMonitors": "No monitors on this page yet.",
"statusPublic.uptime90": "90-day uptime",
"statusPublic.services": "Services",
"statusPublic.systems": "Systems",
"statusPublic.maintenance": "Maintenance",
"statusPublic.incidents": "Incidents",
"statusPublic.checking": "Checking…",
"statusPublic.notFound": "Status page not found.",
"statusPublic.footer": "powered by dash",
} as const;
export type Key = keyof typeof en;
+21 -7
View File
@@ -1,13 +1,27 @@
import { en, type Key } from "@/i18n/en";
import { cs } from "@/i18n/cs";
export type { Key } from "@/i18n/en";
// i18n seam: dictionaries are plain objects keyed by dotted names. `t` is a
// typed lookup that falls back to the key itself, so a missing translation
// renders the key — loud in dev, never a crash. Locales beyond `en` plug in
// here later (settings key + lazy import); nothing else changes.
const dict: Record<Key, string> = en;
export const LOCALES = { en: "English", cs: "Čeština" } as const;
export type Locale = keyof typeof LOCALES;
export function t(key: Key): string {
return dict[key] ?? key;
// i18n seam: dictionaries are plain objects keyed by dotted names. `t` is a
// typed lookup that falls back to English, then to the key itself — a missing
// translation is loud in dev, never a crash. The store calls setLocale when
// the `locale` setting loads/changes; components re-render on prefs updates.
const dicts: Record<Locale, Partial<Record<Key, string>>> = { en, cs };
let active: Locale = "en";
export function setLocale(l: string) {
active = l === "cs" ? "cs" : "en";
document.documentElement.lang = active;
}
export function t(key: Key, params?: Record<string, string | number>): string {
let s: string = dicts[active][key] ?? en[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) s = s.replaceAll(`{${k}}`, String(v));
}
return s;
}
+6
View File
@@ -16,3 +16,9 @@ createRoot(root).render(
<App />
</StrictMode>,
);
// PWA: register the service worker only in production builds — in dev it would
// fight Vite's HMR by caching modules.
if (import.meta.env.PROD && "serviceWorker" in navigator) {
window.addEventListener("load", () => void navigator.serviceWorker.register("/sw.js"));
}
+88 -46
View File
@@ -2,10 +2,11 @@ import { useCallback, useEffect, useState } from "react";
import { Link } from "wouter";
import { ArrowLeft, Pause, Play, RefreshCw, Shield, Trash2 } from "lucide-react";
import { api } from "@/api";
import type { DomainCheck, DomainView } from "@/types";
import type { DomainCheck, DomainView, Subdomain } from "@/types";
import { DomainDialog } from "@/components/DomainDialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
function Row({ k, v, mono }: { k: string; v: React.ReactNode; mono?: boolean }) {
if (v === undefined || v === null || v === "") return null;
@@ -34,28 +35,32 @@ function fmtDate(iso: string | null | undefined) {
export function DomainDetailPage({ id }: { id: string }) {
const [d, setD] = useState<DomainView | null>(null);
const [checks, setChecks] = useState<DomainCheck[]>([]);
const [subs, setSubs] = useState<Subdomain[]>([]);
const [dlgOpen, setDlgOpen] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [subScanning, setSubScanning] = useState(false);
const load = useCallback(async () => {
const [{ data: dv }, { data: ck }] = await Promise.all([
const [{ data: dv }, { data: ck }, { data: sb }] = await Promise.all([
api.GET("/api/domains/{id}", { params: { path: { id } } }),
api.GET("/api/domains/{id}/checks", { params: { path: { id } } }),
api.GET("/api/domains/{id}/subdomains", { params: { path: { id } } }),
]);
if (dv) setD(dv);
if (ck) setChecks(ck);
if (sb) setSubs(sb);
}, [id]);
useEffect(() => { void load(); }, [load]);
if (!d) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">Loading…</main>;
if (!d) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.loading")}</main>;
const expiring = d.expiring || (d.daysUntilExpiry ?? Infinity) < 0;
return (
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<Link href="/domains" className="mb-4 inline-flex items-center gap-1.5 text-[12px] text-text-faint hover:text-text">
<ArrowLeft size={12} /> Domains
<ArrowLeft size={12} /> {t("nav.domains")}
</Link>
<div className="mb-6 flex items-start justify-between">
@@ -66,9 +71,9 @@ export function DomainDetailPage({ id }: { id: string }) {
<p className="mt-0.5 flex items-center gap-2 font-mono text-[11px] text-text-faint">
{d.tld ? `.${d.tld}` : ""}
{d.dnssec === "signed" && <span className="flex items-center gap-1 text-up"><Shield size={10} /> dnssec</span>}
{d.transferLock ? <span>transfer-locked</span> : null}
{d.privacyEnabled ? <span>whois privacy</span> : null}
{d.autoRenew ? <span>auto-renew</span> : null}
{d.transferLock ? <span>{t("domains.transferLock")}</span> : null}
{d.privacyEnabled ? <span>{t("domains.whoisPrivacy")}</span> : null}
{d.autoRenew ? <span>{t("domains.autoRenew")}</span> : null}
</p>
</div>
</div>
@@ -79,16 +84,16 @@ export function DomainDetailPage({ id }: { id: string }) {
void api.POST("/api/domains/{id}/refresh", { params: { path: { id } } })
.then(load).finally(() => setRefreshing(false));
}}>
<RefreshCw size={12} className={cn(refreshing && "animate-spin")} /> Refresh
<RefreshCw size={12} className={cn(refreshing && "animate-spin")} /> {t("common.refresh")}
</Button>
<Button variant="outline" size="sm"
onClick={() => void api.PATCH("/api/domains/{id}", { params: { path: { id } }, body: { active: !d.active } }).then(load)}>
{d.active ? <Pause size={12} /> : <Play size={12} />} {d.active ? "Pause" : "Resume"}
{d.active ? <Pause size={12} /> : <Play size={12} />} {d.active ? t("monitors.pause") : t("monitors.resume")}
</Button>
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>Edit</Button>
<Button variant="outline" size="sm" aria-label="Delete"
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>{t("common.edit")}</Button>
<Button variant="outline" size="sm" aria-label={t("common.delete")}
onClick={() => {
if (confirm(`Stop tracking "${d.name}"?`)) {
if (confirm(t("domains.deleteConfirm", { name: d.name ?? "" }))) {
void api.DELETE("/api/domains/{id}", { params: { path: { id } } }).then(() => { location.href = "/domains"; });
}
}}>
@@ -105,86 +110,86 @@ export function DomainDetailPage({ id }: { id: string }) {
<div className="mb-6 grid grid-cols-4 gap-3 max-[720px]:grid-cols-2">
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Domain expires</div>
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("domains.domainExpires")}</div>
<div className={cn("mt-1 font-mono text-[17px] font-medium", expiring && "text-down")}>
{d.daysUntilExpiry !== null && d.daysUntilExpiry !== undefined
? (d.daysUntilExpiry < 0 ? "expired" : `${d.daysUntilExpiry}d`)
? (d.daysUntilExpiry < 0 ? t("domains.expired") : `${d.daysUntilExpiry}d`)
: "—"}
</div>
<div className="font-mono text-[10px] text-text-faint">{fmtDate(d.expiryDate)}</div>
</div>
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">SSL expires</div>
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("domains.sslExpires")}</div>
<div className={cn("mt-1 font-mono text-[17px] font-medium", d.sslExpiring && "text-down")}>
{d.sslDaysUntilExpiry !== null && d.sslDaysUntilExpiry !== undefined
? (d.sslDaysUntilExpiry < 0 ? "expired" : `${d.sslDaysUntilExpiry}d`)
? (d.sslDaysUntilExpiry < 0 ? t("domains.expired") : `${d.sslDaysUntilExpiry}d`)
: "—"}
</div>
<div className="font-mono text-[10px] text-text-faint">{fmtDate(d.sslValidTo)}</div>
</div>
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Registrar</div>
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("domains.registrar")}</div>
<div className="mt-1 truncate text-[13px] font-medium">{d.registrarName || "—"}</div>
<div className="font-mono text-[10px] text-text-faint">{d.caProvider || "—"}</div>
</div>
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Providers</div>
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("domains.providers")}</div>
<div className="mt-1 truncate text-[13px] font-medium">{d.dnsProvider || "—"}</div>
<div className="font-mono text-[10px] text-text-faint">{d.hostingProvider || d.emailProvider || "—"}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3 max-[820px]:grid-cols-1">
<Section title="Registration">
<Row k="Created" v={fmtDate(d.creationDate)} />
<Row k="Updated" v={fmtDate(d.updatedDate)} />
<Row k="Registrar" v={d.registrarName} />
<Section title={t("domains.registration")}>
<Row k={t("domains.created")} v={fmtDate(d.creationDate)} />
<Row k={t("domains.updated")} v={fmtDate(d.updatedDate)} />
<Row k={t("domains.registrar")} v={d.registrarName} />
<Row k="IANA ID" v={d.registrarId} mono />
<Row k="Registry ID" v={d.registryDomainId} mono />
<Row k="Registrant" v={[d.registrantName, d.registrantOrg].filter(Boolean).join(" / ")} />
<Row k="Country" v={d.registrantCountry} />
<Row k="Abuse" v={d.abuseEmail} mono />
{d.statuses && d.statuses.length > 0 ? <Row k="Status" v={d.statuses.join(", ")} mono /> : null}
<Row k={t("domains.registrant")} v={[d.registrantName, d.registrantOrg].filter(Boolean).join(" / ")} />
<Row k={t("domains.country")} v={d.registrantCountry} />
<Row k={t("domains.abuse")} v={d.abuseEmail} mono />
{d.statuses && d.statuses.length > 0 ? <Row k={t("domains.statusLbl")} v={d.statuses.join(", ")} mono /> : null}
</Section>
<Section title="DNS">
<Section title={t("domains.dns")}>
{d.nameServers?.map((n) => <Row key={n} k="NS" v={n} mono />)}
{d.mxRecords?.map((m) => <Row key={m} k="MX" v={m} mono />)}
{d.ipv4?.map((a) => <Row key={a} k="A" v={a} mono />)}
{d.ipv6?.map((a) => <Row key={a} k="AAAA" v={a} mono />)}
{d.cname ? <Row k="CNAME" v={d.cname} mono /> : null}
{d.txtRecords?.slice(0, 4).map((t, i) => <Row key={i} k="TXT" v={t.length > 60 ? t.slice(0, 60) + "…" : t} mono />)}
{d.dnsProvider ? <Row k="DNS provider" v={d.dnsProvider} /> : null}
{d.emailProvider ? <Row k="Email" v={d.emailProvider} /> : null}
{d.dnsProvider ? <Row k={t("domains.dnsProvider")} v={d.dnsProvider} /> : null}
{d.emailProvider ? <Row k={t("domains.email")} v={d.emailProvider} /> : null}
</Section>
<Section title="TLS certificate">
<Row k="Issuer" v={d.sslIssuer} />
<Section title={t("domains.tlsCert")}>
<Row k={t("domains.issuer")} v={d.sslIssuer} />
<Row k="CA" v={d.caProvider} />
<Row k="Valid" v={`${fmtDate(d.sslValidFrom)} → ${fmtDate(d.sslValidTo)}`} mono />
<Row k="Subject" v={d.sslSubject} mono />
<Row k="Key" v={d.sslKeySize ? `${d.sslKeySize} bit` : ""} mono />
<Row k="Algorithm" v={d.sslSigAlgo} mono />
<Row k={t("domains.valid")} v={`${fmtDate(d.sslValidFrom)} → ${fmtDate(d.sslValidTo)}`} mono />
<Row k={t("domains.subject")} v={d.sslSubject} mono />
<Row k={t("domains.key")} v={d.sslKeySize ? `${d.sslKeySize} bit` : ""} mono />
<Row k={t("domains.algorithm")} v={d.sslSigAlgo} mono />
{d.sslAltNames && d.sslAltNames.length > 0 ? (
<Row k="SANs" v={d.sslAltNames.slice(0, 5).join(", ") + (d.sslAltNames.length > 5 ? ` +${d.sslAltNames.length - 5}` : "")} mono />
) : null}
<Row k="SHA-256" v={d.sslFingerprint ? d.sslFingerprint.slice(0, 29) + "…" : ""} mono />
</Section>
<Section title="Host">
<Row k="Location" v={[d.hostCity, d.hostRegion, d.hostCountry].filter(Boolean).join(", ")} />
<Row k="ISP" v={d.hostIsp} />
<Row k="Org" v={d.hostOrg} />
<Section title={t("domains.host")}>
<Row k={t("domains.location")} v={[d.hostCity, d.hostRegion, d.hostCountry].filter(Boolean).join(", ")} />
<Row k={t("domains.isp")} v={d.hostIsp} />
<Row k={t("domains.org")} v={d.hostOrg} />
<Row k="AS" v={d.hostAs} mono />
<Row k="Hosting" v={d.hostingProvider} />
<Row k="Checked" v={d.lastChecked ? new Date(d.lastChecked).toLocaleString() : "—"} mono />
<Row k={t("domains.hosting")} v={d.hostingProvider} />
<Row k={t("domains.checked")} v={d.lastChecked ? new Date(d.lastChecked).toLocaleString() : "—"} mono />
</Section>
</div>
{Object.keys(d.headers ?? {}).length > 0 ? (
<details className="mt-3 overflow-hidden rounded-[10px] border border-border bg-surface">
<summary className="cursor-pointer px-4 py-2.5 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">
Response headers ({Object.keys(d.headers ?? {}).length})
{t("domains.responseHeaders", { n: Object.keys(d.headers ?? {}).length })}
</summary>
<div className="border-t border-border px-4 py-2">
{Object.entries(d.headers ?? {}).map(([k, v]) => (
@@ -197,20 +202,57 @@ export function DomainDetailPage({ id }: { id: string }) {
</details>
) : null}
<h2 className="mb-2 mt-6 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Check history</h2>
<div className="mb-2 mt-6 flex items-center justify-between">
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">
{t("domains.subdomains")}{subs.length > 0 ? ` (${subs.length})` : ""}
</h2>
<Button variant="ghost" size="sm" disabled={subScanning}
onClick={() => {
setSubScanning(true);
void api.POST("/api/domains/{id}/subdomains/refresh", { params: { path: { id } } })
.then(({ data }) => { if (data) setSubs(data); })
.finally(() => setSubScanning(false));
}}>
<RefreshCw size={11} className={cn(subScanning && "animate-spin")} /> {t("domains.scanShort")}
</Button>
</div>
<div className="overflow-hidden rounded-[10px] border border-border">
<table className="w-full text-[12px]">
<tbody>
{subs.map((s) => (
<tr key={s.name} className="border-b border-border/60 last:border-0">
<td className="px-4 py-2 font-mono text-[11px]">{s.name}</td>
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">
{(s.ips ?? []).join(", ") || "—"}
</td>
<td className="px-4 py-2 text-right font-mono text-[10.5px] text-text-faint">
{fmtDate(s.lastSeen)}
</td>
</tr>
))}
{subs.length === 0 && (
<tr><td className="px-4 py-6 text-center text-text-faint">
{t("domains.noSubsFound")}
</td></tr>
)}
</tbody>
</table>
</div>
<h2 className="mb-2 mt-6 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("domains.checkHistory")}</h2>
<div className="overflow-hidden rounded-[10px] border border-border">
<table className="w-full text-[12px]">
<tbody>
{checks.map((c) => (
<tr key={c.id} className="border-b border-border/60 last:border-0">
<td className="px-4 py-2 font-mono text-[10.5px] text-text-faint">{new Date(c.checkedAt ?? "").toLocaleString()}</td>
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">exp {fmtDate(c.expiryDate)}</td>
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">ssl {fmtDate(c.sslValidTo)}</td>
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{t("domains.expPrefix")} {fmtDate(c.expiryDate)}</td>
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{t("domains.sslPrefix")} {fmtDate(c.sslValidTo)}</td>
<td className="px-4 py-2 font-mono text-[11px] text-text-faint">{(c.ipv4 ?? []).join(", ")}</td>
</tr>
))}
{checks.length === 0 && (
<tr><td className="px-4 py-6 text-center text-text-faint">No snapshots yet.</td></tr>
<tr><td className="px-4 py-6 text-center text-text-faint">{t("domains.noSnapshots")}</td></tr>
)}
</tbody>
</table>
+18 -18
View File
@@ -6,11 +6,12 @@ import type { DomainView } from "@/types";
import { DomainDialog } from "@/components/DomainDialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
function daysLabel(days: number | null | undefined, warn?: boolean) {
if (days === null || days === undefined) return "—";
const cls = warn || days < 0 ? "text-down" : days <= 30 ? "text-amber-500" : "text-text-dim";
return <span className={cn("font-mono text-[11px]", cls)}>{days < 0 ? "expired" : `${days}d`}</span>;
return <span className={cn("font-mono text-[11px]", cls)}>{days < 0 ? t("domains.expired") : `${days}d`}</span>;
}
export function DomainsPage() {
@@ -38,19 +39,19 @@ export function DomainsPage() {
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="text-[15px] font-semibold tracking-tight">Domains</h1>
<p className="text-[12px] text-text-faint">WHOIS, DNS, and certificate watch.</p>
<h1 className="text-[15px] font-semibold tracking-tight">{t("domains.title")}</h1>
<p className="text-[12px] text-text-faint">{t("domains.subtitle")}</p>
</div>
<Button onClick={() => { setEditing(undefined); setDlgOpen(true); }}>
<Plus size={13} strokeWidth={2.2} /> Track domain
<Plus size={13} strokeWidth={2.2} /> {t("domains.new")}
</Button>
</div>
{loaded && domains.length === 0 ? (
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
<p className="text-[13px] text-text-dim">No domains tracked yet.</p>
<p className="text-[13px] text-text-dim">{t("domains.empty")}</p>
<p className="mt-1 text-[11.5px] text-text-faint">
Expiry, registrar, DNS records, SSL certificates, and provider detection.
{t("domains.emptyHint")}
</p>
</div>
) : (
@@ -58,11 +59,11 @@ export function DomainsPage() {
<table className="w-full text-[13px]">
<thead>
<tr className="border-b border-border bg-surface text-left font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
<th className="px-4 py-2.5 font-medium">Domain</th>
<th className="px-4 py-2.5 font-medium">Registrar</th>
<th className="px-4 py-2.5 font-medium">Expires</th>
<th className="px-4 py-2.5 font-medium">SSL</th>
<th className="px-4 py-2.5 font-medium">Providers</th>
<th className="px-4 py-2.5 font-medium">{t("domains.domain")}</th>
<th className="px-4 py-2.5 font-medium">{t("domains.registrar")}</th>
<th className="px-4 py-2.5 font-medium">{t("domains.expires")}</th>
<th className="px-4 py-2.5 font-medium">{t("domains.ssl")}</th>
<th className="px-4 py-2.5 font-medium">{t("domains.providers")}</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
@@ -76,7 +77,7 @@ export function DomainsPage() {
) : null}
<Link href={`/domains/${d.id}`} className="hover:underline">{d.name}</Link>
{d.lookupError ? (
<span className="font-mono text-[10px] text-down" title={d.lookupError}>lookup error</span>
<span className="font-mono text-[10px] text-down" title={d.lookupError}>{t("domains.lookupError")}</span>
) : null}
</span>
</td>
@@ -88,7 +89,7 @@ export function DomainsPage() {
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Button variant="outline" size="icon" aria-label="Refresh now" disabled={refreshing === d.id}
<Button variant="outline" size="icon" aria-label={t("domains.refreshNow")} disabled={refreshing === d.id}
onClick={() => {
if (!d.id) return;
setRefreshing(d.id);
@@ -97,20 +98,19 @@ export function DomainsPage() {
}}>
<RefreshCw size={12} className={cn(refreshing === d.id && "animate-spin")} />
</Button>
<Button variant="outline" size="icon" aria-label={d.active ? "Pause" : "Resume"}
<Button variant="outline" size="icon" aria-label={d.active ? t("monitors.pause") : t("monitors.resume")}
onClick={() => void act(d.id, () => api.PATCH("/api/domains/{id}", { params: { path: { id: d.id! } }, body: { active: !d.active } }))}>
{d.active ? <Pause size={12} /> : <Play size={12} />}
</Button>
<Button variant="outline" size="icon" aria-label="Delete"
<Button variant="outline" size="icon" aria-label={t("common.delete")}
onClick={() => {
if (confirm(`Stop tracking "${d.name}"?`)) {
if (confirm(t("domains.deleteConfirm", { name: d.name ?? "" }))) {
void act(d.id, () => api.DELETE("/api/domains/{id}", { params: { path: { id: d.id! } } }));
}
}}>
<Trash2 size={12} />
</Button>
<Button variant="outline" size="sm" onClick={() => { setEditing(d); setDlgOpen(true); }}>
Edit
<Button variant="outline" size="sm" onClick={() => { setEditing(d); setDlgOpen(true); }}>{t("common.edit")}
</Button>
</div>
</td>
+26 -25
View File
@@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
const SEV_COLOR = {
critical: "text-down",
@@ -19,9 +20,9 @@ const SEV_COLOR = {
type Status = NonNullable<Incident["status"]>;
const NEXT: Partial<Record<Status, { label: string; to: Status }[]>> = {
open: [{ label: "Acknowledge", to: "ack" }, { label: "Resolve", to: "resolved" }],
ack: [{ label: "Resolve", to: "resolved" }],
resolved: [{ label: "Close", to: "closed" }],
open: [{ label: t("incidents.acknowledge"), to: "ack" }, { label: t("incidents.resolve"), to: "resolved" }],
ack: [{ label: t("incidents.resolve"), to: "resolved" }],
resolved: [{ label: t("incidents.close"), to: "closed" }],
};
export function IncidentsPage() {
@@ -83,22 +84,22 @@ export function IncidentsPage() {
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="text-[15px] font-semibold tracking-tight">Incidents</h1>
<p className="text-[12px] text-text-faint">Auto-opened on monitor outages, or tracked manually.</p>
<h1 className="text-[15px] font-semibold tracking-tight">{t("incidents.title")}</h1>
<p className="text-[12px] text-text-faint">{t("incidents.subtitle")}</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setShowAll((v) => !v)}>
{showAll ? "Open only" : "Show all"}
{showAll ? t("incidents.openOnly") : t("incidents.showAll")}
</Button>
<Button onClick={() => setDlgOpen(true)}>
<Plus size={13} strokeWidth={2.2} /> New incident
<Plus size={13} strokeWidth={2.2} /> {t("incidents.new")}
</Button>
</div>
</div>
{loaded && incidents.length === 0 ? (
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
<p className="text-[13px] text-text-dim">No open incidents.</p>
<p className="text-[13px] text-text-dim">{t("incidents.empty")}</p>
</div>
) : (
<div className="flex flex-col gap-2.5">
@@ -122,9 +123,9 @@ export function IncidentsPage() {
{n.label}
</Button>
))}
<Button variant="outline" size="icon" aria-label="Delete"
<Button variant="outline" size="icon" aria-label={t("common.delete")}
onClick={() => {
if (inc.id && confirm(`Delete incident "${inc.title}"?`)) {
if (inc.id && confirm(t("incidents.deleteConfirm", { title: inc.title ?? "" }))) {
void api.DELETE("/api/incidents/{id}", { params: { path: { id: inc.id } } }).then(load);
}
}}>
@@ -144,14 +145,14 @@ export function IncidentsPage() {
</div>
))}
{(inc.updates ?? []).length === 0 && (
<p className="text-[11.5px] text-text-faint">No updates yet.</p>
<p className="text-[11.5px] text-text-faint">{t("incidents.noUpdates")}</p>
)}
</div>
<div className="flex gap-2">
<Input value={note} onChange={(e) => setNote(e.target.value)}
placeholder="Add a note…" className="h-8 text-[12px]"
placeholder={t("incidents.notePlaceholder")} className="h-8 text-[12px]"
onKeyDown={(e) => { if (e.key === "Enter") void addNote(inc.id); }} />
<Button variant="outline" size="sm" onClick={() => void addNote(inc.id)}>Post</Button>
<Button variant="outline" size="sm" onClick={() => void addNote(inc.id)}>{t("incidents.post")}</Button>
</div>
</div>
)}
@@ -163,37 +164,37 @@ export function IncidentsPage() {
<Dialog open={dlgOpen} onOpenChange={setDlgOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New incident</DialogTitle>
<DialogDescription>Track an outage or degradation manually.</DialogDescription>
<DialogTitle>{t("incidents.newTitle")}</DialogTitle>
<DialogDescription>{t("incidents.desc")}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="inc-title">Title</Label>
<Label htmlFor="inc-title">{t("common.title")}</Label>
<Input id="inc-title" value={title} onChange={(e) => setTitle(e.target.value)}
placeholder="NAS offline" autoFocus />
placeholder={t("incidents.titlePlaceholder")} autoFocus />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label>Severity</Label>
<Label>{t("incidents.severity")}</Label>
<Select value={sev} onValueChange={setSev}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="minor">minor</SelectItem>
<SelectItem value="major">major</SelectItem>
<SelectItem value="critical">critical</SelectItem>
<SelectItem value="minor">{t("incidents.sevMinor")}</SelectItem>
<SelectItem value="major">{t("incidents.sevMajor")}</SelectItem>
<SelectItem value="critical">{t("incidents.sevCritical")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="inc-msg">Note (optional)</Label>
<Input id="inc-msg" value={msg} onChange={(e) => setMsg(e.target.value)} placeholder="First observed…" />
<Label htmlFor="inc-msg">{t("incidents.noteOptional")}</Label>
<Input id="inc-msg" value={msg} onChange={(e) => setMsg(e.target.value)} placeholder={t("incidents.noteFirst")} />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>Cancel</Button>
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>{t("common.cancel")}</Button>
<Button onClick={() => void create()} disabled={busy || !title.trim()}>
{busy ? "Creating…" : "Create"}
{busy ? t("common.creating") : t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
+72
View File
@@ -0,0 +1,72 @@
import { useState, type FormEvent } from "react";
import { api } from "@/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { t } from "@/i18n";
/** Login/setup gate shown when auth is enabled and no session exists. */
export function LoginPage({ setup, onDone }: { setup: boolean; onDone: () => void }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
async function submit(e: FormEvent) {
e.preventDefault();
setErr("");
setBusy(true);
const body = { username: username.trim(), password };
const { error, response } = setup
? await api.POST("/api/auth/setup", { body })
: await api.POST("/api/auth/login", { body });
setBusy(false);
if (error) {
setErr(response.status === 429 ? t("auth.rateLimited") : t("auth.invalid"));
return;
}
onDone();
}
return (
<div className="flex min-h-screen items-center justify-center bg-bg p-6">
<form
onSubmit={(e) => void submit(e)}
className="w-full max-w-[320px] rounded-xl border border-border bg-surface p-6 shadow-lg"
>
<div className="mb-5 flex items-center gap-2.5 text-[15px] font-semibold tracking-tight">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden>
<rect x="1" y="1" width="7" height="7" rx="1.5" fill="currentColor" />
<rect x="10" y="1" width="7" height="7" rx="1.5" fill="currentColor" opacity=".45" />
<rect x="1" y="10" width="7" height="7" rx="1.5" fill="currentColor" opacity=".45" />
<rect x="10" y="10" width="7" height="7" rx="1.5" fill="currentColor" />
</svg>
Dash
</div>
<h1 className="mb-1 text-[14px] font-medium">{setup ? t("auth.setupTitle") : t("auth.signInTitle")}</h1>
<p className="mb-4 text-[12px] text-text-faint">{setup ? t("auth.setupHint") : t("auth.signInHint")}</p>
<div className="grid gap-3">
<div className="grid gap-1.5">
<Label htmlFor="auth-user">{t("auth.username")}</Label>
<Input
id="auth-user" autoComplete="username" autoFocus
value={username} onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="auth-pass">{t("auth.password")}</Label>
<Input
id="auth-pass" type="password"
autoComplete={setup ? "new-password" : "current-password"}
value={password} onChange={(e) => setPassword(e.target.value)}
/>
</div>
{err && <p className="text-[12px] text-danger">{err}</p>}
<Button type="submit" disabled={busy || !username.trim() || password.length < 8} className="mt-1">
{setup ? t("auth.createAccount") : t("auth.signIn")}
</Button>
</div>
</form>
</div>
);
}
+60 -15
View File
@@ -7,6 +7,7 @@ import type { Heartbeat, MonitorView } from "@/types";
import { MonitorDialog } from "@/components/MonitorDialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
export function MonitorDetailPage({ id }: { id: string }) {
const [m, setM] = useState<MonitorView | null>(null);
@@ -29,7 +30,7 @@ export function MonitorDetailPage({ id }: { id: string }) {
return () => clearInterval(t);
}, [load]);
if (!m) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">Loading…</main>;
if (!m) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.loading")}</main>;
const chart = hbs.map((h) => ({
t: new Date(h.checkedAt ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
@@ -40,7 +41,7 @@ export function MonitorDetailPage({ id }: { id: string }) {
return (
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<Link href="/monitors" className="mb-4 inline-flex items-center gap-1.5 text-[12px] text-text-faint hover:text-text">
<ArrowLeft size={12} /> Monitors
<ArrowLeft size={12} /> {t("nav.monitors")}
</Link>
<div className="mb-6 flex items-start justify-between">
@@ -51,21 +52,21 @@ export function MonitorDetailPage({ id }: { id: string }) {
<span className="font-mono text-[10.5px] uppercase tracking-[0.08em] text-text-faint">{m.status}</span>
</div>
<p className="mt-1 font-mono text-[11.5px] text-text-faint">
{m.type} · {m.url || m.hostname}{m.port ? `:${m.port}` : ""} · every {m.intervalS}s
{m.type} · {m.url || m.hostname}{m.port ? `:${m.port}` : ""} · {t("monitors.everyInterval", { interval: m.intervalS ?? 0 })}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => void api.POST("/api/monitors/{id}/check", { params: { path: { id } } }).then(load)}>
<RefreshCw size={12} /> Check now
<RefreshCw size={12} /> {t("monitors.checkNow")}
</Button>
<Button variant="outline" size="sm"
onClick={() => void api.PATCH("/api/monitors/{id}", { params: { path: { id } }, body: { active: !m.active } }).then(load)}>
{m.active ? <Pause size={12} /> : <Play size={12} />} {m.active ? "Pause" : "Resume"}
{m.active ? <Pause size={12} /> : <Play size={12} />} {m.active ? t("monitors.pause") : t("monitors.resume")}
</Button>
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>Edit</Button>
<Button variant="outline" size="sm" aria-label="Delete"
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>{t("common.edit")}</Button>
<Button variant="outline" size="sm" aria-label={t("common.delete")}
onClick={() => {
if (confirm(`Delete monitor "${m.name}"?`)) {
if (confirm(t("monitors.deleteConfirm", { name: m.name ?? "" }))) {
void api.DELETE("/api/monitors/{id}", { params: { path: { id } } }).then(() => { location.href = "/monitors"; });
}
}}>
@@ -76,10 +77,10 @@ export function MonitorDetailPage({ id }: { id: string }) {
<div className="mb-6 grid grid-cols-4 gap-3 max-[720px]:grid-cols-2">
{[
["Uptime 24h", `${m.stats?.uptime24h?.toFixed(2)}%`],
["Uptime 30d", `${m.stats?.uptime30d?.toFixed(2)}%`],
["Avg ping 24h", m.stats?.avgPing24h ? `${m.stats.avgPing24h} ms` : "—"],
["Checks", String((m.stats?.up ?? 0) + (m.stats?.down ?? 0))],
[t("monitors.uptime24h"), `${m.stats?.uptime24h?.toFixed(2)}%`],
[t("monitors.uptime30d"), `${m.stats?.uptime30d?.toFixed(2)}%`],
[t("monitors.avgLatency"), m.stats?.avgPing24h ? `${m.stats.avgPing24h} ms` : "—"],
[t("monitors.checks"), String((m.stats?.up ?? 0) + (m.stats?.down ?? 0))],
].map(([k, v]) => (
<div key={k} className="rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{k}</div>
@@ -88,8 +89,13 @@ export function MonitorDetailPage({ id }: { id: string }) {
))}
</div>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.uptime")}</h2>
<div className="mb-6 rounded-[10px] border border-border bg-surface px-3 py-2.5">
<UptimeBar hbs={hbs} hours={hours} />
</div>
<div className="mb-2 flex items-center justify-between">
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Response time</h2>
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.responseTime")}</h2>
<div className="flex gap-1">
{[24, 168, 720].map((h) => (
<button key={h} type="button" onClick={() => setHours(h)}
@@ -115,7 +121,7 @@ export function MonitorDetailPage({ id }: { id: string }) {
</ResponsiveContainer>
</div>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Recent checks</h2>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.recentChecks")}</h2>
<div className="overflow-hidden rounded-[10px] border border-border">
<table className="w-full text-[12px]">
<tbody>
@@ -134,7 +140,7 @@ export function MonitorDetailPage({ id }: { id: string }) {
</tr>
))}
{hbs.length === 0 && (
<tr><td className="px-4 py-6 text-center text-text-faint">No checks in range.</td></tr>
<tr><td className="px-4 py-6 text-center text-text-faint">{t("monitors.noChecks")}</td></tr>
)}
</tbody>
</table>
@@ -144,3 +150,42 @@ export function MonitorDetailPage({ id }: { id: string }) {
</main>
);
}
/** UptimeBar renders the check history as a strip of status-colored bars —
* the window is split into fixed buckets; a bucket is "down" if any check in
* it failed, so outages stay visible at long ranges. */
function UptimeBar({ hbs, hours }: { hbs: Heartbeat[]; hours: number }) {
const BUCKETS = 90;
const now = Date.now();
const span = hours * 3600_000;
const start = now - span;
const buckets: { status: "up" | "down" | "empty"; from: number; to: number; n: number }[] = [];
for (let i = 0; i < BUCKETS; i++) {
buckets.push({ status: "empty", from: start + (i * span) / BUCKETS, to: start + ((i + 1) * span) / BUCKETS, n: 0 });
}
for (const h of hbs) {
const t = new Date(h.checkedAt ?? "").getTime();
if (Number.isNaN(t) || t < start) continue;
const i = Math.min(BUCKETS - 1, Math.floor(((t - start) / span) * BUCKETS));
const b = buckets[i];
if (!b) continue;
b.n++;
if (h.status === "down") b.status = "down";
else if (b.status === "empty") b.status = "up";
}
const fmt = (t: number) => new Date(t).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
return (
<div className="flex h-7 items-stretch gap-[2px]">
{buckets.map((b, i) => (
<div
key={i}
title={`${fmt(b.from)} – ${fmt(b.to)} · ${b.n === 0 ? t("monitors.noChecksBucket") : t("monitors.bucketChecks", { status: b.status, n: b.n })}`}
className={cn(
"flex-1 rounded-[2px]",
b.status === "down" ? "bg-down" : b.status === "up" ? "bg-up/80 hover:bg-up" : "bg-border/50",
)}
/>
))}
</div>
);
}
+18 -17
View File
@@ -6,6 +6,7 @@ import type { MonitorView } from "@/types";
import { MonitorDialog } from "@/components/MonitorDialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
function StatusDot({ status }: { status?: string }) {
return (
@@ -46,19 +47,19 @@ export function MonitorsPage() {
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="text-[15px] font-semibold tracking-tight">Monitors</h1>
<p className="text-[12px] text-text-faint">Uptime checks on a schedule.</p>
<h1 className="text-[15px] font-semibold tracking-tight">{t("monitors.title")}</h1>
<p className="text-[12px] text-text-faint">{t("monitors.desc")}</p>
</div>
<Button onClick={() => { setEditing(undefined); setDlgOpen(true); }}>
<Plus size={13} strokeWidth={2.2} /> New monitor
<Plus size={13} strokeWidth={2.2} /> {t("monitors.new")}
</Button>
</div>
{loaded && monitors.length === 0 ? (
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
<p className="text-[13px] text-text-dim">No monitors yet.</p>
<p className="text-[13px] text-text-dim">{t("monitors.empty")}</p>
<p className="mt-1 text-[11.5px] text-text-faint">
HTTP, TCP, ping, DNS, keyword, JSON path, or push — checks run server-side on your interval.
{t("monitors.checksHint")}
</p>
</div>
) : (
@@ -66,13 +67,13 @@ export function MonitorsPage() {
<table className="w-full text-[13px]">
<thead>
<tr className="border-b border-border bg-surface text-left font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
<th className="px-4 py-2.5 font-medium">Status</th>
<th className="px-4 py-2.5 font-medium">Name</th>
<th className="px-4 py-2.5 font-medium">Type</th>
<th className="px-4 py-2.5 font-medium">Uptime 24h</th>
<th className="px-4 py-2.5 font-medium">Uptime 30d</th>
<th className="px-4 py-2.5 font-medium">Ping</th>
<th className="px-4 py-2.5 font-medium">Every</th>
<th className="px-4 py-2.5 font-medium">{t("monitors.status")}</th>
<th className="px-4 py-2.5 font-medium">{t("common.name")}</th>
<th className="px-4 py-2.5 font-medium">{t("common.type")}</th>
<th className="px-4 py-2.5 font-medium">{t("monitors.uptime24h")}</th>
<th className="px-4 py-2.5 font-medium">{t("monitors.uptime30d")}</th>
<th className="px-4 py-2.5 font-medium">{t("monitors.ping")}</th>
<th className="px-4 py-2.5 font-medium">{t("monitors.every")}</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
@@ -101,24 +102,24 @@ export function MonitorsPage() {
<td className="px-4 py-3 font-mono text-[11px] text-text-dim">{m.intervalS}s</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Button variant="outline" size="icon" aria-label="Check now"
<Button variant="outline" size="icon" aria-label={t("monitors.checkNow")}
onClick={() => void act(m.id, () => api.POST("/api/monitors/{id}/check", { params: { path: { id: m.id! } } }))}>
<RefreshCw size={12} />
</Button>
<Button variant="outline" size="icon" aria-label={m.active ? "Pause" : "Resume"}
<Button variant="outline" size="icon" aria-label={m.active ? t("monitors.pause") : t("monitors.resume")}
onClick={() => void act(m.id, () => api.PATCH("/api/monitors/{id}", { params: { path: { id: m.id! } }, body: { active: !m.active } }))}>
{m.active ? <Pause size={12} /> : <Play size={12} />}
</Button>
<Button variant="outline" size="icon" aria-label="Delete"
<Button variant="outline" size="icon" aria-label={t("common.delete")}
onClick={() => {
if (confirm(`Delete monitor "${m.name}"?`)) {
if (confirm(t("monitors.deleteConfirm", { name: m.name ?? "" }))) {
void act(m.id, () => api.DELETE("/api/monitors/{id}", { params: { path: { id: m.id! } } }));
}
}}>
<Trash2 size={12} />
</Button>
<Button variant="outline" size="sm" onClick={() => { setEditing(m); setDlgOpen(true); }}>
Edit
{t("common.edit")}
</Button>
</div>
</td>
+38 -37
View File
@@ -10,6 +10,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
// Admin for public status pages + maintenance windows. The public view
// itself lives at /status/:slug.
@@ -63,7 +64,7 @@ export function StatusPagesPage() {
},
});
setBusy(false);
if (error) { setErr("Could not create — slug may be taken."); return; }
if (error) { setErr(t("statusPages.createError")); return; }
setPageOpen(false); setPTitle(""); setPSlug(""); setPDesc(""); setPMonitors([]);
void load();
}
@@ -79,7 +80,7 @@ export function StatusPagesPage() {
},
});
setBusy(false);
if (error) { setErr("Invalid window — check times."); return; }
if (error) { setErr(t("maintenance.createError")); return; }
setWinOpen(false); setWTitle(""); setWStart(""); setWEnd("");
void load();
}
@@ -88,17 +89,17 @@ export function StatusPagesPage() {
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="text-[15px] font-semibold tracking-tight">Status pages</h1>
<p className="text-[12px] text-text-faint">Public live views at <code className="font-mono">/status/&lt;slug&gt;</code>.</p>
<h1 className="text-[15px] font-semibold tracking-tight">{t("statusPages.title")}</h1>
<p className="text-[12px] text-text-faint">{t("statusPages.publicAt")} <code className="font-mono">/status/&lt;slug&gt;</code>.</p>
</div>
<Button onClick={() => setPageOpen(true)}>
<Plus size={13} strokeWidth={2.2} /> New page
<Plus size={13} strokeWidth={2.2} /> {t("statusPages.new")}
</Button>
</div>
{loaded && pages.length === 0 ? (
<div className="mb-8 rounded-[10px] border border-dashed border-border px-6 py-10 text-center">
<p className="text-[13px] text-text-dim">No status pages yet.</p>
<p className="text-[13px] text-text-dim">{t("statusPages.empty")}</p>
</div>
) : (
<div className="mb-8 flex flex-col gap-2">
@@ -107,11 +108,11 @@ export function StatusPagesPage() {
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{p.title}</span>
<code className="font-mono text-[11px] text-text-faint">/status/{p.slug}</code>
<Link href={`/status/${p.slug}`} className="shrink-0">
<Button variant="outline" size="icon" aria-label="Open public page"><ExternalLink size={12} /></Button>
<Button variant="outline" size="icon" aria-label={t("statusPages.open")}><ExternalLink size={12} /></Button>
</Link>
<Button variant="outline" size="icon" aria-label="Delete"
<Button variant="outline" size="icon" aria-label={t("common.delete")}
onClick={() => {
if (p.id && confirm(`Delete status page "${p.title}"?`)) {
if (p.id && confirm(t("statusPages.deleteConfirm", { title: p.title ?? "" }))) {
void api.DELETE("/api/status-pages/{id}", { params: { path: { id: p.id } } }).then(load);
}
}}>
@@ -124,17 +125,17 @@ export function StatusPagesPage() {
<div className="mb-5 flex items-center justify-between">
<div>
<h2 className="text-[15px] font-semibold tracking-tight">Maintenance windows</h2>
<p className="text-[12px] text-text-faint">Active windows mute alerts + auto-incidents and mark covered monitors.</p>
<h2 className="text-[15px] font-semibold tracking-tight">{t("maintenance.title")}</h2>
<p className="text-[12px] text-text-faint">{t("maintenance.subtitle")}</p>
</div>
<Button variant="outline" onClick={() => setWinOpen(true)}>
<Plus size={13} strokeWidth={2.2} /> Schedule
<Plus size={13} strokeWidth={2.2} /> {t("maintenance.schedule")}
</Button>
</div>
{loaded && windows.length === 0 ? (
<div className="rounded-[10px] border border-dashed border-border px-6 py-10 text-center">
<p className="text-[13px] text-text-dim">No maintenance scheduled.</p>
<p className="text-[13px] text-text-dim">{t("maintenance.empty")}</p>
</div>
) : (
<div className="flex flex-col gap-2">
@@ -146,11 +147,11 @@ export function StatusPagesPage() {
{new Date(w.startsAt ?? "").toLocaleString()} → {new Date(w.endsAt ?? "").toLocaleString()}
</span>
<span className={cn("font-mono text-[10px] uppercase", w.active ? "text-up" : "text-text-faint")}>
{w.active ? "active" : "scheduled"}
{w.active ? t("maintenance.active") : t("maintenance.scheduled")}
</span>
<Button variant="outline" size="icon" aria-label="Delete"
<Button variant="outline" size="icon" aria-label={t("common.delete")}
onClick={() => {
if (w.id && confirm(`Delete window "${w.title}"?`)) {
if (w.id && confirm(t("maintenance.deleteConfirm", { title: w.title ?? "" }))) {
void api.DELETE("/api/maintenance/{id}", { params: { path: { id: w.id } } }).then(load);
}
}}>
@@ -164,26 +165,26 @@ export function StatusPagesPage() {
<Dialog open={pageOpen} onOpenChange={setPageOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New status page</DialogTitle>
<DialogDescription>Public live view over selected monitors.</DialogDescription>
<DialogTitle>{t("statusPages.newTitle")}</DialogTitle>
<DialogDescription>{t("statusPages.newDesc")}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="sp-title">Title</Label>
<Input id="sp-title" value={pTitle} onChange={(e) => setPTitle(e.target.value)} placeholder="Homelab status" autoFocus />
<Label htmlFor="sp-title">{t("common.title")}</Label>
<Input id="sp-title" value={pTitle} onChange={(e) => setPTitle(e.target.value)} placeholder={t("statusPages.titlePlaceholder")} autoFocus />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="sp-slug">Slug</Label>
<Input id="sp-slug" value={pSlug} onChange={(e) => setPSlug(e.target.value)} placeholder="auto" className="font-mono text-[12px]" />
<Label htmlFor="sp-slug">{t("common.slug")}</Label>
<Input id="sp-slug" value={pSlug} onChange={(e) => setPSlug(e.target.value)} placeholder={t("statusPages.slugPlaceholder")} className="font-mono text-[12px]" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="sp-desc">Description</Label>
<Input id="sp-desc" value={pDesc} onChange={(e) => setPDesc(e.target.value)} placeholder="optional" />
<Label htmlFor="sp-desc">{t("common.description")}</Label>
<Input id="sp-desc" value={pDesc} onChange={(e) => setPDesc(e.target.value)} placeholder={t("common.optional")} />
</div>
<div className="flex flex-col gap-1.5">
<Label>Monitors</Label>
<Label>{t("statusPages.monitors")}</Label>
<div className="max-h-40 overflow-y-auto rounded-[7px] border border-border p-2">
{monitors.map((m) => (
<label key={m.id} className="flex items-center gap-2 px-1 py-1 text-[12px]">
@@ -194,16 +195,16 @@ export function StatusPagesPage() {
{m.name}
</label>
))}
{monitors.length === 0 && <p className="px-1 py-1 text-[11.5px] text-text-faint">No monitors yet — empty selection shows all.</p>}
{monitors.length === 0 && <p className="px-1 py-1 text-[11.5px] text-text-faint">{t("statusPages.noMonitors")}</p>}
</div>
<p className="text-[11px] text-text-faint">Leave all unchecked to include every active monitor.</p>
<p className="text-[11px] text-text-faint">{t("statusPages.allHint")}</p>
</div>
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPageOpen(false)} disabled={busy}>Cancel</Button>
<Button variant="outline" onClick={() => setPageOpen(false)} disabled={busy}>{t("common.cancel")}</Button>
<Button onClick={() => void createPage()} disabled={busy || !pTitle.trim()}>
{busy ? "Creating…" : "Create"}
{busy ? t("common.creating") : t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
@@ -212,30 +213,30 @@ export function StatusPagesPage() {
<Dialog open={winOpen} onOpenChange={setWinOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Schedule maintenance</DialogTitle>
<DialogDescription>Suppresses alerts and auto-incidents for the window.</DialogDescription>
<DialogTitle>{t("maintenance.dialogTitle")}</DialogTitle>
<DialogDescription>{t("maintenance.dialogDesc")}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mw-title">Title</Label>
<Input id="mw-title" value={wTitle} onChange={(e) => setWTitle(e.target.value)} placeholder="Network upgrade" autoFocus />
<Label htmlFor="mw-title">{t("common.title")}</Label>
<Input id="mw-title" value={wTitle} onChange={(e) => setWTitle(e.target.value)} placeholder={t("maintenance.titlePlaceholder")} autoFocus />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mw-start">Starts</Label>
<Label htmlFor="mw-start">{t("maintenance.starts")}</Label>
<Input id="mw-start" type="datetime-local" value={wStart} onChange={(e) => setWStart(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="mw-end">Ends</Label>
<Label htmlFor="mw-end">{t("maintenance.ends")}</Label>
<Input id="mw-end" type="datetime-local" value={wEnd} onChange={(e) => setWEnd(e.target.value)} />
</div>
</div>
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setWinOpen(false)} disabled={busy}>Cancel</Button>
<Button variant="outline" onClick={() => setWinOpen(false)} disabled={busy}>{t("common.cancel")}</Button>
<Button onClick={() => void createWindow()} disabled={busy || !wTitle.trim() || !wStart || !wEnd}>
{busy ? "Saving…" : "Schedule"}
{busy ? t("common.saving") : t("maintenance.schedule")}
</Button>
</DialogFooter>
</DialogContent>
+12 -11
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { api } from "@/api";
import type { PublicStatus } from "@/types";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
// Public status page — clean read-only view; refreshes every 30s.
// Route: /status/:slug -> GET /api/status-pages/:slug/public.
@@ -9,10 +10,10 @@ import { cn } from "@/lib/utils";
// status strings arrive as plain `string` — map with functions, not
// open dictionaries, so the lookup type stays honest.
function overallBanner(overall?: string) {
if (overall === "down") return { label: "Major outage", cls: "bg-down" };
if (overall === "maintenance") return { label: "Under maintenance", cls: "bg-text-faint/60" };
if (overall === "up") return { label: "All systems operational", cls: "bg-up" };
return { label: "Checking…", cls: "bg-text-faint/40" };
if (overall === "down") return { label: t("statusPublic.outage"), cls: "bg-down" };
if (overall === "maintenance") return { label: t("statusPublic.underMaintenance"), cls: "bg-text-faint/60" };
if (overall === "up") return { label: t("statusPublic.operational"), cls: "bg-up" };
return { label: t("statusPublic.checking"), cls: "bg-text-faint/40" };
}
function dotCls(status?: string) {
@@ -38,8 +39,8 @@ export function StatusPublicPage({ slug }: { slug: string }) {
return () => clearInterval(t);
}, [load]);
if (missing) return <main className="mx-auto max-w-2xl px-7 py-16 text-center text-[13px] text-text-faint">Status page not found.</main>;
if (!st) return <main className="mx-auto max-w-2xl px-7 py-16 text-center text-[13px] text-text-faint">Loading…</main>;
if (missing) return <main className="mx-auto max-w-2xl px-7 py-16 text-center text-[13px] text-text-faint">{t("statusPublic.notFound")}</main>;
if (!st) return <main className="mx-auto max-w-2xl px-7 py-16 text-center text-[13px] text-text-faint">{t("common.loading")}</main>;
const banner = overallBanner(st.overall);
@@ -56,7 +57,7 @@ export function StatusPublicPage({ slug }: { slug: string }) {
{(st.monitors ?? []).length > 0 && (
<section className="mb-6">
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Services</h2>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("statusPublic.services")}</h2>
<div className="overflow-hidden rounded-[10px] border border-border">
{(st.monitors ?? []).map((m) => (
<div key={m.id} className="flex items-center gap-3 border-b border-border/60 px-4 py-2.5 last:border-0">
@@ -72,7 +73,7 @@ export function StatusPublicPage({ slug }: { slug: string }) {
{(st.systems ?? []).length > 0 && (
<section className="mb-6">
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Systems</h2>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("statusPublic.systems")}</h2>
<div className="overflow-hidden rounded-[10px] border border-border">
{(st.systems ?? []).map((s) => (
<div key={s.id} className="flex items-center gap-3 border-b border-border/60 px-4 py-2.5 last:border-0">
@@ -87,7 +88,7 @@ export function StatusPublicPage({ slug }: { slug: string }) {
{(st.maintenance ?? []).length > 0 && (
<section className="mb-6">
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Maintenance</h2>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("statusPublic.maintenance")}</h2>
<div className="overflow-hidden rounded-[10px] border border-border">
{(st.maintenance ?? []).map((w) => (
<div key={w.id} className="flex items-center gap-3 border-b border-border/60 px-4 py-2.5 last:border-0">
@@ -103,7 +104,7 @@ export function StatusPublicPage({ slug }: { slug: string }) {
{(st.incidents ?? []).length > 0 && (
<section className="mb-6">
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Incidents</h2>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("statusPublic.incidents")}</h2>
<div className="flex flex-col gap-2">
{(st.incidents ?? []).map((inc) => (
<div key={inc.id} className="rounded-[10px] border border-border px-4 py-3">
@@ -122,7 +123,7 @@ export function StatusPublicPage({ slug }: { slug: string }) {
</section>
)}
<p className="mt-10 text-center font-mono text-[10px] text-text-faint">powered by dash</p>
<p className="mt-10 text-center font-mono text-[10px] text-text-faint">{t("statusPublic.footer")}</p>
</main>
);
}
+109 -21
View File
@@ -7,6 +7,7 @@ import type { System, SystemStat } from "@/types";
import { agentCmd, fmtBytes, fmtUptime } from "@/pages/SystemsPage";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
export function SystemDetailPage({ id }: { id: string }) {
const [sys, setSys] = useState<System | null>(null);
@@ -32,11 +33,11 @@ export function SystemDetailPage({ id }: { id: string }) {
if (notFound) return (
<main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">
<Link href="/systems" className="inline-flex items-center gap-1.5 hover:text-text"><ArrowLeft size={12} /> Systems</Link>
<p className="mt-4">System not found.</p>
<Link href="/systems" className="inline-flex items-center gap-1.5 hover:text-text"><ArrowLeft size={12} /> {t("nav.systems")}</Link>
<p className="mt-4">{t("systems.notFound")}</p>
</main>
);
if (!sys) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">Loading…</main>;
if (!sys) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.loading")}</main>;
const l = sys.latest;
const chart = stats.map((s) => ({
@@ -53,7 +54,7 @@ export function SystemDetailPage({ id }: { id: string }) {
return (
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<Link href="/systems" className="mb-4 inline-flex items-center gap-1.5 text-[12px] text-text-faint hover:text-text">
<ArrowLeft size={12} /> Systems
<ArrowLeft size={12} /> {t("nav.systems")}
</Link>
<div className="mb-6 flex items-start justify-between">
@@ -66,14 +67,14 @@ export function SystemDetailPage({ id }: { id: string }) {
</div>
<p className="mt-1 font-mono text-[11.5px] text-text-faint">
{sys.host ? `${sys.host} · ` : ""}{sys.os}/{sys.arch}
{sys.cpuModel ? ` · ${sys.cpuModel}` : ""}{sys.cores ? ` · ${sys.cores} cores` : ""}
{sys.lastSeenAt ? ` · seen ${new Date(sys.lastSeenAt).toLocaleTimeString()}` : " · never seen"}
{sys.cpuModel ? ` · ${sys.cpuModel}` : ""}{sys.cores ? ` · ${t("systems.cores", { n: sys.cores })}` : ""}
{sys.lastSeenAt ? ` · ${t("systems.seenAt", { time: new Date(sys.lastSeenAt).toLocaleTimeString() })}` : ` · ${t("systems.neverSeen")}`}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" aria-label="Delete"
<Button variant="outline" size="sm" aria-label={t("common.delete")}
onClick={() => {
if (confirm(`Delete system "${sys.name}"?`)) {
if (confirm(t("systems.deleteConfirm", { name: sys.name ?? "" }))) {
void api.DELETE("/api/systems/{id}", { params: { path: { id } } }).then(() => { location.href = "/systems"; });
}
}}>
@@ -84,10 +85,10 @@ export function SystemDetailPage({ id }: { id: string }) {
<div className="mb-6 grid grid-cols-4 gap-3 max-[720px]:grid-cols-2">
{[
["CPU", l?.cpu !== undefined ? `${l.cpu.toFixed(1)}%` : "—"],
["Memory", l?.memTotal ? `${fmtBytes(l.memUsed ?? 0)} / ${fmtBytes(l.memTotal)}` : "—"],
["Disk /", l?.diskTotal ? `${fmtBytes(l.diskUsed ?? 0)} / ${fmtBytes(l.diskTotal)}` : "—"],
["Uptime", l?.uptimeS ? fmtUptime(l.uptimeS) : "—"],
[t("systems.cpu"), l?.cpu !== undefined ? `${l.cpu.toFixed(1)}%` : "—"],
[t("systems.mem"), l?.memTotal ? `${fmtBytes(l.memUsed ?? 0)} / ${fmtBytes(l.memTotal)}` : "—"],
[t("systems.disk") + " /", l?.diskTotal ? `${fmtBytes(l.diskUsed ?? 0)} / ${fmtBytes(l.diskTotal)}` : "—"],
[t("systems.uptime"), l?.uptimeS ? fmtUptime(l.uptimeS) : "—"],
].map(([k, v]) => (
<div key={k} className="rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{k}</div>
@@ -97,7 +98,7 @@ export function SystemDetailPage({ id }: { id: string }) {
</div>
<div className="mb-2 flex items-center justify-between">
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Usage</h2>
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("systems.usage")}</h2>
<div className="flex gap-1">
{[1, 6, 24, 168].map((h) => (
<button key={h} type="button" onClick={() => setHours(h)}
@@ -111,7 +112,7 @@ export function SystemDetailPage({ id }: { id: string }) {
<div className="mb-6 grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">CPU %</div>
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.cpuPct")}</div>
<ResponsiveContainer width="100%" height="85%">
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
@@ -123,7 +124,7 @@ export function SystemDetailPage({ id }: { id: string }) {
</ResponsiveContainer>
</div>
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">Memory %</div>
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.memPct")}</div>
<ResponsiveContainer width="100%" height="85%">
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
@@ -135,7 +136,7 @@ export function SystemDetailPage({ id }: { id: string }) {
</ResponsiveContainer>
</div>
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">Network KB/s</div>
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.netKbs")}</div>
<ResponsiveContainer width="100%" height="85%">
<LineChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -18 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
@@ -148,7 +149,7 @@ export function SystemDetailPage({ id }: { id: string }) {
</ResponsiveContainer>
</div>
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">Load 1m</div>
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.load1m")}</div>
<ResponsiveContainer width="100%" height="85%">
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
@@ -165,7 +166,7 @@ export function SystemDetailPage({ id }: { id: string }) {
{l?.temps && Object.keys(l.temps).length > 0 && (
<div className="rounded-[10px] border border-border">
<h2 className="border-b border-border bg-surface px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
Temperatures
{t("systems.temperatures")}
</h2>
<table className="w-full text-[12px]">
<tbody>
@@ -182,13 +183,23 @@ export function SystemDetailPage({ id }: { id: string }) {
{l?.containers && l.containers.length > 0 && (
<div className="rounded-[10px] border border-border">
<h2 className="border-b border-border bg-surface px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
Containers
{t("systems.containers")}
</h2>
<table className="w-full text-[12px]">
<tbody>
{l.containers.map((ctr) => (
<tr key={ctr.name} className="border-b border-border/60 last:border-0">
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{ctr.name}</td>
<td className="px-4 py-2 text-right font-mono text-[11px] text-text-faint">
{ctr.state === "running" && (ctr.cpu || ctr.memUsed) ? (
<>
{(ctr.cpu ?? 0).toFixed(1)}%
{" · "}
{fmtBytes(ctr.memUsed ?? 0)}
{ctr.memLimit ? ` / ${fmtBytes(ctr.memLimit)}` : ""}
</>
) : null}
</td>
<td className="px-4 py-2 text-right">
<span className={cn("font-mono text-[10.5px] uppercase",
ctr.state === "running" ? "text-up" : "text-text-faint")}>
@@ -201,15 +212,92 @@ export function SystemDetailPage({ id }: { id: string }) {
</table>
</div>
)}
{l?.smart && l.smart.length > 0 && (
<div className="rounded-[10px] border border-border">
<h2 className="border-b border-border bg-surface px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
{t("systems.drivesSmart")}
</h2>
<table className="w-full text-[12px]">
<tbody>
{l.smart.map((d) => (
<tr key={d.device} className="border-b border-border/60 last:border-0">
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{d.device}</td>
<td className="px-4 py-2 text-[11px] text-text-dim">{d.model}</td>
<td className="px-4 py-2 text-right font-mono text-[11px]">
{d.tempC ? `${d.tempC.toFixed(0)}°C` : ""}
</td>
<td className="px-4 py-2 text-right">
<span className={cn("font-mono text-[10.5px] uppercase",
d.passed === false ? "text-destructive" : "text-up")}>
{d.passed === false ? t("systems.failing") : d.passed ? "ok" : "—"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{l?.zfs && l.zfs.length > 0 && (
<div className="rounded-[10px] border border-border">
<h2 className="border-b border-border bg-surface px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
{t("systems.zfs")}
</h2>
<table className="w-full text-[12px]">
<tbody>
{l.zfs.map((p) => (
<tr key={p.name} className="border-b border-border/60 last:border-0">
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{p.name}</td>
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">
{p.size ? `${fmtBytes((p.size ?? 0) - (p.free ?? 0))} / ${fmtBytes(p.size)}` : ""}
</td>
<td className="px-4 py-2 text-right">
<span className={cn("font-mono text-[10.5px] uppercase",
p.health === "ONLINE" ? "text-up" : "text-destructive")}>
{p.health}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{l?.gpu && l.gpu.length > 0 && (
<div className="rounded-[10px] border border-border">
<h2 className="border-b border-border bg-surface px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
GPU
</h2>
<table className="w-full text-[12px]">
<tbody>
{l.gpu.map((g) => (
<tr key={g.name} className="border-b border-border/60 last:border-0">
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{g.name}</td>
<td className="px-4 py-2 text-right font-mono text-[11px] text-text-dim">
{[
g.utilPct ? `${g.utilPct.toFixed(0)}%` : "",
g.tempC ? `${g.tempC.toFixed(0)}°C` : "",
g.memTotal ? `${fmtBytes(g.memUsed ?? 0)} / ${fmtBytes(g.memTotal)}` : "",
].filter(Boolean).join(" · ")}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="mt-6 rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="mb-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Agent command</div>
<div className="mb-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("systems.agentCmd")}</div>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto whitespace-nowrap font-mono text-[11px] text-text-dim">
{agentCmd(sys)}
</code>
<Button variant="outline" size="icon" aria-label="Copy"
<Button variant="outline" size="icon" aria-label={t("systems.copy")}
onClick={() => void navigator.clipboard.writeText(agentCmd(sys))}>
<Copy size={12} />
</Button>
+20 -19
View File
@@ -10,6 +10,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
const TB = 2 ** 40;
const GB = 2 ** 30;
@@ -79,7 +80,7 @@ export function SystemsPage() {
}
async function remove(id: string | undefined, n: string | undefined) {
if (!id || !confirm(`Delete system "${n}"?`)) return;
if (!id || !confirm(t("systems.deleteConfirm", { name: n ?? "" }))) return;
await api.DELETE("/api/systems/{id}", { params: { path: { id } } });
await load();
}
@@ -88,19 +89,19 @@ export function SystemsPage() {
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="text-[15px] font-semibold tracking-tight">Systems</h1>
<p className="text-[12px] text-text-faint">Servers and PCs reporting via dash-agent.</p>
<h1 className="text-[15px] font-semibold tracking-tight">{t("systems.title")}</h1>
<p className="text-[12px] text-text-faint">{t("systems.subtitle")}</p>
</div>
<Button onClick={() => { setCreated(null); setDlgOpen(true); }}>
<Plus size={13} strokeWidth={2.2} /> New system
<Plus size={13} strokeWidth={2.2} /> {t("systems.new")}
</Button>
</div>
{loaded && systems.length === 0 ? (
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
<p className="text-[13px] text-text-dim">No systems yet.</p>
<p className="text-[13px] text-text-dim">{t("systems.empty")}</p>
<p className="mt-1 text-[11.5px] text-text-faint">
Add one, then run <code className="font-mono">dash-agent</code> on the host with the issued token.
{t("systems.emptyHintPre")} <code className="font-mono">dash-agent</code> {t("systems.emptyHintPost")}
</p>
</div>
) : (
@@ -122,18 +123,18 @@ export function SystemsPage() {
<Link href={`/systems/${sys.id}`} className="min-w-0 flex-1 truncate text-[13px] font-medium hover:underline">
{sys.name}
</Link>
<Button variant="outline" size="icon" aria-label="Delete"
<Button variant="outline" size="icon" aria-label={t("common.delete")}
onClick={() => void remove(sys.id, sys.name)}>
<Trash2 size={11} />
</Button>
</div>
{sys.host ? (
<p className="mb-2.5 truncate font-mono text-[10.5px] text-text-faint">
{sys.host} · {sys.cpuModel || `${sys.cores} cores`}
{sys.host} · {sys.cpuModel || t("systems.cores", { n: sys.cores ?? 0 })}
{l?.uptimeS ? ` · up ${fmtUptime(l.uptimeS)}` : ""}
</p>
) : (
<p className="mb-2.5 font-mono text-[10.5px] text-text-faint">waiting for agent…</p>
<p className="mb-2.5 font-mono text-[10.5px] text-text-faint">{t("systems.waiting")}</p>
)}
<div className="flex flex-col gap-1.5">
{rows.map(([label, pct, txt]) => (
@@ -153,43 +154,43 @@ export function SystemsPage() {
<Dialog open={dlgOpen} onOpenChange={setDlgOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New system</DialogTitle>
<DialogDescription>Name the host, then run dash-agent on it with the issued token.</DialogDescription>
<DialogTitle>{t("systems.newTitle")}</DialogTitle>
<DialogDescription>{t("systems.newDesc")}</DialogDescription>
</DialogHeader>
{created ? (
<div className="flex flex-col gap-3">
<p className="text-[12px] text-text-dim">
Agent command for <span className="font-medium text-text">{created.name}</span>:
{t("systems.agentCmdFor")} <span className="font-medium text-text">{created.name}</span>:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto whitespace-nowrap rounded-[7px] border border-border bg-bg px-3 py-2 font-mono text-[11px]">
{agentCmd(created)}
</code>
<Button variant="outline" size="icon" aria-label="Copy"
<Button variant="outline" size="icon" aria-label={t("systems.copy")}
onClick={() => void navigator.clipboard.writeText(agentCmd(created))}>
<Copy size={12} />
</Button>
</div>
<p className="text-[11px] text-text-faint">
The token identifies this system — keep it out of public repos.
{t("systems.tokenWarning")}
</p>
</div>
) : (
<div className="flex flex-col gap-1.5">
<Label htmlFor="sys-name">Name</Label>
<Label htmlFor="sys-name">{t("common.name")}</Label>
<Input id="sys-name" value={name} onChange={(e) => setName(e.target.value)}
placeholder="nas" autoFocus
placeholder={t("systems.namePlaceholder")} autoFocus
onKeyDown={(e) => { if (e.key === "Enter") void create(); }} />
</div>
)}
<DialogFooter>
{created ? (
<Button onClick={() => setDlgOpen(false)}>Done</Button>
<Button onClick={() => setDlgOpen(false)}>{t("systems.done")}</Button>
) : (
<>
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>Cancel</Button>
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>{t("common.cancel")}</Button>
<Button onClick={() => void create()} disabled={busy || !name.trim()}>
{busy ? "Creating…" : "Create"}
{busy ? t("common.creating") : t("common.create")}
</Button>
</>
)}
+3 -2
View File
@@ -1,5 +1,6 @@
import { ChevronDown, GripVertical, Pencil, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
import { useItemDnd, useSectionDnd } from "@/board/dnd";
import { IconImg, ItemAnchor, StatusChip, hostOf } from "@/board/primitives";
import { WidgetContent } from "@/widgets";
@@ -28,7 +29,7 @@ function SectionView({ section, onToggle, onDelete, children }: SectionViewProps
<div className="group/sec flex cursor-pointer select-none items-center gap-3 px-0.5 py-1">
<button
type="button"
aria-label="Drag section"
aria-label={t("renderer.dragSection")}
className="cursor-grab text-text-faint opacity-0 transition-opacity group-hover/sec:opacity-100 active:cursor-grabbing"
{...dnd.attributes}
{...dnd.listeners}
@@ -54,7 +55,7 @@ function SectionView({ section, onToggle, onDelete, children }: SectionViewProps
</button>
<button
type="button"
aria-label="Delete section"
aria-label={t("renderer.deleteSection")}
onClick={onDelete}
className="text-text-faint opacity-0 transition-opacity hover:text-destructive group-hover/sec:opacity-100"
>
+3 -2
View File
@@ -1,5 +1,6 @@
import { ChevronDown, GripVertical, Pencil, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
import { useItemDnd, useSectionDnd } from "@/board/dnd";
import { IconImg, ItemAnchor, StatusChip, hostOf } from "@/board/primitives";
import { WidgetContent } from "@/widgets";
@@ -32,7 +33,7 @@ function SectionView({ section, onToggle, onDelete, children }: SectionViewProps
</button>
<button
type="button"
aria-label="Drag section"
aria-label={t("renderer.dragSection")}
className="cursor-grab text-text-faint opacity-0 transition-opacity group-hover/sec:opacity-100 active:cursor-grabbing"
{...dnd.attributes}
{...dnd.listeners}
@@ -41,7 +42,7 @@ function SectionView({ section, onToggle, onDelete, children }: SectionViewProps
</button>
<button
type="button"
aria-label="Delete section"
aria-label={t("renderer.deleteSection")}
onClick={onDelete}
className="text-text-faint opacity-0 transition-opacity hover:text-destructive group-hover/sec:opacity-100"
>
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { ChevronDown, GripVertical, Pencil, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
import { useItemDnd, useSectionDnd } from "@/board/dnd";
import { useBoard } from "@/board/store";
import { IconImg, ItemAnchor, hostOf } from "@/board/primitives";
@@ -37,10 +38,10 @@ function BoardView({ children }: BoardViewProps) {
}).format(now);
const cells: [string, string, string][] = [
["Local time", time, date.split(",")[0] ?? ""],
[t("renderer.localTime"), time, date.split(",")[0] ?? ""],
["Services", String(services.length), `${board.sections.length} sections`],
["Up", String(up), "responding"],
["Down", String(down), down === 0 ? "all green" : "offline"],
[t("renderer.down"), String(down), down === 0 ? t("renderer.allGreen") : t("renderer.offline")],
["Widgets", String(widgets.length), "registered"],
];
@@ -77,7 +78,7 @@ function SectionView({ section, index, onToggle, onDelete, children }: SectionVi
<div className="group/sec mb-0.5 flex cursor-pointer select-none items-baseline gap-3.5 border-b border-border-strong pb-2.5">
<button
type="button"
aria-label="Drag section"
aria-label={t("renderer.dragSection")}
className="cursor-grab self-center text-text-faint opacity-0 transition-opacity group-hover/sec:opacity-100 active:cursor-grabbing"
{...dnd.attributes}
{...dnd.listeners}
@@ -96,7 +97,7 @@ function SectionView({ section, index, onToggle, onDelete, children }: SectionVi
{section.name}
</h2>
<span className="font-mono text-[11px] text-text-faint">
{section.items.length} {section.items.length === 1 ? "entry" : "entries"}
{section.items.length} {t(section.items.length === 1 ? "renderer.entry" : "renderer.entries")}
</span>
<ChevronDown
size={14}
@@ -105,7 +106,7 @@ function SectionView({ section, index, onToggle, onDelete, children }: SectionVi
</button>
<button
type="button"
aria-label="Delete section"
aria-label={t("renderer.deleteSection")}
onClick={onDelete}
className="self-center text-text-faint opacity-0 transition-opacity hover:text-destructive group-hover/sec:opacity-100"
>
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { ChevronDown, Pencil, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
import { useItemDnd, useSectionDnd } from "@/board/dnd";
import { useBoard } from "@/board/store";
import { IconImg, ItemAnchor, hostOf } from "@/board/primitives";
@@ -42,7 +43,7 @@ function BoardView({ children }: BoardViewProps) {
const panels: { head: string; v: string; s: string; live?: boolean; wide?: boolean }[] = [
{ head: "clock --local", v: time, s: date, live: true, wide: true },
{ head: "services --up", v: String(up), s: `of ${probed} probed` },
{ head: "services --down", v: String(down), s: down === 0 ? "all green" : "needs attention" },
{ head: "services --down", v: String(down), s: down === 0 ? t("renderer.allGreen") : t("renderer.needsAttention") },
{ head: "widgets", v: String(widgets.length), s: "registered" },
{ head: "endpoints", v: String(endpoints), s: "urls indexed" },
{ head: "uptime --probes", v: probed > 0 ? `${Math.round((up / probed) * 100)}%` : "—", s: `${up}/${probed} responding` },
@@ -56,7 +57,7 @@ function BoardView({ children }: BoardViewProps) {
<span className="font-medium text-text-dim">{services.length} services</span>
{` indexed · `}
<span className="text-up">{up} up</span>
{down > 0 ? <span>{` · `}<span className="text-down">{down} down</span></span> : ` · all green`}
{down > 0 ? <span>{` · `}<span className="text-down">{t("renderer.downCount", { n: down })}</span></span> : ` · ${t("renderer.allGreen")}`}
{` · `}
<span className="font-medium text-text-dim">{widgets.length} widgets</span>
{`\ndrag rows to reorder · click rows with multiple endpoints to choose`}
@@ -70,7 +71,7 @@ function BoardView({ children }: BoardViewProps) {
>
<div className="flex items-center gap-1.5 border-b border-border px-3 py-2 text-[10px] uppercase tracking-[0.1em] text-text-faint">
{p.head}
{p.live ? <span className="ml-auto text-[9px] text-up">● live</span> : null}
{p.live ? <span className="ml-auto text-[9px] text-up">● {t("renderer.live")}</span> : null}
</div>
<div className="px-3 py-3">
<div className={cn("font-medium leading-none tracking-tight tabular-nums", p.wide ? "text-[26px]" : "text-[22px]")}>{p.v}</div>
@@ -108,7 +109,7 @@ function SectionView({ section, onToggle, onDelete, children }: SectionViewProps
</button>
<button
type="button"
aria-label="Drag section"
aria-label={t("renderer.dragSection")}
className="cursor-grab text-text-faint opacity-0 transition-opacity group-hover/sec:opacity-100 active:cursor-grabbing"
{...dnd.attributes}
{...dnd.listeners}
@@ -117,7 +118,7 @@ function SectionView({ section, onToggle, onDelete, children }: SectionViewProps
</button>
<button
type="button"
aria-label="Delete section"
aria-label={t("renderer.deleteSection")}
onClick={onDelete}
className="text-text-faint opacity-0 transition-opacity hover:text-destructive group-hover/sec:opacity-100"
>
+15
View File
@@ -2,6 +2,7 @@ import type { components, paths } from "@dash/api-client";
// Board entities — generated from openapi.yaml. Never hand-edit shapes here.
export type Section = components["schemas"]["Section"];
export type Board = components["schemas"]["Board"];
export type Item = components["schemas"]["Item"];
export type BoardUrl = components["schemas"]["Url"];
export type UrlInput = components["schemas"]["UrlInput"];
@@ -20,6 +21,7 @@ export type Domain = components["schemas"]["Domain"];
export type DomainInput = components["schemas"]["DomainInput"];
export type DomainView = components["schemas"]["DomainView"];
export type DomainCheck = components["schemas"]["DomainCheck"];
export type Subdomain = components["schemas"]["Subdomain"];
export type System = components["schemas"]["System"];
export type StatSample = components["schemas"]["StatSample"];
/** Stats rows are StatSample plus the server-side timestamp. */
@@ -41,13 +43,26 @@ export type SettingsWire = NonNullable<
export interface Prefs {
theme: Theme;
renderer: RendererName;
/** CSS color override for --accent; empty = theme default. */
accent: string;
/** Background image URL for the board; empty = none. */
wallpaper: string;
/** Raw user CSS injected into a <style> tag; empty = none. */
customCss: string;
/** UI language — matches a key in i18n LOCALES; empty = en. */
locale: string;
}
/** Narrow the settings map to typed prefs; unknown/absent keys fall back. */
export function parsePrefs(raw: SettingsWire): Prefs {
const r = raw.renderer;
const s = (v: unknown) => (typeof v === "string" ? v : "");
return {
theme: raw.theme === "light" ? "light" : "dark",
renderer: r === "cards" || r === "index" || r === "console" ? r : "bento",
accent: s(raw.accent),
wallpaper: s(raw.wallpaper),
customCss: s(raw.custom_css),
locale: s(raw.locale),
};
}
+5 -4
View File
@@ -1,5 +1,6 @@
import type { Item } from "@/types";
import type { WidgetData } from "@/widgets/index";
import { t } from "@/i18n";
// Shared DNS-blocker layout: big block-rate, queries + blocked counts.
// Pi-hole and AdGuard return the same fields from their fetchers.
@@ -23,12 +24,12 @@ function BlockerWidget({ item, data, error }: { item: Item; data: WidgetData | u
<span className="font-mono text-[22px] font-medium leading-none tracking-tight tabular-nums">
{data ? `${pct.toFixed(1)}%` : "…"}
</span>
<span className="text-[10.5px] uppercase tracking-[0.1em] text-text-faint">blocked</span>
<span className="text-[10.5px] uppercase tracking-[0.1em] text-text-faint">{t("widget.blockedLbl")}</span>
</div>
<div className="ml-auto flex flex-col items-end gap-1 font-mono text-[10.5px] text-text-faint">
<span>{fmt(num(data, "queriesToday"))} queries</span>
<span>{fmt(num(data, "blockedToday"))} blocked</span>
{typeof data?.avgProcessMs === "number" && <span>{num(data, "avgProcessMs").toFixed(0)} ms avg</span>}
<span>{t("widget.queries", { n: fmt(num(data, "queriesToday")) })}</span>
<span>{t("widget.blocked", { n: fmt(num(data, "blockedToday")) })}</span>
{typeof data?.avgProcessMs === "number" && <span>{t("widget.msAvg", { n: num(data, "avgProcessMs").toFixed(0) })}</span>}
</div>
</div>
);
+2 -1
View File
@@ -1,6 +1,7 @@
import type { WidgetData } from "@/widgets";
import type { Item } from "@/types";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
// Board tile for a tracked domain: favicon, name, expiry countdown.
export function DomainWidget({ item, data, error }: {
@@ -29,7 +30,7 @@ export function DomainWidget({ item, data, error }: {
"shrink-0 font-mono text-[10.5px]",
error ? "text-down" : expiring || (days !== undefined && days < 0) ? "text-down" : "text-text-faint",
)}>
{error ? "err" : days === undefined ? "…" : days < 0 ? "expired" : `${days}d`}
{error ? t("widget.err") : days === undefined ? "…" : days < 0 ? t("domains.expired") : `${days}d`}
{sslDays !== undefined && sslDays <= 30 ? ` · ssl ${sslDays}d` : ""}
</span>
</a>
+23
View File
@@ -0,0 +1,23 @@
import type { Item } from "@/types";
import { cfgStr } from "@/widgets/index";
import { t } from "@/i18n";
// Embed — sandboxed iframe. Fully client-side; the tile chrome still shows
// the item name above the frame.
export function EmbedWidget({ item }: { item: Item }) {
const url = cfgStr(item, "url");
// Block script-capable schemes; http(s)/relative URLs are fine.
const bad = /^\s*(javascript|data|vbscript|file):/i.test(url);
if (!url || bad) {
return <span className="text-[11px] text-text-faint">{t("widget.embedHint")}</span>;
}
return (
<iframe
src={url}
title={item.name || "embed"}
sandbox="allow-scripts allow-same-origin allow-forms"
referrerPolicy="no-referrer"
className="pointer-events-auto h-full min-h-[120px] w-full rounded-[6px] border border-border bg-bg"
/>
);
}
+3 -2
View File
@@ -1,5 +1,6 @@
import type { Item } from "@/types";
import type { WidgetData } from "@/widgets/index";
import { t } from "@/i18n";
// Immich — photo/video counts and library footprint.
@@ -24,10 +25,10 @@ export function ImmichWidget({ item, data, error }: { item: Item; data: WidgetDa
<span className="font-mono text-[22px] font-medium leading-none tracking-tight tabular-nums">
{data ? fmtNum(num(data, "photos")) : "…"}
</span>
<span className="text-[10.5px] uppercase tracking-[0.1em] text-text-faint">photos</span>
<span className="text-[10.5px] uppercase tracking-[0.1em] text-text-faint">{t("widget.photosLbl")}</span>
</div>
<div className="ml-auto flex flex-col items-end gap-1 font-mono text-[10.5px] text-text-faint">
<span>{fmtNum(num(data, "videos"))} videos</span>
<span>{t("widget.videos", { n: fmtNum(num(data, "videos")) })}</span>
<span>{fmtBytes(num(data, "usageBytes"))}</span>
</div>
</div>
+7 -2
View File
@@ -1,11 +1,14 @@
import { useEffect, useState } from "react";
import type { Item } from "@/types";
import { t } from "@/i18n";
import { ClockWidget } from "@/widgets/clock";
import { PiholeWidget, AdguardWidget } from "@/widgets/blocker";
import { ImmichWidget } from "@/widgets/immich";
import { MonitorWidget } from "@/widgets/monitor";
import { DomainWidget } from "@/widgets/domain";
import { SystemWidget } from "@/widgets/system";
import { EmbedWidget } from "@/widgets/embed";
import { JsonPathWidget } from "@/widgets/jsonpath";
// WidgetHost seam: renderers own the tile chrome; this maps config.type to
// the component that fills it. Adding a widget = one fetcher in Go + one
@@ -34,6 +37,8 @@ const registry: WidgetRegistry = {
monitor: { view: MonitorWidget },
domain: { view: DomainWidget },
system: { view: SystemWidget },
embed: { local: true, view: EmbedWidget },
jsonpath: { view: JsonPathWidget },
};
export function cfgStr(item: Item, key: string): string {
@@ -66,7 +71,7 @@ export function useWidgetData(item: Item): { data: WidgetData | undefined; error
const body: unknown = await res.json();
if (dead) return;
if (!res.ok) {
let msg = "fetch failed";
let msg = t("widget.fetchFailed");
if (typeof body === "object" && body !== null && "error" in body) {
msg = String(body.error);
}
@@ -79,7 +84,7 @@ export function useWidgetData(item: Item): { data: WidgetData | undefined; error
setState({ data, error: undefined });
}
} catch {
if (!dead) setState((s) => ({ data: s.data, error: "offline" }));
if (!dead) setState((s) => ({ data: s.data, error: t("widget.offline") }));
}
if (!dead) timer = setTimeout(tick, 30_000);
};
+22
View File
@@ -0,0 +1,22 @@
import type { WidgetData } from "@/widgets";
import type { Item } from "@/types";
// JSON value — renders the scalar the backend extracted at config.path.
export function JsonPathWidget({ item, data, error }: {
item: Item;
data: WidgetData | undefined;
error: string | undefined;
}) {
const label = typeof data?.label === "string" && data.label ? data.label : item.name;
const value = data?.value;
return (
<div className="flex min-w-0 flex-col justify-center gap-0.5">
<span className="truncate text-[10.5px] uppercase tracking-[0.1em] text-text-faint">
{label}
</span>
<span className="truncate font-mono text-[18px] font-medium leading-tight">
{error ? "err" : value === undefined ? "…" : typeof value === "object" ? JSON.stringify(value) : String(value)}
</span>
</div>
);
}
+2 -1
View File
@@ -1,6 +1,7 @@
import type { WidgetData } from "@/widgets";
import type { Item } from "@/types";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
// Board tile for an uptime monitor: status dot, uptime %, avg ping.
// config.monitorId binds it; data comes from the monitor fetcher.
@@ -30,7 +31,7 @@ export function MonitorWidget({ item, data, error }: {
{typeof data?.name === "string" ? data.name : item.name}
</span>
<span className="shrink-0 font-mono text-[10.5px] text-text-faint">
{error ? "err" : uptime !== undefined ? `${uptime.toFixed(1)}%` : "…"}
{error ? t("widget.err") : uptime !== undefined ? `${uptime.toFixed(1)}%` : "…"}
{ping ? ` · ${Math.round(ping)}ms` : ""}
</span>
</a>
+2 -1
View File
@@ -1,6 +1,7 @@
import type { WidgetData } from "@/widgets";
import type { Item } from "@/types";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
// Board tile for a monitored system: status dot + cpu/mem/disk mini bars.
// config.systemId binds it; data comes from the system fetcher.
@@ -47,7 +48,7 @@ export function SystemWidget({ item, data, error }: {
{typeof data?.name === "string" ? data.name : item.name}
</span>
{error ? (
<span className="shrink-0 font-mono text-[10.5px] text-text-faint">err</span>
<span className="shrink-0 font-mono text-[10.5px] text-text-faint">{t("widget.err")}</span>
) : (
<span className="flex shrink-0 items-center gap-2">
{bar(cpu, "cpu")}