From 1f0e6a9d4c8f6598ed2b4e5523f304a98b7c0854 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Tue, 15 Sep 2026 12:23:12 +0200 Subject: [PATCH] Split Settings into sub-pages and add DNS cache clearing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings was a single long page. Break it into a sub-nav (Notifications, DNS Badges, Cache) with its own route per section, each loading and saving independently. Also add the DNS record-cache clearing action that Sloth Manager had — a new admin-only POST /api/dns/cache/clear truncates the zone/record cache tables so stale data can be wiped and zones re-synced from scratch. Co-Authored-By: Claude Sonnet 5 --- server/src/routes/dns.ts | 16 + web/src/App.tsx | 12 +- web/src/api/client.ts | 1 + web/src/pages/Settings.tsx | 583 +----------------- web/src/pages/settings/CacheSettings.tsx | 47 ++ web/src/pages/settings/DnsBadgeSettings.tsx | 94 +++ .../pages/settings/NotificationSettings.tsx | 534 ++++++++++++++++ 7 files changed, 721 insertions(+), 566 deletions(-) create mode 100644 web/src/pages/settings/CacheSettings.tsx create mode 100644 web/src/pages/settings/DnsBadgeSettings.tsx create mode 100644 web/src/pages/settings/NotificationSettings.tsx diff --git a/server/src/routes/dns.ts b/server/src/routes/dns.ts index d0b5201..36108b6 100644 --- a/server/src/routes/dns.ts +++ b/server/src/routes/dns.ts @@ -506,3 +506,19 @@ dnsRouter.delete("/providers/:id/zones/:zoneId/records/:recordId", requireRole(" res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); } })); + +// ─── Cache ─────────────────────────────────────────────────────────────────── + +dnsRouter.post("/cache/clear", requireRole("admin"), asyncHandler(async (req, res) => { + await db.delete(dnsRecordsCache); + await db.delete(dnsZonesCache); + + await recordAudit({ + actor: req.currentUser!, + category: "dns", + action: "clear_cache", + targetType: "dns_cache", + }); + + res.json({ ok: true }); +})); diff --git a/web/src/App.tsx b/web/src/App.tsx index c710ef1..ab483f8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type ReactNode } from "react"; -import { Routes, Route } from "react-router-dom"; +import { Routes, Route, Navigate } from "react-router-dom"; import { api, type CurrentUser, type UserRole, UnauthorizedError } from "./api/client"; import Login from "./pages/Login"; import Dashboard from "./pages/Dashboard"; @@ -11,6 +11,9 @@ import Dns from "./pages/Dns"; import ServersTasks from "./pages/ServersTasks"; import Integrations from "./pages/Integrations"; import Settings from "./pages/Settings"; +import NotificationSettings from "./pages/settings/NotificationSettings"; +import DnsBadgeSettings from "./pages/settings/DnsBadgeSettings"; +import CacheSettings from "./pages/settings/CacheSettings"; import AppShell from "./layout/AppShell"; const roleRank: Record = { viewer: 0, operator: 1, admin: 2 }; @@ -87,7 +90,12 @@ export default function App() { } - /> + > + } /> + } /> + } /> + } /> + ); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 673d468..68553ab 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -473,6 +473,7 @@ export const api = { { method: "DELETE" }, ), }, + clearCache: () => request<{ ok: true }>("/api/dns/cache/clear", { method: "POST" }), }, servers: { list: () => request<{ servers: ServerRecord[] }>("/api/servers"), diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 8b139f0..61e24b0 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -1,576 +1,31 @@ -import { useEffect, useState } from "react"; -import { - api, - type AppSettings, - type GotifySettings, - type NtfySettings, - type SmtpSettings, - type WebhookSettings, - type NotificationEvents, -} from "../api/client"; +import { NavLink, Outlet } from "react-router-dom"; -const PROVIDER_NAMES: Record = { - cloudflare: "Cloudflare", - loopia: "Loopia", - pihole: "Pi-hole", - azure: "Azure DNS", - cpanel: "cPanel", - technitium: "Technitium", -}; - -const TIMEZONES = [ - "UTC", - "Europe/Stockholm", - "Europe/London", - "Europe/Berlin", - "Europe/Paris", - "Europe/Oslo", - "Europe/Helsinki", - "Europe/Amsterdam", - "Europe/Copenhagen", - "America/New_York", - "America/Chicago", - "America/Denver", - "America/Los_Angeles", - "America/Toronto", - "America/Sao_Paulo", - "Asia/Tokyo", - "Asia/Shanghai", - "Asia/Singapore", - "Asia/Dubai", - "Australia/Sydney", +const SUB_NAV = [ + { to: "/settings/notifications", label: "Notifications" }, + { to: "/settings/dns-badges", label: "DNS Badges" }, + { to: "/settings/cache", label: "Cache" }, ]; -const DEFAULT_GOTIFY: GotifySettings = { enabled: false, url: "", token: "", priority: 5 }; -const DEFAULT_NTFY: NtfySettings = { enabled: false, url: "https://ntfy.sh", topic: "", token: "", priority: 3 }; -const DEFAULT_SMTP: SmtpSettings = { enabled: false, host: "", port: 587, secure: false, username: "", password: "", from: "", to: "" }; -const DEFAULT_WEBHOOK: WebhookSettings = { enabled: false, url: "", secret: "" }; -const DEFAULT_NOTIFICATIONS: NotificationEvents = { - dnsAdd: true, - dnsUpdate: true, - dnsDelete: true, - secretCheck: true, - secretCheckTime: "08:00", - timezone: "UTC", -}; - -type TestResult = { ok: boolean; message: string } | null; - -function TestBadge({ result }: { result: TestResult }) { - if (!result) return null; - return ( -
- {result.ok ? "✓" : "✕"} {result.message} -
- ); -} - export default function Settings() { - const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - const [saving, setSaving] = useState(false); - const [saved, setSaved] = useState(false); - const [saveError, setSaveError] = useState(null); - - const [gotify, setGotify] = useState(DEFAULT_GOTIFY); - const [ntfy, setNtfy] = useState(DEFAULT_NTFY); - const [smtp, setSmtp] = useState(DEFAULT_SMTP); - const [webhook, setWebhook] = useState(DEFAULT_WEBHOOK); - const [notifications, setNotifications] = useState(DEFAULT_NOTIFICATIONS); - const [providerColors, setProviderColors] = useState>({}); - - const [testingGotify, setTestingGotify] = useState(false); - const [testGotifyResult, setTestGotifyResult] = useState(null); - const [testingNtfy, setTestingNtfy] = useState(false); - const [testNtfyResult, setTestNtfyResult] = useState(null); - const [testingSmtp, setTestingSmtp] = useState(false); - const [testSmtpResult, setTestSmtpResult] = useState(null); - const [testingWebhook, setTestingWebhook] = useState(false); - const [testWebhookResult, setTestWebhookResult] = useState(null); - - useEffect(() => { - api.settings - .get() - .then((res: { settings: AppSettings }) => { - setGotify(res.settings.gotify); - setNtfy(res.settings.ntfy); - setSmtp(res.settings.smtp); - setWebhook(res.settings.webhook); - setNotifications(res.settings.notifications); - setProviderColors(res.settings.providerColors); - }) - .catch((err) => setLoadError(err instanceof Error ? err.message : String(err))) - .finally(() => setLoading(false)); - }, []); - - async function handleSave() { - setSaving(true); - setSaveError(null); - setSaved(false); - try { - await api.settings.update({ gotify, ntfy, smtp, webhook, notifications, providerColors }); - setSaved(true); - setTimeout(() => setSaved(false), 3000); - } catch (err) { - setSaveError(err instanceof Error ? err.message : String(err)); - } finally { - setSaving(false); - } - } - - async function handleTestGotify() { - setTestingGotify(true); - setTestGotifyResult(null); - try { - await api.settings.testGotify(gotify); - setTestGotifyResult({ ok: true, message: "Notification sent successfully." }); - } catch (err) { - setTestGotifyResult({ ok: false, message: err instanceof Error ? err.message : String(err) }); - } finally { - setTestingGotify(false); - } - } - - async function handleTestNtfy() { - setTestingNtfy(true); - setTestNtfyResult(null); - try { - await api.settings.testNtfy(ntfy); - setTestNtfyResult({ ok: true, message: "Notification sent successfully." }); - } catch (err) { - setTestNtfyResult({ ok: false, message: err instanceof Error ? err.message : String(err) }); - } finally { - setTestingNtfy(false); - } - } - - async function handleTestSmtp() { - setTestingSmtp(true); - setTestSmtpResult(null); - try { - await api.settings.testSmtp(smtp); - setTestSmtpResult({ ok: true, message: "Test email sent successfully." }); - } catch (err) { - setTestSmtpResult({ ok: false, message: err instanceof Error ? err.message : String(err) }); - } finally { - setTestingSmtp(false); - } - } - - async function handleTestWebhook() { - setTestingWebhook(true); - setTestWebhookResult(null); - try { - await api.settings.testWebhook(webhook); - setTestWebhookResult({ ok: true, message: "Webhook delivered successfully." }); - } catch (err) { - setTestWebhookResult({ ok: false, message: err instanceof Error ? err.message : String(err) }); - } finally { - setTestingWebhook(false); - } - } - - if (loading) return
Loading…
; - return ( <> -
-

Settings

-
- {saveError && {saveError}} - {saved && ✓ Settings saved} - -
-
- - {loadError &&
{loadError}
} - -
-
-
-
-

Gotify

-
-
-

Send push notifications to a self-hosted Gotify instance.

- -
- - setGotify((g) => ({ ...g, url: e.target.value }))} - /> -
-
- - setGotify((g) => ({ ...g, token: e.target.value }))} - /> -
-
- - setGotify((g) => ({ ...g, priority: Number(e.target.value) }))} - /> -
- -
-
- -
+ {item.label} + + ))}
- -
-
-
-

ntfy

-
-
-

- Send notifications via ntfy.sh or a self-hosted instance. -

- -
-
- - setNtfy((n) => ({ ...n, url: e.target.value }))} - /> -
-
- - setNtfy((n) => ({ ...n, priority: Number(e.target.value) }))} - /> -
-
-
- - setNtfy((n) => ({ ...n, topic: e.target.value }))} - /> -
-
- - setNtfy((n) => ({ ...n, token: e.target.value }))} - /> -
- -
-
- -
-
-
- -
-
-
-

Email (SMTP)

-
-
-

Send notifications via email using any SMTP server.

- -
-
- - setSmtp((s) => ({ ...s, host: e.target.value }))} - /> -
-
- - setSmtp((s) => ({ ...s, port: Number(e.target.value) }))} - /> -
-
- -
-
- - setSmtp((s) => ({ ...s, username: e.target.value }))} - /> -
-
- - setSmtp((s) => ({ ...s, password: e.target.value }))} - /> -
-
-
-
- - setSmtp((s) => ({ ...s, from: e.target.value }))} - /> -
-
- - setSmtp((s) => ({ ...s, to: e.target.value }))} - /> -
-
- -
-
- -
-
-
- -
-
-
-

Webhook

-
-
-

- POST a JSON payload to any HTTP endpoint. Uses Discord/Slack-compatible {"{ \"content\": \"…\" }"} format. -

- -
- - setWebhook((w) => ({ ...w, url: e.target.value }))} - /> -
-
- - setWebhook((w) => ({ ...w, secret: e.target.value }))} - /> -
- -
-
- -
-
-
- -
-
-
-

Notify on

-
-
-

- Controls which events trigger a notification on every enabled channel above. -

- {[ - { key: "dnsAdd" as const, label: "DNS record added" }, - { key: "dnsUpdate" as const, label: "DNS record updated" }, - { key: "dnsDelete" as const, label: "DNS record deleted" }, - { key: "secretCheck" as const, label: "Secret expiry reminder" }, - ].map(({ key, label }) => ( - - ))} -
-
- - setNotifications((n) => ({ ...n, secretCheckTime: e.target.value }))} - /> -
-
- - -
-
-
-
-
- -
-
-
-

DNS provider badge colors

-
-
-

Customize the color of each DNS provider's badge throughout the app.

-
- {Object.entries(PROVIDER_NAMES).map(([id, name]) => { - const color = providerColors[id] ?? "#6366f1"; - return ( -
- - {name} - - setProviderColors((c) => ({ ...c, [id]: e.target.value }))} - /> - {color} -
- ); - })} -
-
-
+
+
diff --git a/web/src/pages/settings/CacheSettings.tsx b/web/src/pages/settings/CacheSettings.tsx new file mode 100644 index 0000000..46968a2 --- /dev/null +++ b/web/src/pages/settings/CacheSettings.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; +import { api } from "../../api/client"; + +export default function CacheSettings() { + const [clearing, setClearing] = useState(false); + const [cleared, setCleared] = useState(false); + const [error, setError] = useState(null); + + async function handleClear() { + if (!confirm("Clear the DNS record cache? All zones will need to be re-synced afterwards.")) return; + setClearing(true); + setError(null); + setCleared(false); + try { + await api.dns.clearCache(); + setCleared(true); + setTimeout(() => setCleared(false), 3000); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setClearing(false); + } + } + + return ( + <> +

Cache

+
+
+

DNS record cache

+
+
+

+ The DNS module keeps a local copy of every synced zone and record so pages load instantly. Clear it if + you see stale or incorrect data — every zone will need to be re-synced from its provider afterwards. +

+ {error &&
{error}
} +
+
+ +
+
+ + ); +} diff --git a/web/src/pages/settings/DnsBadgeSettings.tsx b/web/src/pages/settings/DnsBadgeSettings.tsx new file mode 100644 index 0000000..31cccaa --- /dev/null +++ b/web/src/pages/settings/DnsBadgeSettings.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState } from "react"; +import { api } from "../../api/client"; + +const PROVIDER_NAMES: Record = { + cloudflare: "Cloudflare", + loopia: "Loopia", + pihole: "Pi-hole", + azure: "Azure DNS", + cpanel: "cPanel", + technitium: "Technitium", +}; + +export default function DnsBadgeSettings() { + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [providerColors, setProviderColors] = useState>({}); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [saveError, setSaveError] = useState(null); + + useEffect(() => { + api.settings + .providerColors() + .then((res) => setProviderColors(res.providerColors)) + .catch((err) => setLoadError(err instanceof Error ? err.message : String(err))) + .finally(() => setLoading(false)); + }, []); + + async function handleSave() { + setSaving(true); + setSaveError(null); + setSaved(false); + try { + const res = await api.settings.update({ providerColors }); + setProviderColors(res.settings.providerColors); + setSaved(true); + setTimeout(() => setSaved(false), 3000); + } catch (err) { + setSaveError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + } + + if (loading) return
Loading…
; + + return ( + <> +
+

DNS Badges

+
+ {saveError && {saveError}} + {saved && ✓ Colors saved} + +
+
+ + {loadError &&
{loadError}
} + +
+
+

DNS provider badge colors

+
+
+

Customize the color of each DNS provider's badge throughout the app.

+
+ {Object.entries(PROVIDER_NAMES).map(([id, name]) => { + const color = providerColors[id] ?? "#6366f1"; + return ( +
+ + {name} + + setProviderColors((c) => ({ ...c, [id]: e.target.value }))} + /> + {color} +
+ ); + })} +
+
+
+ + ); +} diff --git a/web/src/pages/settings/NotificationSettings.tsx b/web/src/pages/settings/NotificationSettings.tsx new file mode 100644 index 0000000..95d165a --- /dev/null +++ b/web/src/pages/settings/NotificationSettings.tsx @@ -0,0 +1,534 @@ +import { useEffect, useState } from "react"; +import { + api, + type AppSettings, + type GotifySettings, + type NtfySettings, + type SmtpSettings, + type WebhookSettings, + type NotificationEvents, +} from "../../api/client"; + +const TIMEZONES = [ + "UTC", + "Europe/Stockholm", + "Europe/London", + "Europe/Berlin", + "Europe/Paris", + "Europe/Oslo", + "Europe/Helsinki", + "Europe/Amsterdam", + "Europe/Copenhagen", + "America/New_York", + "America/Chicago", + "America/Denver", + "America/Los_Angeles", + "America/Toronto", + "America/Sao_Paulo", + "Asia/Tokyo", + "Asia/Shanghai", + "Asia/Singapore", + "Asia/Dubai", + "Australia/Sydney", +]; + +const DEFAULT_GOTIFY: GotifySettings = { enabled: false, url: "", token: "", priority: 5 }; +const DEFAULT_NTFY: NtfySettings = { enabled: false, url: "https://ntfy.sh", topic: "", token: "", priority: 3 }; +const DEFAULT_SMTP: SmtpSettings = { enabled: false, host: "", port: 587, secure: false, username: "", password: "", from: "", to: "" }; +const DEFAULT_WEBHOOK: WebhookSettings = { enabled: false, url: "", secret: "" }; +const DEFAULT_NOTIFICATIONS: NotificationEvents = { + dnsAdd: true, + dnsUpdate: true, + dnsDelete: true, + secretCheck: true, + secretCheckTime: "08:00", + timezone: "UTC", +}; + +type TestResult = { ok: boolean; message: string } | null; + +function TestBadge({ result }: { result: TestResult }) { + if (!result) return null; + return ( +
+ {result.ok ? "✓" : "✕"} {result.message} +
+ ); +} + +export default function NotificationSettings() { + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [saveError, setSaveError] = useState(null); + + const [gotify, setGotify] = useState(DEFAULT_GOTIFY); + const [ntfy, setNtfy] = useState(DEFAULT_NTFY); + const [smtp, setSmtp] = useState(DEFAULT_SMTP); + const [webhook, setWebhook] = useState(DEFAULT_WEBHOOK); + const [notifications, setNotifications] = useState(DEFAULT_NOTIFICATIONS); + + const [testingGotify, setTestingGotify] = useState(false); + const [testGotifyResult, setTestGotifyResult] = useState(null); + const [testingNtfy, setTestingNtfy] = useState(false); + const [testNtfyResult, setTestNtfyResult] = useState(null); + const [testingSmtp, setTestingSmtp] = useState(false); + const [testSmtpResult, setTestSmtpResult] = useState(null); + const [testingWebhook, setTestingWebhook] = useState(false); + const [testWebhookResult, setTestWebhookResult] = useState(null); + + useEffect(() => { + api.settings + .get() + .then((res: { settings: AppSettings }) => { + setGotify(res.settings.gotify); + setNtfy(res.settings.ntfy); + setSmtp(res.settings.smtp); + setWebhook(res.settings.webhook); + setNotifications(res.settings.notifications); + }) + .catch((err) => setLoadError(err instanceof Error ? err.message : String(err))) + .finally(() => setLoading(false)); + }, []); + + async function handleSave() { + setSaving(true); + setSaveError(null); + setSaved(false); + try { + await api.settings.update({ gotify, ntfy, smtp, webhook, notifications }); + setSaved(true); + setTimeout(() => setSaved(false), 3000); + } catch (err) { + setSaveError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + } + + async function handleTestGotify() { + setTestingGotify(true); + setTestGotifyResult(null); + try { + await api.settings.testGotify(gotify); + setTestGotifyResult({ ok: true, message: "Notification sent successfully." }); + } catch (err) { + setTestGotifyResult({ ok: false, message: err instanceof Error ? err.message : String(err) }); + } finally { + setTestingGotify(false); + } + } + + async function handleTestNtfy() { + setTestingNtfy(true); + setTestNtfyResult(null); + try { + await api.settings.testNtfy(ntfy); + setTestNtfyResult({ ok: true, message: "Notification sent successfully." }); + } catch (err) { + setTestNtfyResult({ ok: false, message: err instanceof Error ? err.message : String(err) }); + } finally { + setTestingNtfy(false); + } + } + + async function handleTestSmtp() { + setTestingSmtp(true); + setTestSmtpResult(null); + try { + await api.settings.testSmtp(smtp); + setTestSmtpResult({ ok: true, message: "Test email sent successfully." }); + } catch (err) { + setTestSmtpResult({ ok: false, message: err instanceof Error ? err.message : String(err) }); + } finally { + setTestingSmtp(false); + } + } + + async function handleTestWebhook() { + setTestingWebhook(true); + setTestWebhookResult(null); + try { + await api.settings.testWebhook(webhook); + setTestWebhookResult({ ok: true, message: "Webhook delivered successfully." }); + } catch (err) { + setTestWebhookResult({ ok: false, message: err instanceof Error ? err.message : String(err) }); + } finally { + setTestingWebhook(false); + } + } + + if (loading) return
Loading…
; + + return ( + <> +
+

Notifications

+
+ {saveError && {saveError}} + {saved && ✓ Settings saved} + +
+
+ + {loadError &&
{loadError}
} + +
+
+
+
+

Gotify

+
+
+

Send push notifications to a self-hosted Gotify instance.

+ +
+ + setGotify((g) => ({ ...g, url: e.target.value }))} + /> +
+
+ + setGotify((g) => ({ ...g, token: e.target.value }))} + /> +
+
+ + setGotify((g) => ({ ...g, priority: Number(e.target.value) }))} + /> +
+ +
+
+ +
+
+
+ +
+
+
+

ntfy

+
+
+

+ Send notifications via ntfy.sh or a self-hosted instance. +

+ +
+
+ + setNtfy((n) => ({ ...n, url: e.target.value }))} + /> +
+
+ + setNtfy((n) => ({ ...n, priority: Number(e.target.value) }))} + /> +
+
+
+ + setNtfy((n) => ({ ...n, topic: e.target.value }))} + /> +
+
+ + setNtfy((n) => ({ ...n, token: e.target.value }))} + /> +
+ +
+
+ +
+
+
+ +
+
+
+

Email (SMTP)

+
+
+

Send notifications via email using any SMTP server.

+ +
+
+ + setSmtp((s) => ({ ...s, host: e.target.value }))} + /> +
+
+ + setSmtp((s) => ({ ...s, port: Number(e.target.value) }))} + /> +
+
+ +
+
+ + setSmtp((s) => ({ ...s, username: e.target.value }))} + /> +
+
+ + setSmtp((s) => ({ ...s, password: e.target.value }))} + /> +
+
+
+
+ + setSmtp((s) => ({ ...s, from: e.target.value }))} + /> +
+
+ + setSmtp((s) => ({ ...s, to: e.target.value }))} + /> +
+
+ +
+
+ +
+
+
+ +
+
+
+

Webhook

+
+
+

+ POST a JSON payload to any HTTP endpoint. Uses Discord/Slack-compatible {"{ \"content\": \"…\" }"} format. +

+ +
+ + setWebhook((w) => ({ ...w, url: e.target.value }))} + /> +
+
+ + setWebhook((w) => ({ ...w, secret: e.target.value }))} + /> +
+ +
+
+ +
+
+
+ +
+
+
+

Notify on

+
+
+

+ Controls which events trigger a notification on every enabled channel above. +

+ {[ + { key: "dnsAdd" as const, label: "DNS record added" }, + { key: "dnsUpdate" as const, label: "DNS record updated" }, + { key: "dnsDelete" as const, label: "DNS record deleted" }, + { key: "secretCheck" as const, label: "Secret expiry reminder" }, + ].map(({ key, label }) => ( + + ))} +
+
+ + setNotifications((n) => ({ ...n, secretCheckTime: e.target.value }))} + /> +
+
+ + +
+
+
+
+
+
+ + ); +}