Every 15 minutes (on the existing health-check timer) the app reads the latest run of each Semaphore template and each Gitea repo's latest workflow run. A failed one raises one notification, and another when a later run succeeds. It is state-based like the server health alerts, so a job that fails every night alerts on the first failure, not every night. Gitea alerts include the run's link. Toggle: Settings > Notifications. What counts: - Semaphore "error" is a failure, "success" is a pass. A run that is waiting, running, stopped by hand or rejected is neither, so it leaves the previous state alone: a run in progress must not clear a failure it hasn't fixed yet, and a manual stop isn't a failure. - Gitea failure/success likewise; running, waiting, blocked, cancelled and skipped leave things as they were. - A failing template that gets another failing run does not re-alert. Not mistaking "couldn't read" for "fixed": - Semaphore's template listing swallowed per-project errors, so a project that failed to load looked like a project with no templates. A new checkTemplates adapter method reports which projects failed, and their failures are held rather than cleared. - Gitea reports a run it couldn't fetch as null, the same as "no runs"; both leave the repo's state alone. - An unreachable integration holds all of its failures. Nothing is cleared or re-announced while it is down. The first pass only records what is already failing without announcing it, so upgrading (or adding an integration to a fresh install) doesn't produce a wall of alerts about months-old failures. That baseline is not spent while nothing could be read. Maintenance windows on a Semaphore or Gitea integration silence its failure alerts with the same rules as the health alerts: a problem that starts during a window alerts when it ends, and one already announced stays known. The diff logic is reused from the health monitor rather than copied. Maintenance page text updated. Known limit: for Gitea this follows the repo's most recent run on any workflow or branch, matching what the Gitea page shows; a failure in one workflow can be masked by a later success of another. Verified with 53 checks against fake Semaphore and Gitea servers and a webhook receiver: classification, baseline (including not being consumed when nothing is readable), single alert per failure, no repeat, in-progress/ stopped/cancelled runs, recovery and re-failure, unreadable project, unreadable integration, run-fetch errors, maintenance windows (silenced, then announced after), the toggle, disabled integrations and repos without Actions. Real dev database mtime untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
699 lines
29 KiB
TypeScript
699 lines
29 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import {
|
||
api,
|
||
type AppSettings,
|
||
type GotifySettings,
|
||
type NtfySettings,
|
||
type SmtpSettings,
|
||
type WebhookSettings,
|
||
type NotificationEvents,
|
||
type QuietHoursSettings,
|
||
type HealthCheckSettings,
|
||
} 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,
|
||
tailscaleKeyCheck: true,
|
||
dockerUpdateCheck: true,
|
||
proxmoxBackupCheck: true,
|
||
healthAlerts: true,
|
||
automationAlerts: true,
|
||
secretCheckTime: "08:00",
|
||
timezone: "UTC",
|
||
integrationFailureAlerts: true,
|
||
integrationFailureThreshold: 3,
|
||
};
|
||
const DEFAULT_QUIET_HOURS: QuietHoursSettings = { enabled: false, start: "22:00", end: "07:00" };
|
||
const DEFAULT_HEALTH_CHECKS: HealthCheckSettings = { serverOfflineMinutes: 60, diskUsagePercent: 90 };
|
||
|
||
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 [quietHours, setQuietHours] = useState<QuietHoursSettings>(DEFAULT_QUIET_HOURS);
|
||
const [healthChecks, setHealthChecks] = useState<HealthCheckSettings>(DEFAULT_HEALTH_CHECKS);
|
||
const [queuedCount, setQueuedCount] = useState<number | null>(null);
|
||
const [flushing, setFlushing] = useState(false);
|
||
const [flushResult, setFlushResult] = useState<string | null>(null);
|
||
|
||
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);
|
||
setQuietHours(res.settings.quietHours);
|
||
setHealthChecks(res.settings.healthChecks);
|
||
})
|
||
.catch((err) => setLoadError(err instanceof Error ? err.message : String(err)))
|
||
.finally(() => setLoading(false));
|
||
api.settings
|
||
.quietHoursQueue()
|
||
.then((res) => setQueuedCount(res.count))
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
async function handleSave() {
|
||
setSaving(true);
|
||
setSaveError(null);
|
||
setSaved(false);
|
||
try {
|
||
await api.settings.update({ gotify, ntfy, smtp, webhook, notifications, quietHours, healthChecks });
|
||
setSaved(true);
|
||
setTimeout(() => setSaved(false), 3000);
|
||
} catch (err) {
|
||
setSaveError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleFlushNow() {
|
||
setFlushing(true);
|
||
setFlushResult(null);
|
||
try {
|
||
const res = await api.settings.flushQuietHours();
|
||
setFlushResult(res.flushed > 0 ? `Sent a digest of ${res.flushed} queued notification${res.flushed !== 1 ? "s" : ""}.` : "Nothing was queued.");
|
||
setQueuedCount(0);
|
||
} catch (err) {
|
||
setFlushResult(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
setFlushing(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 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 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 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 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 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" },
|
||
{ key: "tailscaleKeyCheck" as const, label: "Tailscale key expiry reminder" },
|
||
{ key: "dockerUpdateCheck" as const, label: "Docker image update available" },
|
||
{ key: "proxmoxBackupCheck" as const, label: "Proxmox backup failed or a guest has no coverage" },
|
||
{ key: "healthAlerts" as const, label: "Server offline, disk nearly full, or Synology volume/disk problem" },
|
||
{ key: "automationAlerts" as const, label: "Semaphore template or Gitea workflow run failed" },
|
||
{ key: "integrationFailureAlerts" as const, label: "Integration/DNS provider failing repeatedly" },
|
||
].map(({ key, label }) => (
|
||
<label key={key} className="form-check 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">Alert after</label>
|
||
<div className="input-group">
|
||
<input
|
||
type="number"
|
||
className="form-control"
|
||
min={1}
|
||
max={20}
|
||
value={notifications.integrationFailureThreshold}
|
||
disabled={!notifications.integrationFailureAlerts}
|
||
onChange={(e) => setNotifications((n) => ({ ...n, integrationFailureThreshold: Number(e.target.value) }))}
|
||
/>
|
||
<span className="input-group-text">consecutive failures</span>
|
||
</div>
|
||
<div className="form-hint">
|
||
Based on the Diagnostic Log. A recovery notice is sent once it succeeds again.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="row g-2 mt-2">
|
||
<div className="col-6">
|
||
<label className="form-label">Server offline after</label>
|
||
<div className="input-group">
|
||
<input
|
||
type="number"
|
||
className="form-control"
|
||
min={15}
|
||
max={10080}
|
||
value={healthChecks.serverOfflineMinutes}
|
||
disabled={!notifications.healthAlerts}
|
||
onChange={(e) => setHealthChecks((h) => ({ ...h, serverOfflineMinutes: Number(e.target.value) }))}
|
||
/>
|
||
<span className="input-group-text">min</span>
|
||
</div>
|
||
<div className="form-hint">Agents report every 15 min by default.</div>
|
||
</div>
|
||
<div className="col-6">
|
||
<label className="form-label">Disk usage alert at</label>
|
||
<div className="input-group">
|
||
<input
|
||
type="number"
|
||
className="form-control"
|
||
min={50}
|
||
max={99}
|
||
value={healthChecks.diskUsagePercent}
|
||
disabled={!notifications.healthAlerts}
|
||
onChange={(e) => setHealthChecks((h) => ({ ...h, diskUsagePercent: Number(e.target.value) }))}
|
||
/>
|
||
<span className="input-group-text">%</span>
|
||
</div>
|
||
<div className="form-hint">Servers, Proxmox storage, Synology volumes. Checked every 15 min; a recovery notice follows.</div>
|
||
</div>
|
||
</div>
|
||
{(() => {
|
||
const dailyChecksEnabled =
|
||
notifications.secretCheck || notifications.tailscaleKeyCheck || notifications.dockerUpdateCheck || notifications.proxmoxBackupCheck;
|
||
return (
|
||
<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={!dailyChecksEnabled}
|
||
onChange={(e) => setNotifications((n) => ({ ...n, secretCheckTime: e.target.value }))}
|
||
/>
|
||
<div className="form-hint">Shared by the secret, Tailscale key, Docker update, and Proxmox backup reminders above.</div>
|
||
</div>
|
||
<div className="col-6">
|
||
<label className="form-label">Timezone</label>
|
||
<select
|
||
className="form-select"
|
||
value={notifications.timezone}
|
||
disabled={!dailyChecksEnabled}
|
||
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">Quiet hours</h3>
|
||
</div>
|
||
<div className="card-body">
|
||
<p className="text-secondary">
|
||
Notifications that fire during this window are held instead of sent immediately, then delivered
|
||
as one digest at the end time (in the timezone set above). Scheduled daily reminders already only
|
||
fire once at a time you pick, so this mainly affects instant ones — DNS changes and integration
|
||
failure/recovery alerts.
|
||
</p>
|
||
<label className="form-check mb-3">
|
||
<input
|
||
type="checkbox"
|
||
className="form-check-input"
|
||
checked={quietHours.enabled}
|
||
onChange={(e) => setQuietHours((q) => ({ ...q, enabled: e.target.checked }))}
|
||
/>
|
||
<span className="form-check-label">Enable quiet hours</span>
|
||
</label>
|
||
<div className="row g-2 mb-2">
|
||
<div className="col-6">
|
||
<label className="form-label">Start</label>
|
||
<input
|
||
type="time"
|
||
className="form-control"
|
||
value={quietHours.start}
|
||
disabled={!quietHours.enabled}
|
||
onChange={(e) => setQuietHours((q) => ({ ...q, start: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="col-6">
|
||
<label className="form-label">End</label>
|
||
<input
|
||
type="time"
|
||
className="form-control"
|
||
value={quietHours.end}
|
||
disabled={!quietHours.enabled}
|
||
onChange={(e) => setQuietHours((q) => ({ ...q, end: e.target.value }))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="form-hint mb-3">
|
||
A start time later than the end time (e.g. 22:00 → 07:00) is treated as spanning midnight.
|
||
</div>
|
||
{queuedCount !== null && queuedCount > 0 && (
|
||
<div className="alert alert-info d-flex align-items-center justify-content-between mb-0">
|
||
<span>
|
||
{queuedCount} notification{queuedCount !== 1 ? "s" : ""} currently queued.
|
||
</span>
|
||
<button className="btn btn-sm btn-outline-secondary" onClick={handleFlushNow} disabled={flushing}>
|
||
{flushing ? "Sending…" : "Flush now"}
|
||
</button>
|
||
</div>
|
||
)}
|
||
{flushResult && <div className="text-secondary small mt-2">{flushResult}</div>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|