mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
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:
1 parent
f3143af351
commit
e0ef60eca6
49 files changed
+2131
-426
No files matched your search
@@ -1,25 +1,32 @@
|
||||
# Dash
|
||||
|
||||
A self-hosted homelab dashboard. CasaOS-grade ease of use, Vercel-grade design.
|
||||
Single container, UI-managed, no YAML, no auth.
|
||||
Single container, UI-managed, no YAML. Optional auth.
|
||||
|
||||

|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- **Service board** — sections, drag-and-drop, multi-URL services, icon upload,
|
||||
four board renderers (Bento, Cards, Index, Console), dark/light themes
|
||||
- **Service board** — multiple boards, sections, drag-and-drop, multi-URL
|
||||
services, icon upload, four renderers (Bento, Cards, Index, Console),
|
||||
dark/light themes, custom accent/wallpaper/CSS
|
||||
- **Uptime monitors** — HTTP, TCP, ping, DNS, keyword, JSON-path, and push
|
||||
checks on a schedule, with heartbeat history and uptime stats
|
||||
checks on a schedule, heartbeat history, uptime graphs, per-monitor alert
|
||||
rules
|
||||
- **Domain watch** — WHOIS/RDAP, DNS records, TLS certificate expiry,
|
||||
registrar and provider detection
|
||||
registrar and provider detection, CT-log subdomain discovery
|
||||
- **System monitoring** — lightweight `dash-agent` pushes CPU, memory, disk,
|
||||
network, load, temperatures, and Docker container state
|
||||
network, load, temperatures, per-container stats, and SMART/ZFS/GPU when
|
||||
available
|
||||
- **Incidents & status pages** — manual and automatic incidents, maintenance
|
||||
windows, public `/status/:slug` pages, SVG badges
|
||||
- **Alerts** — generic webhook notifications (Slack, Discord, ntfy compatible)
|
||||
- **Alerts** — webhook, Slack, Discord, Telegram, Gotify, ntfy, and SMTP
|
||||
transports
|
||||
- **Observability** — Prometheus `/api/metrics`, JSON export, CSV bulk import,
|
||||
importers for Homepage, Homarr, and Dashy configs
|
||||
- **Optional auth** — local username/password with bcrypt + session cookies,
|
||||
off by default; public status pages stay open either way
|
||||
- **i18n + PWA** — English and Czech UI, installable manifest/service worker
|
||||
|
||||
## Run
|
||||
|
||||
|
||||
+17
-18
@@ -205,41 +205,40 @@ Beszel-style server monitoring, push-based.
|
||||
|
||||
**Exit:** a stranger can `docker run`, import their Homepage config, and have a working board in under two minutes.
|
||||
|
||||
## Phase 5 — Post-1.0 (planned, demand-confirmed 2026-09-21)
|
||||
## Phase 5 — Post-1.0 (shipped 2026-09-22)
|
||||
|
||||
All items below confirmed in scope. Sequenced by dependency and risk —
|
||||
quick wins first, structure-changing work last. Nothing here is built yet.
|
||||
All items shipped on `feat/phase5`.
|
||||
|
||||
### P5a — Quick wins (frontend-heavy, no schema risk)
|
||||
|
||||
- [ ] **Uptime graphs** — monitor detail renders 24h/30d heartbeat bars +
|
||||
- [x] **Uptime graphs** — monitor detail renders 24h/30d heartbeat bars +
|
||||
latency line (recharts, already a dep). Data: existing
|
||||
`/api/monitors/:id/heartbeats` + uptime stats. Frontend only.
|
||||
- [ ] **iframe widget** — `config.url`, sandboxed `<iframe>` tile in the
|
||||
- [x] **iframe widget** — `config.url`, sandboxed `<iframe>` tile in the
|
||||
widget registry; local type, no backend fetcher.
|
||||
- [ ] **Generic JSON-path widget** — backend fetcher: GET `config.url` with
|
||||
- [x] **Generic JSON-path widget** — backend fetcher: GET `config.url` with
|
||||
optional headers, extract `config.path` (dot/bracket path), display
|
||||
value + optional label/format. Covers most "is API X alive" asks.
|
||||
- [ ] **Custom CSS / accent / wallpaper** — settings keys `accent`,
|
||||
- [x] **Custom CSS / accent / wallpaper** — settings keys `accent`,
|
||||
`custom_css`, `wallpaper_url`; injected as CSS var override + `<style>`
|
||||
+ board background layer. Respect reduced-motion/contrast defaults.
|
||||
- [ ] **PWA** — `manifest.webmanifest` (icons, name, theme color), minimal
|
||||
- [x] **PWA** — `manifest.webmanifest` (icons, name, theme color), minimal
|
||||
service worker: cache-first for static assets, network-first for /api.
|
||||
No offline board editing — read-only graceful degradation.
|
||||
- [ ] **Demo GIF** — scripted playwright walkthrough (board → monitors →
|
||||
- [x] **Demo GIF** — scripted playwright walkthrough (board → monitors →
|
||||
status page) rendered via ffmpeg; committed to `docs/screenshots/`.
|
||||
|
||||
### P5b — Alert depth
|
||||
|
||||
- [ ] **SMTP transport** — `net/smtp` stdlib (STARTTLS + plain-auth only,
|
||||
- [x] **SMTP transport** — `net/smtp` stdlib (STARTTLS + plain-auth only,
|
||||
no external dep). Settings: `smtp_host`, `smtp_port`, `smtp_user`,
|
||||
`smtp_pass` (secret), `smtp_from`, `smtp_to`. Same event pipeline as
|
||||
the webhook; test button reuses `/api/notify/test`.
|
||||
- [ ] **Notifier presets** — refactor `notify.go` into a transport registry:
|
||||
- [x] **Notifier presets** — refactor `notify.go` into a transport registry:
|
||||
`webhook` (generic, current), `slack`, `discord`, `telegram`, `gotify`,
|
||||
`ntfy`, `smtp`. Presets = field set + payload template per transport;
|
||||
settings store per-transport config. UI: transport picker + fields.
|
||||
- [ ] **Alert rules** — per-monitor JSON `alerts` config: consecutive
|
||||
- [x] **Alert rules** — per-monitor JSON `alerts` config: consecutive
|
||||
failures before down (default 1), latency-warn ms, cert-days threshold
|
||||
for domains, mute flag. Scheduler evaluates rules before firing events;
|
||||
maintenance windows keep priority. Migration adds nullable
|
||||
@@ -247,23 +246,23 @@ quick wins first, structure-changing work last. Nothing here is built yet.
|
||||
|
||||
### P5c — Agent + domain depth
|
||||
|
||||
- [ ] **Per-container metrics** — agent calls Docker `/containers/{id}/stats?stream=false`
|
||||
- [x] **Per-container metrics** — agent calls Docker `/containers/{id}/stats?stream=false`
|
||||
(cpu%, mem used/limit) when docker.sock present; payload adds
|
||||
`containers[].cpu`, `.memUsed`, `.memLimit` (JSON — no migration).
|
||||
UI: containers table gets cpu/mem columns + optional sparkline.
|
||||
- [ ] **SMART / ZFS / GPU** — optional collectors, all best-effort and
|
||||
- [x] **SMART / ZFS / GPU** — optional collectors, all best-effort and
|
||||
silently skipped when tooling is absent:
|
||||
SMART via `smartctl -j` exec if installed; ZFS via `zpool status`/`-j`
|
||||
parse; GPU via `/sys/class/drm` (amd/intel basic) and `nvidia-smi -j`
|
||||
when present. No new hard deps — exec-or-skip.
|
||||
- [ ] **Subdomain discovery** — crt.sh CT-log query + A/AAAA resolve for
|
||||
- [x] **Subdomain discovery** — crt.sh CT-log query + A/AAAA resolve for
|
||||
discovered names; `subdomains` table (domain_id, name, ips, first_seen,
|
||||
last_seen). Domain detail: subdomains tab with probe status. Daily
|
||||
sweep alongside the domain refresh job.
|
||||
|
||||
### P5d — Structure (big, changes product shape)
|
||||
|
||||
- [ ] **Auth (opt-in, email/username + password)** — deliberate constraint:
|
||||
- [x] **Auth (opt-in, email/username + password)** — deliberate constraint:
|
||||
stays disabled by default; enabling is a settings toggle. First enable
|
||||
creates the admin user. `users` table (id, email, name, passhash
|
||||
bcrypt/argon2id, created_at), session cookie (httpOnly, signed,
|
||||
@@ -272,12 +271,12 @@ quick wins first, structure-changing work last. Nothing here is built yet.
|
||||
and badges stay open. No OIDC, no SSO — local accounts only.
|
||||
Security pass required: rate-limit login, constant-time compare,
|
||||
session rotation.
|
||||
- [ ] **Multiple boards** — `boards` table (id, name, slug, position),
|
||||
- [x] **Multiple boards** — `boards` table (id, name, slug, position),
|
||||
`sections.board_id` FK (migration backfills all rows to board 1).
|
||||
Board switcher in the header (dropdown + `/b/:slug` route), settings
|
||||
for default board. Monitors/domains/systems pages stay global — only
|
||||
the service board multiplies.
|
||||
- [ ] **Full i18n extraction** — every UI string moves into `en.ts` via the
|
||||
- [x] **Full i18n extraction** — every UI string moves into `en.ts` via the
|
||||
existing `t()` seam; mechanical pass over pages/dialogs/widgets.
|
||||
Second locale (cs) lands in the same pass to prove the seam end-to-end.
|
||||
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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/<slug></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/<slug></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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
Reference in new issue
Block a user