Add notification quiet hours with digest delivery
Instant notifications (DNS changes, integration failure/recovery alerts) had no way to avoid pinging overnight. Adds a "Quiet hours" window under Settings -> Notifications: notifications that would fire during the window are held in a new notification_queue table instead of sent immediately, then delivered as one combined digest at the end time (in the same timezone already used for the daily checks) via a new scheduled flush job. The scheduled daily checks (secret expiry, Tailscale key, Docker updates, Proxmox backups) already only fire once at a chosen time, so this mainly matters for the instant ones. Includes a live "N queued" indicator with a manual "Flush now" button for visibility, and correctly falls back to sending immediately whenever the feature is disabled (the default). Gating lives at the single choke point every notification already flows through (notify()), so no per-event-type wiring was needed. Verified against a fake webhook receiver on an isolated scratch database: exhaustively checked the midnight-wraparound window math (9 cases including exact-boundary inclusive/exclusive edges) against synthetic "now" values rather than depending on when the test happens to run, then end-to-end through the real notify()/ flushQuietHoursQueue() functions with a window constructed around the actual current time — confirmed a notification during the window queues instead of sending, the flush produces one digest with the original title/message intact and clears the queue, a notification outside the window sends immediately, and disabling the feature entirely sends immediately regardless of the window. Confirmed the real dev database's mtime was untouched throughout. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
99db7e1cf0
commit
73d649377e
12 files changed
+1445
-3
No files matched your search
@@ -7,6 +7,7 @@ import {
|
||||
type SmtpSettings,
|
||||
type WebhookSettings,
|
||||
type NotificationEvents,
|
||||
type QuietHoursSettings,
|
||||
} from "../../api/client";
|
||||
|
||||
const TIMEZONES = [
|
||||
@@ -49,6 +50,7 @@ const DEFAULT_NOTIFICATIONS: NotificationEvents = {
|
||||
integrationFailureAlerts: true,
|
||||
integrationFailureThreshold: 3,
|
||||
};
|
||||
const DEFAULT_QUIET_HOURS: QuietHoursSettings = { enabled: false, start: "22:00", end: "07:00" };
|
||||
|
||||
type TestResult = { ok: boolean; message: string } | null;
|
||||
|
||||
@@ -73,6 +75,10 @@ export default function NotificationSettings() {
|
||||
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 [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);
|
||||
@@ -92,9 +98,14 @@ export default function NotificationSettings() {
|
||||
setSmtp(res.settings.smtp);
|
||||
setWebhook(res.settings.webhook);
|
||||
setNotifications(res.settings.notifications);
|
||||
setQuietHours(res.settings.quietHours);
|
||||
})
|
||||
.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() {
|
||||
@@ -102,7 +113,7 @@ export default function NotificationSettings() {
|
||||
setSaveError(null);
|
||||
setSaved(false);
|
||||
try {
|
||||
await api.settings.update({ gotify, ntfy, smtp, webhook, notifications });
|
||||
await api.settings.update({ gotify, ntfy, smtp, webhook, notifications, quietHours });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
} catch (err) {
|
||||
@@ -112,6 +123,20 @@ export default function NotificationSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -564,6 +589,67 @@ export default function NotificationSettings() {
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user