Files
Homelab-manager/web/src/pages/settings/NotificationSettings.tsx
T
bobbanandClaude Sonnet 5 4118062405 Alert when a Semaphore template or Gitea workflow run fails
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>
2026-09-26 03:11:59 +02:00

699 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
</>
);
}