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}
} +
+
+
);