From 73d649377e3edfe199a83de72a6fae63106f7c6c Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Tue, 22 Sep 2026 20:56:33 +0200 Subject: [PATCH] Add notification quiet hours with digest delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/drizzle/0006_aberrant_darkhawk.sql | 6 + server/drizzle/meta/0006_snapshot.json | 1185 +++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema.ts | 11 + server/src/index.ts | 2 + server/src/routes/settings.ts | 22 +- server/src/services/notificationQueue.ts | 21 + server/src/services/notify.ts | 53 +- server/src/services/quietHoursScheduler.ts | 31 + server/src/services/settingsStore.ts | 10 + web/src/api/client.ts | 12 + .../pages/settings/NotificationSettings.tsx | 88 +- 12 files changed, 1445 insertions(+), 3 deletions(-) create mode 100644 server/drizzle/0006_aberrant_darkhawk.sql create mode 100644 server/drizzle/meta/0006_snapshot.json create mode 100644 server/src/services/notificationQueue.ts create mode 100644 server/src/services/quietHoursScheduler.ts diff --git a/server/drizzle/0006_aberrant_darkhawk.sql b/server/drizzle/0006_aberrant_darkhawk.sql new file mode 100644 index 0000000..eecf7fb --- /dev/null +++ b/server/drizzle/0006_aberrant_darkhawk.sql @@ -0,0 +1,6 @@ +CREATE TABLE `notification_queue` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `title` text NOT NULL, + `message` text NOT NULL, + `created_at` text DEFAULT (current_timestamp) NOT NULL +); diff --git a/server/drizzle/meta/0006_snapshot.json b/server/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..7175743 --- /dev/null +++ b/server/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1185 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ba20e414-e06c-43b0-823b-7fc6b11facc4", + "prevId": "19abebb6-2675-47dc-b0d3-52b1fd0e5f5d", + "tables": { + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_label": { + "name": "actor_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_actor_user_id_users_id_fk": { + "name": "audit_log_actor_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "diag_log": { + "name": "diag_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ok": { + "name": "ok", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dns_providers": { + "name": "dns_providers", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "dns_providers_credential_id_integration_credentials_id_fk": { + "name": "dns_providers_credential_id_integration_credentials_id_fk", + "tableFrom": "dns_providers", + "tableTo": "integration_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dns_records_cache": { + "name": "dns_records_cache", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_id": { + "name": "record_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ttl": { + "name": "ttl", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxied": { + "name": "proxied", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "dns_records_cache_provider_id_dns_providers_id_fk": { + "name": "dns_records_cache_provider_id_dns_providers_id_fk", + "tableFrom": "dns_records_cache", + "tableTo": "dns_providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dns_zones_cache": { + "name": "dns_zones_cache", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "zone_name": { + "name": "zone_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "synced_at": { + "name": "synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "dns_zones_cache_provider_zone_idx": { + "name": "dns_zones_cache_provider_zone_idx", + "columns": [ + "provider_id", + "zone_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "dns_zones_cache_provider_id_dns_providers_id_fk": { + "name": "dns_zones_cache_provider_id_dns_providers_id_fk", + "tableFrom": "dns_zones_cache", + "tableTo": "dns_providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration_credentials": { + "name": "integration_credentials", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_secret": { + "name": "encrypted_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrations": { + "name": "integrations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "integrations_credential_id_integration_credentials_id_fk": { + "name": "integrations_credential_id_integration_credentials_id_fk", + "tableFrom": "integrations", + "tableTo": "integration_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ipam_entries": { + "name": "ipam_entries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "ipam_entries_ip_address_unique": { + "name": "ipam_entries_ip_address_unique", + "columns": [ + "ip_address" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_queue": { + "name": "notification_queue", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scheduled_tasks": { + "name": "scheduled_tasks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "server_id": { + "name": "server_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_type": { + "name": "schedule_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "schedule_expression": { + "name": "schedule_expression", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_metadata": { + "name": "raw_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_stale": { + "name": "is_stale", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "scheduled_tasks_server_id_servers_id_fk": { + "name": "scheduled_tasks_server_id_servers_id_fk", + "tableFrom": "scheduled_tasks", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "secrets": { + "name": "secrets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'generic'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiry_date": { + "name": "expiry_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "warn_days": { + "name": "warn_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "server_links": { + "name": "server_links", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "server_id": { + "name": "server_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "server_links_server_id_servers_id_fk": { + "name": "server_links_server_id_servers_id_fk", + "tableFrom": "server_links", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "servers": { + "name": "servers", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "os_type": { + "name": "os_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'linux'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_token_hash": { + "name": "api_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_token_prefix": { + "name": "api_token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_addresses": { + "name": "ip_addresses", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_model": { + "name": "cpu_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_cores": { + "name": "cpu_cores", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_load_percent": { + "name": "cpu_load_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mem_total_bytes": { + "name": "mem_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mem_used_bytes": { + "name": "mem_used_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disks": { + "name": "disks", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxmox_integration_id": { + "name": "proxmox_integration_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxmox_node": { + "name": "proxmox_node", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxmox_guest_type": { + "name": "proxmox_guest_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxmox_vmid": { + "name": "proxmox_vmid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hide_proxmox_link": { + "name": "hide_proxmox_link", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "servers_proxmox_integration_id_integrations_id_fk": { + "name": "servers_proxmox_integration_id_integrations_id_fk", + "tableFrom": "servers", + "tableTo": "integrations", + "columnsFrom": [ + "proxmox_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "last_login_at": { + "name": "last_login_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_oidc_sub_unique": { + "name": "users_oidc_sub_unique", + "columns": [ + "oidc_sub" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 21a448c..946c2cd 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1789759852933, "tag": "0005_bent_violations", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1790103102037, + "tag": "0006_aberrant_darkhawk", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 1eb2e13..e04678f 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -48,6 +48,17 @@ export const diagLog = sqliteTable("diag_log", { .default(sql`(current_timestamp)`), }); +// ─── Notification queue — pending notifications held during quiet hours ──── + +export const notificationQueue = sqliteTable("notification_queue", { + id: integer("id").primaryKey({ autoIncrement: true }), + title: text("title").notNull(), + message: text("message").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), +}); + // ─── Settings (key/value) ─────────────────────────────────────────────────── export const settings = sqliteTable("settings", { diff --git a/server/src/index.ts b/server/src/index.ts index 9c64fa4..10a1149 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -28,6 +28,7 @@ import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpirySc import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js"; import { initDockerUpdateScheduler } from "./services/dockerUpdateScheduler.js"; import { initProxmoxBackupScheduler } from "./services/proxmoxBackupScheduler.js"; +import { initQuietHoursScheduler } from "./services/quietHoursScheduler.js"; warnIfAuthNotConfigured(); await runMigrations(); @@ -36,6 +37,7 @@ await initTailscaleKeyExpiryScheduler(); await initLogRetentionScheduler(); await initDockerUpdateScheduler(); await initProxmoxBackupScheduler(); +await initQuietHoursScheduler(); const __dirname = dirname(fileURLToPath(import.meta.url)); const webDist = join(__dirname, "..", "..", "web", "dist"); diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts index 103e1b4..160edd0 100644 --- a/server/src/routes/settings.ts +++ b/server/src/routes/settings.ts @@ -9,7 +9,9 @@ import { scheduleDockerUpdateCheck } from "../services/dockerUpdateScheduler.js" import { scheduleProxmoxBackupCheck } from "../services/proxmoxBackupScheduler.js"; import { scheduleLogRetentionPurge } from "../services/logRetentionScheduler.js"; import { purgeOldLogs } from "../services/logRetention.js"; -import { testGotify, testNtfy, testSmtp, testWebhook } from "../services/notify.js"; +import { scheduleQuietHoursFlush } from "../services/quietHoursScheduler.js"; +import { testGotify, testNtfy, testSmtp, testWebhook, flushQuietHoursQueue } from "../services/notify.js"; +import { listQueuedNotifications } from "../services/notificationQueue.js"; import { buildExportPayload, encryptExport, decryptExport, applyImportPayload, type EncryptedExportFile } from "../services/configBackup.js"; import { asyncHandler } from "../utils/asyncHandler.js"; @@ -83,6 +85,10 @@ const updateSchema = z.object({ .object({ enabled: z.boolean(), retentionDays: z.number().int().min(1).max(3650), intervalHours: z.number().int().min(1).max(720) }) .partial() .optional(), + quietHours: z + .object({ enabled: z.boolean(), start: z.string().regex(/^\d{2}:\d{2}$/), end: z.string().regex(/^\d{2}:\d{2}$/) }) + .partial() + .optional(), }); settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => { @@ -102,6 +108,9 @@ settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => { if (parsed.data.logRetention) { await scheduleLogRetentionPurge(); } + if (parsed.data.quietHours || parsed.data.notifications) { + await scheduleQuietHoursFlush(); + } await recordAudit({ actor: req.currentUser!, @@ -182,6 +191,17 @@ settingsRouter.post("/purge-logs", requireRole("admin"), asyncHandler(async (req res.json(result); })); +settingsRouter.get("/quiet-hours-queue", requireRole("admin"), asyncHandler(async (_req, res) => { + const items = await listQueuedNotifications(); + res.json({ count: items.length, items }); +})); + +settingsRouter.post("/flush-quiet-hours", requireRole("admin"), asyncHandler(async (req, res) => { + const flushed = await flushQuietHoursQueue(); + await recordAudit({ actor: req.currentUser!, category: "settings", action: "flush_quiet_hours", detail: { flushed } }); + res.json({ flushed }); +})); + const exportSchema = z.object({ passphrase: z.string().min(8) }); settingsRouter.post("/export", requireRole("admin"), asyncHandler(async (req, res) => { diff --git a/server/src/services/notificationQueue.ts b/server/src/services/notificationQueue.ts new file mode 100644 index 0000000..bcc8190 --- /dev/null +++ b/server/src/services/notificationQueue.ts @@ -0,0 +1,21 @@ +import { db } from "../db/client.js"; +import { notificationQueue } from "../db/schema.js"; + +export interface QueuedNotification { + id: number; + title: string; + message: string; + createdAt: string; +} + +export async function enqueueNotification(title: string, message: string): Promise { + await db.insert(notificationQueue).values({ title, message }); +} + +export async function listQueuedNotifications(): Promise { + return db.select().from(notificationQueue); +} + +export async function clearQueuedNotifications(): Promise { + await db.delete(notificationQueue); +} diff --git a/server/src/services/notify.ts b/server/src/services/notify.ts index 5940931..36ea9c9 100644 --- a/server/src/services/notify.ts +++ b/server/src/services/notify.ts @@ -1,5 +1,6 @@ import nodemailer from "nodemailer"; import { getSettings, type NotificationEvents } from "./settingsStore.js"; +import { enqueueNotification, listQueuedNotifications, clearQueuedNotifications } from "./notificationQueue.js"; // ─── Channel senders (best-effort — failures are logged, never thrown) ────── @@ -70,10 +71,60 @@ async function sendWebhook(title: string, message: string) { } } -export async function notify(title: string, message: string): Promise { +async function sendToChannels(title: string, message: string): Promise { await Promise.all([sendGotify(title, message), sendNtfy(title, message), sendSmtp(title, message), sendWebhook(title, message)]); } +function currentTimeInZone(timezone: string): { hh: number; mm: number } { + const parts = new Intl.DateTimeFormat("en-GB", { timeZone: timezone, hour: "2-digit", minute: "2-digit", hour12: false }).formatToParts( + new Date(), + ); + return { + hh: Number(parts.find((p) => p.type === "hour")?.value ?? "0"), + mm: Number(parts.find((p) => p.type === "minute")?.value ?? "0"), + }; +} + +function toMinutes(hhmm: string): number { + const [h, m] = hhmm.split(":").map(Number); + return (Number.isFinite(h) ? h : 0) * 60 + (Number.isFinite(m) ? m : 0); +} + +async function isQuietHoursActive(): Promise { + const { quietHours, notifications } = await getSettings(); + if (!quietHours.enabled) return false; + const { hh, mm } = currentTimeInZone(notifications.timezone); + const nowMin = hh * 60 + mm; + const startMin = toMinutes(quietHours.start); + const endMin = toMinutes(quietHours.end); + if (startMin === endMin) return false; // zero-length window — treat as always off + // A same-day window (e.g. 09:00 -> 17:00) is a simple range; a window that + // wraps past midnight (e.g. 22:00 -> 07:00, the common case) means "active" + // is everything outside [end, start) instead. + return startMin < endMin ? nowMin >= startMin && nowMin < endMin : nowMin >= startMin || nowMin < endMin; +} + +export async function notify(title: string, message: string): Promise { + if (await isQuietHoursActive()) { + await enqueueNotification(title, message); + return; + } + await sendToChannels(title, message); +} + +/** Called by the daily scheduler at quiet-hours end (or manually) — sends everything queued during the window as one digest, then clears it. */ +export async function flushQuietHoursQueue(): Promise { + const queued = await listQueuedNotifications(); + if (queued.length === 0) return 0; + const sections = queued.map((q) => `— ${q.title} —\n${q.message}`); + await sendToChannels( + "Homelab Manager — Notifications from quiet hours", + `${queued.length} notification${queued.length !== 1 ? "s were" : " was"} held during quiet hours:\n\n${sections.join("\n\n")}`, + ); + await clearQueuedNotifications(); + return queued.length; +} + // ─── Test senders — throw on failure so the route can report it ──────────── export async function testGotify(cfg: { url: string; token: string; priority?: number }): Promise { diff --git a/server/src/services/quietHoursScheduler.ts b/server/src/services/quietHoursScheduler.ts new file mode 100644 index 0000000..d9ed2da --- /dev/null +++ b/server/src/services/quietHoursScheduler.ts @@ -0,0 +1,31 @@ +import schedule from "node-schedule"; +import { flushQuietHoursQueue } from "./notify.js"; +import { getSettings } from "./settingsStore.js"; + +function cronFromTime(time: string): string { + const [h, m] = time.split(":").map(Number); + return `${Number.isFinite(m) ? m : 0} ${Number.isFinite(h) ? h : 8} * * *`; +} + +let currentJob: schedule.Job | null = null; + +/** (Re)schedules the daily queue flush at quiet-hours end per the current settings. Call again after settings change. */ +export async function scheduleQuietHoursFlush(): Promise { + if (currentJob) { + currentJob.cancel(); + currentJob = null; + } + const { quietHours, notifications } = await getSettings(); + if (!quietHours.enabled) { + console.log("[quietHours] disabled, nothing scheduled"); + return; + } + currentJob = schedule.scheduleJob({ rule: cronFromTime(quietHours.end), tz: notifications.timezone }, () => { + flushQuietHoursQueue().catch((err) => console.error("[quietHours] flush failed:", err)); + }); + console.log(`Quiet hours flush scheduled at ${quietHours.end} (${notifications.timezone})`); +} + +export async function initQuietHoursScheduler(): Promise { + await scheduleQuietHoursFlush(); +} diff --git a/server/src/services/settingsStore.ts b/server/src/services/settingsStore.ts index ba080b1..fc5bbe3 100644 --- a/server/src/services/settingsStore.ts +++ b/server/src/services/settingsStore.ts @@ -70,6 +70,14 @@ export interface LogRetentionSettings { intervalHours: number; } +export interface QuietHoursSettings { + enabled: boolean; + /** "HH:MM", local to notifications.timezone. Notifications from `start` to `end` are queued, not sent immediately. */ + start: string; + /** "HH:MM" — when `start`..`end` wraps past midnight (e.g. 22:00 -> 07:00), the queue is flushed as one digest at this time. */ + end: string; +} + export interface AppSettings { gotify: GotifySettings; ntfy: NtfySettings; @@ -80,6 +88,7 @@ export interface AppSettings { integrationColors: IntegrationColors; display: DisplaySettings; logRetention: LogRetentionSettings; + quietHours: QuietHoursSettings; } const DEFAULTS: AppSettings = { @@ -104,6 +113,7 @@ const DEFAULTS: AppSettings = { integrationColors: {}, display: { dateFormat: "ymd", timeFormat: "24h", pageSize: 20 }, logRetention: { enabled: false, retentionDays: 90, intervalHours: 24 }, + quietHours: { enabled: false, start: "22:00", end: "07:00" }, }; const KEYS = Object.keys(DEFAULTS) as (keyof AppSettings)[]; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index eb58255..2fdfc4c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -175,6 +175,12 @@ export interface LogRetentionSettings { intervalHours: number; } +export interface QuietHoursSettings { + enabled: boolean; + start: string; + end: string; +} + export interface AppSettings { gotify: GotifySettings; ntfy: NtfySettings; @@ -185,6 +191,7 @@ export interface AppSettings { integrationColors: Record; display: DisplaySettings; logRetention: LogRetentionSettings; + quietHours: QuietHoursSettings; } export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial }; @@ -889,5 +896,10 @@ export const api = { request("/api/settings/export", { method: "POST", body: JSON.stringify({ passphrase }) }), importConfig: (passphrase: string, file: EncryptedExportFile) => request("/api/settings/import", { method: "POST", body: JSON.stringify({ passphrase, file }) }), + quietHoursQueue: () => + request<{ count: number; items: { id: number; title: string; message: string; createdAt: string }[] }>( + "/api/settings/quiet-hours-queue", + ), + flushQuietHours: () => request<{ flushed: number }>("/api/settings/flush-quiet-hours", { method: "POST" }), }, }; diff --git a/web/src/pages/settings/NotificationSettings.tsx b/web/src/pages/settings/NotificationSettings.tsx index 1fb3a88..a01c3ec 100644 --- a/web/src/pages/settings/NotificationSettings.tsx +++ b/web/src/pages/settings/NotificationSettings.tsx @@ -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(DEFAULT_SMTP); const [webhook, setWebhook] = useState(DEFAULT_WEBHOOK); const [notifications, setNotifications] = useState(DEFAULT_NOTIFICATIONS); + const [quietHours, setQuietHours] = useState(DEFAULT_QUIET_HOURS); + const [queuedCount, setQueuedCount] = useState(null); + const [flushing, setFlushing] = useState(false); + const [flushResult, setFlushResult] = useState(null); const [testingGotify, setTestingGotify] = useState(false); const [testGotifyResult, setTestGotifyResult] = useState(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() { + +
+
+
+

Quiet hours

+
+
+

+ 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. +

+ +
+
+ + setQuietHours((q) => ({ ...q, start: e.target.value }))} + /> +
+
+ + setQuietHours((q) => ({ ...q, end: e.target.value }))} + /> +
+
+
+ A start time later than the end time (e.g. 22:00 → 07:00) is treated as spanning midnight. +
+ {queuedCount !== null && queuedCount > 0 && ( +
+ + {queuedCount} notification{queuedCount !== 1 ? "s" : ""} currently queued. + + +
+ )} + {flushResult &&
{flushResult}
} +
+
+
);