Split Settings into sub-pages and add DNS cache clearing
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
3255314402
commit
1f0e6a9d4c
7 files changed
+720
-565
No files matched your search
@@ -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) });
|
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 });
|
||||||
|
}));
|
||||||
+10
-2
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, type ReactNode } from "react";
|
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 { api, type CurrentUser, type UserRole, UnauthorizedError } from "./api/client";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import Dashboard from "./pages/Dashboard";
|
import Dashboard from "./pages/Dashboard";
|
||||||
@@ -11,6 +11,9 @@ import Dns from "./pages/Dns";
|
|||||||
import ServersTasks from "./pages/ServersTasks";
|
import ServersTasks from "./pages/ServersTasks";
|
||||||
import Integrations from "./pages/Integrations";
|
import Integrations from "./pages/Integrations";
|
||||||
import Settings from "./pages/Settings";
|
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";
|
import AppShell from "./layout/AppShell";
|
||||||
|
|
||||||
const roleRank: Record<UserRole, number> = { viewer: 0, operator: 1, admin: 2 };
|
const roleRank: Record<UserRole, number> = { viewer: 0, operator: 1, admin: 2 };
|
||||||
@@ -87,7 +90,12 @@ export default function App() {
|
|||||||
<Settings />
|
<Settings />
|
||||||
</RequireRole>
|
</RequireRole>
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
|
<Route index element={<Navigate to="notifications" replace />} />
|
||||||
|
<Route path="notifications" element={<NotificationSettings />} />
|
||||||
|
<Route path="dns-badges" element={<DnsBadgeSettings />} />
|
||||||
|
<Route path="cache" element={<CacheSettings />} />
|
||||||
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -473,6 +473,7 @@ export const api = {
|
|||||||
{ method: "DELETE" },
|
{ method: "DELETE" },
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
clearCache: () => request<{ ok: true }>("/api/dns/cache/clear", { method: "POST" }),
|
||||||
},
|
},
|
||||||
servers: {
|
servers: {
|
||||||
list: () => request<{ servers: ServerRecord[] }>("/api/servers"),
|
list: () => request<{ servers: ServerRecord[] }>("/api/servers"),
|
||||||
|
|||||||
+18
-563
@@ -1,576 +1,31 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { NavLink, Outlet } from "react-router-dom";
|
||||||
import {
|
|
||||||
api,
|
|
||||||
type AppSettings,
|
|
||||||
type GotifySettings,
|
|
||||||
type NtfySettings,
|
|
||||||
type SmtpSettings,
|
|
||||||
type WebhookSettings,
|
|
||||||
type NotificationEvents,
|
|
||||||
} from "../api/client";
|
|
||||||
|
|
||||||
const PROVIDER_NAMES: Record<string, string> = {
|
const SUB_NAV = [
|
||||||
cloudflare: "Cloudflare",
|
{ to: "/settings/notifications", label: "Notifications" },
|
||||||
loopia: "Loopia",
|
{ to: "/settings/dns-badges", label: "DNS Badges" },
|
||||||
pihole: "Pi-hole",
|
{ to: "/settings/cache", label: "Cache" },
|
||||||
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() {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="d-flex align-items-center justify-content-between mb-3">
|
<h2 className="page-title mb-3">Settings</h2>
|
||||||
<h2 className="page-title mb-0">Settings</h2>
|
<div className="row">
|
||||||
<div className="d-flex align-items-center gap-2">
|
<div className="col-12 col-md-3 col-lg-2 mb-3">
|
||||||
{saveError && <span className="text-danger small">{saveError}</span>}
|
<div className="list-group">
|
||||||
{saved && <span className="text-success small">✓ Settings saved</span>}
|
{SUB_NAV.map((item) => (
|
||||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
<NavLink
|
||||||
{saving ? "Saving…" : "Save Settings"}
|
key={item.to}
|
||||||
</button>
|
to={item.to}
|
||||||
</div>
|
className={({ isActive }) => `list-group-item list-group-item-action ${isActive ? "active" : ""}`}
|
||||||
</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"}
|
{item.label}
|
||||||
</button>
|
</NavLink>
|
||||||
</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>
|
||||||
|
<div className="col-12 col-md-9 col-lg-10">
|
||||||
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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<string | null>(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 (
|
||||||
|
<>
|
||||||
|
<h3 className="mb-3">Cache</h3>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">DNS record cache</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="text-secondary">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
{error && <div className="alert alert-danger">{error}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="card-footer">
|
||||||
|
<button className="btn btn-danger" onClick={handleClear} disabled={clearing}>
|
||||||
|
{clearing ? "Clearing…" : cleared ? "✓ Cache cleared" : "Clear DNS Cache"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "../../api/client";
|
||||||
|
|
||||||
|
const PROVIDER_NAMES: Record<string, string> = {
|
||||||
|
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<string | null>(null);
|
||||||
|
const [providerColors, setProviderColors] = useState<Record<string, string>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(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 <div className="text-secondary">Loading…</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<h3 className="mb-0">DNS Badges</h3>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
{saveError && <span className="text-danger small">{saveError}</span>}
|
||||||
|
{saved && <span className="text-success small">✓ Colors saved</span>}
|
||||||
|
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? "Saving…" : "Save Colors"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadError && <div className="alert alert-danger">{loadError}</div>}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className={`alert ${result.ok ? "alert-success" : "alert-danger"} mb-0 mt-2 py-2`}>
|
||||||
|
{result.ok ? "✓" : "✕"} {result.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationSettings() {
|
||||||
|
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 [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);
|
||||||
|
})
|
||||||
|
.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 <div className="text-secondary">Loading…</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<h3 className="mb-0">Notifications</h3>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user