Build the Settings module: notification channels, event toggles, DNS badge colors

The Settings page was a "coming soon" placeholder. Port Sloth Manager's
settings feature set: Gotify/ntfy/SMTP/webhook notification channels
(each with its own test-send button), per-event toggles (DNS record
added/updated/deleted, a daily secret-expiry digest with configurable
time/timezone), and per-provider DNS badge color customization.

Settings persist in the existing `settings` key/value table via a new
settingsStore service; a notify service fans a message out to every
enabled channel. DNS record add/update/delete now fire notifications,
and a node-schedule job re-arms itself whenever the notification
settings change. Removed the now-superseded GOTIFY_URL/GOTIFY_TOKEN
env vars in favor of in-app configuration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-15 12:14:05 +02:00
1 parent 3a53a86ce0
commit 3255314402
16 files changed
+1272 -23

No files matched your search

+2 -2
View File
@@ -10,7 +10,7 @@ import Ipam from "./pages/Ipam";
import Dns from "./pages/Dns";
import ServersTasks from "./pages/ServersTasks";
import Integrations from "./pages/Integrations";
import ComingSoon from "./pages/ComingSoon";
import Settings from "./pages/Settings";
import AppShell from "./layout/AppShell";
const roleRank: Record<UserRole, number> = { viewer: 0, operator: 1, admin: 2 };
@@ -84,7 +84,7 @@ export default function App() {
path="/settings"
element={
<RequireRole user={user} minRole="admin">
<ComingSoon title="Settings" />
<Settings />
</RequireRole>
}
/>
+66
View File
@@ -78,6 +78,58 @@ export interface IpamInput {
export type DnsProviderType = "cloudflare" | "loopia" | "pihole" | "azure" | "cpanel" | "technitium";
export interface GotifySettings {
enabled: boolean;
url: string;
token: string;
priority: number;
}
export interface NtfySettings {
enabled: boolean;
url: string;
topic: string;
token: string;
priority: number;
}
export interface SmtpSettings {
enabled: boolean;
host: string;
port: number;
secure: boolean;
username: string;
password: string;
from: string;
to: string;
}
export interface WebhookSettings {
enabled: boolean;
url: string;
secret: string;
}
export interface NotificationEvents {
dnsAdd: boolean;
dnsUpdate: boolean;
dnsDelete: boolean;
secretCheck: boolean;
secretCheckTime: string;
timezone: string;
}
export interface AppSettings {
gotify: GotifySettings;
ntfy: NtfySettings;
smtp: SmtpSettings;
webhook: WebhookSettings;
notifications: NotificationEvents;
providerColors: Record<string, string>;
}
export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial<AppSettings[K]> };
export interface DnsProviderField {
key: string;
label: string;
@@ -534,4 +586,18 @@ export const api = {
storage: (integrationId: number) => request<SynologyStorageResponse>(`/api/integrations/${integrationId}/synology/storage`),
},
},
settings: {
get: () => request<{ settings: AppSettings }>("/api/settings"),
providerColors: () => request<{ providerColors: Record<string, string> }>("/api/settings/provider-colors"),
update: (data: AppSettingsPatch) =>
request<{ settings: AppSettings }>("/api/settings", { method: "PUT", body: JSON.stringify(data) }),
testGotify: (data: { url: string; token: string; priority?: number }) =>
request<{ ok: true }>("/api/settings/test-gotify", { method: "POST", body: JSON.stringify(data) }),
testNtfy: (data: { url: string; topic: string; token?: string; priority?: number }) =>
request<{ ok: true }>("/api/settings/test-ntfy", { method: "POST", body: JSON.stringify(data) }),
testSmtp: (data: { host: string; port?: number; secure?: boolean; username?: string; password?: string; from: string; to: string }) =>
request<{ ok: true }>("/api/settings/test-smtp", { method: "POST", body: JSON.stringify(data) }),
testWebhook: (data: { url: string; secret?: string }) =>
request<{ ok: true }>("/api/settings/test-webhook", { method: "POST", body: JSON.stringify(data) }),
},
};
-12
View File
@@ -1,12 +0,0 @@
export default function ComingSoon({ title }: { title: string }) {
return (
<>
<h2 className="page-title mb-3">{title}</h2>
<div className="card">
<div className="card-body text-secondary">
This module hasn't been built yet — it's on the roadmap for a follow-up pass.
</div>
</div>
</>
);
}
+17 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useState, type CSSProperties } from "react";
import {
api,
type CurrentUser,
@@ -21,6 +21,12 @@ const PROVIDER_LABELS: Record<DnsProviderType, string> = {
const emptyRecordForm: DnsRecordInput = { type: "A", name: "", content: "", ttl: 3600, priority: undefined, proxied: undefined };
function providerBadgeStyle(colors: Record<string, string>, providerType: DnsProviderType): CSSProperties {
const color = colors[providerType];
if (!color) return {};
return { backgroundColor: `${color}22`, color, border: `1px solid ${color}55` };
}
export default function Dns({ user }: { user: CurrentUser }) {
const isAdmin = user.role === "admin";
const canEdit = user.role === "admin" || user.role === "operator";
@@ -29,6 +35,11 @@ export default function Dns({ user }: { user: CurrentUser }) {
const [managingProviders, setManagingProviders] = useState(false);
const [selectedProviderId, setSelectedProviderId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [providerColors, setProviderColors] = useState<Record<string, string>>({});
useEffect(() => {
api.settings.providerColors().then((res) => setProviderColors(res.providerColors)).catch(() => {});
}, []);
const [zones, setZones] = useState<DnsZone[] | null>(null);
const [zonesError, setZonesError] = useState<string | null>(null);
@@ -212,7 +223,11 @@ export default function Dns({ user }: { user: CurrentUser }) {
{providers?.map((p) => (
<tr key={p.id}>
<td>{p.name}</td>
<td>{PROVIDER_LABELS[p.providerType]}</td>
<td>
<span className="badge" style={providerBadgeStyle(providerColors, p.providerType)}>
{PROVIDER_LABELS[p.providerType]}
</span>
</td>
<td>
<button
className={`badge ${p.enabled ? "bg-green-lt text-green" : "bg-secondary-lt text-secondary"} border-0`}
+578
View File
@@ -0,0 +1,578 @@
import { useEffect, useState } from "react";
import {
api,
type AppSettings,
type GotifySettings,
type NtfySettings,
type SmtpSettings,
type WebhookSettings,
type NotificationEvents,
} from "../api/client";
const PROVIDER_NAMES: Record<string, string> = {
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 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 (
<div className={`alert ${result.ok ? "alert-success" : "alert-danger"} mb-0 mt-2 py-2`}>
{result.ok ? "✓" : "✕"} {result.message}
</div>
);
}
export default function Settings() {
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [gotify, setGotify] = useState<GotifySettings>(DEFAULT_GOTIFY);
const [ntfy, setNtfy] = useState<NtfySettings>(DEFAULT_NTFY);
const [smtp, setSmtp] = useState<SmtpSettings>(DEFAULT_SMTP);
const [webhook, setWebhook] = useState<WebhookSettings>(DEFAULT_WEBHOOK);
const [notifications, setNotifications] = useState<NotificationEvents>(DEFAULT_NOTIFICATIONS);
const [providerColors, setProviderColors] = useState<Record<string, string>>({});
const [testingGotify, setTestingGotify] = useState(false);
const [testGotifyResult, setTestGotifyResult] = useState<TestResult>(null);
const [testingNtfy, setTestingNtfy] = useState(false);
const [testNtfyResult, setTestNtfyResult] = useState<TestResult>(null);
const [testingSmtp, setTestingSmtp] = useState(false);
const [testSmtpResult, setTestSmtpResult] = useState<TestResult>(null);
const [testingWebhook, setTestingWebhook] = useState(false);
const [testWebhookResult, setTestWebhookResult] = useState<TestResult>(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 <div className="text-secondary">Loading…</div>;
return (
<>
<div className="d-flex align-items-center justify-content-between mb-3">
<h2 className="page-title mb-0">Settings</h2>
<div className="d-flex align-items-center gap-2">
{saveError && <span className="text-danger small">{saveError}</span>}
{saved && <span className="text-success small">✓ Settings saved</span>}
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? "Saving…" : "Save Settings"}
</button>
</div>
</div>
{loadError && <div className="alert alert-danger">{loadError}</div>}
<div className="row row-cards">
<div className="col-md-6">
<div className="card">
<div className="card-header">
<h3 className="card-title">Gotify</h3>
</div>
<div className="card-body">
<p className="text-secondary">Send push notifications to a self-hosted Gotify instance.</p>
<label className="form-check form-check-single mb-3">
<input
type="checkbox"
className="form-check-input"
checked={gotify.enabled}
onChange={(e) => setGotify((g) => ({ ...g, enabled: e.target.checked }))}
/>
<span className="form-check-label">Enable Gotify notifications</span>
</label>
<div className="mb-2">
<label className="form-label">Gotify URL</label>
<input
className="form-control"
placeholder="http://gotify.example.com"
value={gotify.url}
disabled={!gotify.enabled}
onChange={(e) => setGotify((g) => ({ ...g, url: e.target.value }))}
/>
</div>
<div className="mb-2">
<label className="form-label">App token</label>
<input
type="password"
className="form-control"
placeholder="Your Gotify app token"
value={gotify.token}
disabled={!gotify.enabled}
onChange={(e) => setGotify((g) => ({ ...g, token: e.target.value }))}
/>
</div>
<div className="mb-2">
<label className="form-label">Priority (1–10)</label>
<input
type="number"
min={1}
max={10}
className="form-control"
value={gotify.priority}
disabled={!gotify.enabled}
onChange={(e) => setGotify((g) => ({ ...g, priority: Number(e.target.value) }))}
/>
</div>
<TestBadge result={testGotifyResult} />
</div>
<div className="card-footer">
<button
className="btn btn-outline-secondary"
onClick={handleTestGotify}
disabled={testingGotify || !gotify.url || !gotify.token}
>
{testingGotify ? "Sending…" : "Send test"}
</button>
</div>
</div>
</div>
<div className="col-md-6">
<div className="card">
<div className="card-header">
<h3 className="card-title">ntfy</h3>
</div>
<div className="card-body">
<p className="text-secondary">
Send notifications via <a href="https://ntfy.sh" target="_blank" rel="noreferrer">ntfy.sh</a> or a self-hosted instance.
</p>
<label className="form-check form-check-single mb-3">
<input
type="checkbox"
className="form-check-input"
checked={ntfy.enabled}
onChange={(e) => setNtfy((n) => ({ ...n, enabled: e.target.checked }))}
/>
<span className="form-check-label">Enable ntfy notifications</span>
</label>
<div className="row g-2 mb-2">
<div className="col-8">
<label className="form-label">Server URL</label>
<input
className="form-control"
placeholder="https://ntfy.sh"
value={ntfy.url}
disabled={!ntfy.enabled}
onChange={(e) => setNtfy((n) => ({ ...n, url: e.target.value }))}
/>
</div>
<div className="col-4">
<label className="form-label">Priority (1–5)</label>
<input
type="number"
min={1}
max={5}
className="form-control"
value={ntfy.priority}
disabled={!ntfy.enabled}
onChange={(e) => setNtfy((n) => ({ ...n, priority: Number(e.target.value) }))}
/>
</div>
</div>
<div className="mb-2">
<label className="form-label">Topic</label>
<input
className="form-control"
placeholder="your-topic"
value={ntfy.topic}
disabled={!ntfy.enabled}
onChange={(e) => setNtfy((n) => ({ ...n, topic: e.target.value }))}
/>
</div>
<div className="mb-2">
<label className="form-label">Access token (optional, for protected topics)</label>
<input
type="password"
className="form-control"
value={ntfy.token}
disabled={!ntfy.enabled}
onChange={(e) => setNtfy((n) => ({ ...n, token: e.target.value }))}
/>
</div>
<TestBadge result={testNtfyResult} />
</div>
<div className="card-footer">
<button
className="btn btn-outline-secondary"
onClick={handleTestNtfy}
disabled={testingNtfy || !ntfy.url || !ntfy.topic}
>
{testingNtfy ? "Sending…" : "Send test"}
</button>
</div>
</div>
</div>
<div className="col-md-6">
<div className="card">
<div className="card-header">
<h3 className="card-title">Email (SMTP)</h3>
</div>
<div className="card-body">
<p className="text-secondary">Send notifications via email using any SMTP server.</p>
<label className="form-check form-check-single mb-3">
<input
type="checkbox"
className="form-check-input"
checked={smtp.enabled}
onChange={(e) => setSmtp((s) => ({ ...s, enabled: e.target.checked }))}
/>
<span className="form-check-label">Enable SMTP notifications</span>
</label>
<div className="row g-2 mb-2">
<div className="col-8">
<label className="form-label">SMTP host</label>
<input
className="form-control"
placeholder="smtp.example.com"
value={smtp.host}
disabled={!smtp.enabled}
onChange={(e) => setSmtp((s) => ({ ...s, host: e.target.value }))}
/>
</div>
<div className="col-4">
<label className="form-label">Port</label>
<input
type="number"
className="form-control"
placeholder="587"
value={smtp.port}
disabled={!smtp.enabled}
onChange={(e) => setSmtp((s) => ({ ...s, port: Number(e.target.value) }))}
/>
</div>
</div>
<label className="form-check form-check-single mb-3">
<input
type="checkbox"
className="form-check-input"
checked={smtp.secure}
disabled={!smtp.enabled}
onChange={(e) => setSmtp((s) => ({ ...s, secure: e.target.checked }))}
/>
<span className="form-check-label">Use TLS/SSL (port 465)</span>
</label>
<div className="row g-2 mb-2">
<div className="col-6">
<label className="form-label">Username (optional)</label>
<input
className="form-control"
value={smtp.username}
disabled={!smtp.enabled}
onChange={(e) => setSmtp((s) => ({ ...s, username: e.target.value }))}
/>
</div>
<div className="col-6">
<label className="form-label">Password (optional)</label>
<input
type="password"
className="form-control"
value={smtp.password}
disabled={!smtp.enabled}
onChange={(e) => setSmtp((s) => ({ ...s, password: e.target.value }))}
/>
</div>
</div>
<div className="row g-2 mb-2">
<div className="col-6">
<label className="form-label">From</label>
<input
type="email"
className="form-control"
placeholder="homelab@example.com"
value={smtp.from}
disabled={!smtp.enabled}
onChange={(e) => setSmtp((s) => ({ ...s, from: e.target.value }))}
/>
</div>
<div className="col-6">
<label className="form-label">To</label>
<input
type="email"
className="form-control"
placeholder="you@example.com"
value={smtp.to}
disabled={!smtp.enabled}
onChange={(e) => setSmtp((s) => ({ ...s, to: e.target.value }))}
/>
</div>
</div>
<TestBadge result={testSmtpResult} />
</div>
<div className="card-footer">
<button
className="btn btn-outline-secondary"
onClick={handleTestSmtp}
disabled={testingSmtp || !smtp.host || !smtp.from || !smtp.to}
>
{testingSmtp ? "Sending…" : "Send test"}
</button>
</div>
</div>
</div>
<div className="col-md-6">
<div className="card">
<div className="card-header">
<h3 className="card-title">Webhook</h3>
</div>
<div className="card-body">
<p className="text-secondary">
POST a JSON payload to any HTTP endpoint. Uses Discord/Slack-compatible <code>{"{ \"content\": \"…\" }"}</code> format.
</p>
<label className="form-check form-check-single mb-3">
<input
type="checkbox"
className="form-check-input"
checked={webhook.enabled}
onChange={(e) => setWebhook((w) => ({ ...w, enabled: e.target.checked }))}
/>
<span className="form-check-label">Enable webhook notifications</span>
</label>
<div className="mb-2">
<label className="form-label">Webhook URL</label>
<input
type="url"
className="form-control"
placeholder="https://discord.com/api/webhooks/…"
value={webhook.url}
disabled={!webhook.enabled}
onChange={(e) => setWebhook((w) => ({ ...w, url: e.target.value }))}
/>
</div>
<div className="mb-2">
<label className="form-label">Secret (optional, sent as X-Webhook-Secret header)</label>
<input
type="password"
className="form-control"
value={webhook.secret}
disabled={!webhook.enabled}
onChange={(e) => setWebhook((w) => ({ ...w, secret: e.target.value }))}
/>
</div>
<TestBadge result={testWebhookResult} />
</div>
<div className="card-footer">
<button className="btn btn-outline-secondary" onClick={handleTestWebhook} disabled={testingWebhook || !webhook.url}>
{testingWebhook ? "Sending…" : "Send test"}
</button>
</div>
</div>
</div>
<div className="col-md-6">
<div className="card">
<div className="card-header">
<h3 className="card-title">Notify on</h3>
</div>
<div className="card-body">
<p className="text-secondary">
Controls which events trigger a notification on every enabled channel above.
</p>
{[
{ 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 }) => (
<label key={key} className="form-check form-check-single mb-2">
<input
type="checkbox"
className="form-check-input"
checked={notifications[key]}
onChange={(e) => setNotifications((n) => ({ ...n, [key]: e.target.checked }))}
/>
<span className="form-check-label">{label}</span>
</label>
))}
<div className="row g-2 mt-2">
<div className="col-6">
<label className="form-label">Daily reminder time</label>
<input
type="time"
className="form-control"
value={notifications.secretCheckTime}
disabled={!notifications.secretCheck}
onChange={(e) => setNotifications((n) => ({ ...n, secretCheckTime: e.target.value }))}
/>
</div>
<div className="col-6">
<label className="form-label">Timezone</label>
<select
className="form-select"
value={notifications.timezone}
disabled={!notifications.secretCheck}
onChange={(e) => setNotifications((n) => ({ ...n, timezone: e.target.value }))}
>
{TIMEZONES.map((tz) => (
<option key={tz} value={tz}>
{tz}
</option>
))}
</select>
</div>
</div>
</div>
</div>
</div>
<div className="col-md-6">
<div className="card">
<div className="card-header">
<h3 className="card-title">DNS provider badge colors</h3>
</div>
<div className="card-body">
<p className="text-secondary">Customize the color of each DNS provider's badge throughout the app.</p>
<div className="d-flex flex-column gap-2">
{Object.entries(PROVIDER_NAMES).map(([id, name]) => {
const color = providerColors[id] ?? "#6366f1";
return (
<div key={id} className="d-flex align-items-center gap-2">
<span
className="badge"
style={{ backgroundColor: `${color}22`, color, border: `1px solid ${color}55`, minWidth: 110 }}
>
{name}
</span>
<input
type="color"
value={color}
className="form-control form-control-color"
onChange={(e) => setProviderColors((c) => ({ ...c, [id]: e.target.value }))}
/>
<span className="text-secondary small">{color}</span>
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
</>
);
}