diff --git a/migrations/0008_aromatic_chat.sql b/migrations/0008_aromatic_chat.sql new file mode 100644 index 0000000..18500b5 --- /dev/null +++ b/migrations/0008_aromatic_chat.sql @@ -0,0 +1,43 @@ +CREATE TABLE `notification_channel_monitors` ( + `channel_id` integer NOT NULL, + `monitor_id` integer NOT NULL, + PRIMARY KEY(`channel_id`, `monitor_id`), + FOREIGN KEY (`channel_id`) REFERENCES `notification_channels`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`monitor_id`) REFERENCES `monitors`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `notification_channel_monitors_monitor_id_idx` ON `notification_channel_monitors` (`monitor_id`);--> statement-breakpoint +CREATE TABLE `notification_channels` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `type` text NOT NULL, + `config` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `notify_manual` integer DEFAULT true NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `notification_channels_enabled_idx` ON `notification_channels` (`enabled`);--> statement-breakpoint +CREATE TABLE `notification_deliveries` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `channel_id` integer NOT NULL, + `incident_id` integer, + `monitor_id` integer, + `event` text NOT NULL, + `ok` integer NOT NULL, + `status_code` integer, + `error` text, + `attempts` integer DEFAULT 1 NOT NULL, + `created_at` integer NOT NULL, + FOREIGN KEY (`channel_id`) REFERENCES `notification_channels`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`incident_id`) REFERENCES `incidents`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`monitor_id`) REFERENCES `monitors`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `notification_deliveries_channel_id_created_at_idx` ON `notification_deliveries` (`channel_id`,`created_at`);--> statement-breakpoint +INSERT INTO `notification_channels` (`name`, `type`, `config`, `enabled`, `notify_manual`, `created_at`, `updated_at`) +SELECT 'Legacy webhook', 'webhook', json_object('url', `webhook_url`), `webhook_enabled`, 1, `created_at`, `updated_at` +FROM `notification_settings` +WHERE `webhook_url` IS NOT NULL;--> statement-breakpoint +DROP TABLE `notification_settings`; diff --git a/migrations/meta/0008_snapshot.json b/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..ed329be --- /dev/null +++ b/migrations/meta/0008_snapshot.json @@ -0,0 +1,1220 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "df28e3d3-6b1a-4060-a848-2067dfd1974c", + "prevId": "3e14e2ff-5a06-45c1-83c8-91d471c8cdf7", + "tables": { + "admin_credentials": { + "name": "admin_credentials", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ai_settings": { + "name": "ai_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "checks": { + "name": "checks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ok": { + "name": "ok", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "maintenance": { + "name": "maintenance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "checks_monitor_id_checked_at_idx": { + "name": "checks_monitor_id_checked_at_idx", + "columns": [ + "monitor_id", + "checked_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "checks_monitor_id_monitors_id_fk": { + "name": "checks_monitor_id_monitors_id_fk", + "tableFrom": "checks", + "tableTo": "monitors", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "incident_monitors": { + "name": "incident_monitors", + "columns": { + "incident_id": { + "name": "incident_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "incident_monitors_monitor_id_idx": { + "name": "incident_monitors_monitor_id_idx", + "columns": [ + "monitor_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "incident_monitors_incident_id_incidents_id_fk": { + "name": "incident_monitors_incident_id_incidents_id_fk", + "tableFrom": "incident_monitors", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "incident_monitors_monitor_id_monitors_id_fk": { + "name": "incident_monitors_monitor_id_monitors_id_fk", + "tableFrom": "incident_monitors", + "tableTo": "monitors", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "incident_monitors_incident_id_monitor_id_pk": { + "columns": [ + "incident_id", + "monitor_id" + ], + "name": "incident_monitors_incident_id_monitor_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "incident_updates": { + "name": "incident_updates", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "incident_id": { + "name": "incident_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "incident_updates_incident_id_created_at_idx": { + "name": "incident_updates_incident_id_created_at_idx", + "columns": [ + "incident_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "incident_updates_incident_id_incidents_id_fk": { + "name": "incident_updates_incident_id_incidents_id_fk", + "tableFrom": "incident_updates", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "incidents": { + "name": "incidents", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'investigating'" + }, + "impact": { + "name": "impact", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'major'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'auto'" + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_status_code": { + "name": "start_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_error": { + "name": "start_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "incidents_started_at_idx": { + "name": "incidents_started_at_idx", + "columns": [ + "started_at" + ], + "isUnique": false + }, + "incidents_resolved_at_idx": { + "name": "incidents_resolved_at_idx", + "columns": [ + "resolved_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "login_attempts": { + "name": "login_attempts", + "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 + }, + "attempted_at": { + "name": "attempted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "login_attempts_ip_attempted_at_idx": { + "name": "login_attempts_ip_attempted_at_idx", + "columns": [ + "ip_address", + "attempted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_window_monitors": { + "name": "maintenance_window_monitors", + "columns": { + "window_id": { + "name": "window_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "maintenance_window_monitors_monitor_id_idx": { + "name": "maintenance_window_monitors_monitor_id_idx", + "columns": [ + "monitor_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "maintenance_window_monitors_window_id_maintenance_windows_id_fk": { + "name": "maintenance_window_monitors_window_id_maintenance_windows_id_fk", + "tableFrom": "maintenance_window_monitors", + "tableTo": "maintenance_windows", + "columnsFrom": [ + "window_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "maintenance_window_monitors_monitor_id_monitors_id_fk": { + "name": "maintenance_window_monitors_monitor_id_monitors_id_fk", + "tableFrom": "maintenance_window_monitors", + "tableTo": "monitors", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "maintenance_window_monitors_window_id_monitor_id_pk": { + "columns": [ + "window_id", + "monitor_id" + ], + "name": "maintenance_window_monitors_window_id_monitor_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_windows": { + "name": "maintenance_windows", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_minute": { + "name": "start_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "maintenance_windows_enabled_idx": { + "name": "maintenance_windows_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_daily_stats": { + "name": "monitor_daily_stats", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day": { + "name": "day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_checks": { + "name": "total_checks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "up_checks": { + "name": "up_checks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avg_latency_ms": { + "name": "avg_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "min_latency_ms": { + "name": "min_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_latency_ms": { + "name": "max_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "monitor_daily_stats_monitor_id_day_uidx": { + "name": "monitor_daily_stats_monitor_id_day_uidx", + "columns": [ + "monitor_id", + "day" + ], + "isUnique": true + } + }, + "foreignKeys": { + "monitor_daily_stats_monitor_id_monitors_id_fk": { + "name": "monitor_daily_stats_monitor_id_monitors_id_fk", + "tableFrom": "monitor_daily_stats", + "tableTo": "monitors", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitors": { + "name": "monitors", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'GET'" + }, + "expected_status": { + "name": "expected_status", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 200 + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 300 + }, + "timeout_ms": { + "name": "timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 10000 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "alerts_enabled": { + "name": "alerts_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "failure_threshold": { + "name": "failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2 + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_ok": { + "name": "last_ok", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_status_code": { + "name": "last_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_latency_ms": { + "name": "last_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "monitors_enabled_last_checked_at_idx": { + "name": "monitors_enabled_last_checked_at_idx", + "columns": [ + "enabled", + "last_checked_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_channel_monitors": { + "name": "notification_channel_monitors", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notification_channel_monitors_monitor_id_idx": { + "name": "notification_channel_monitors_monitor_id_idx", + "columns": [ + "monitor_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notification_channel_monitors_channel_id_notification_channels_id_fk": { + "name": "notification_channel_monitors_channel_id_notification_channels_id_fk", + "tableFrom": "notification_channel_monitors", + "tableTo": "notification_channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_channel_monitors_monitor_id_monitors_id_fk": { + "name": "notification_channel_monitors_monitor_id_monitors_id_fk", + "tableFrom": "notification_channel_monitors", + "tableTo": "monitors", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notification_channel_monitors_channel_id_monitor_id_pk": { + "columns": [ + "channel_id", + "monitor_id" + ], + "name": "notification_channel_monitors_channel_id_monitor_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_channels": { + "name": "notification_channels", + "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 + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "notify_manual": { + "name": "notify_manual", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notification_channels_enabled_idx": { + "name": "notification_channels_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_deliveries": { + "name": "notification_deliveries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "incident_id": { + "name": "incident_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ok": { + "name": "ok", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notification_deliveries_channel_id_created_at_idx": { + "name": "notification_deliveries_channel_id_created_at_idx", + "columns": [ + "channel_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notification_deliveries_channel_id_notification_channels_id_fk": { + "name": "notification_deliveries_channel_id_notification_channels_id_fk", + "tableFrom": "notification_deliveries", + "tableTo": "notification_channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_deliveries_incident_id_incidents_id_fk": { + "name": "notification_deliveries_incident_id_incidents_id_fk", + "tableFrom": "notification_deliveries", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_deliveries_monitor_id_monitors_id_fk": { + "name": "notification_deliveries_monitor_id_monitors_id_fk", + "tableFrom": "notification_deliveries", + "tableTo": "monitors", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index c533e85..8322a06 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1788057318682, "tag": "0007_true_mauler", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1788059207578, + "tag": "0008_aromatic_chat", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/client/api/channels.ts b/src/client/api/channels.ts new file mode 100644 index 0000000..ffcdb74 --- /dev/null +++ b/src/client/api/channels.ts @@ -0,0 +1,58 @@ +import { deleteJson, getJson, patchJson, postJson } from './http'; + +export type ChannelType = 'slack' | 'discord' | 'telegram' | 'webhook'; +export type ChannelConfigInput = { url: string } | { botToken: string; chatId: string }; +export type NotificationDelivery = { + id: number; + channelId: number; + incidentId: number | null; + monitorId: number | null; + event: string; + ok: boolean; + statusCode: number | null; + error: string | null; + attempts: number; + createdAt: string; +}; +export type NotificationChannel = { + id: number; + name: string; + type: ChannelType; + config: { configSet: boolean; url?: string; botToken?: string; chatId?: string }; + enabled: boolean; + notifyManual: boolean; + monitorIds: number[]; + lastDelivery: NotificationDelivery | null; + createdAt: string; + updatedAt: string; +}; +export type NotificationChannelInput = { + name: string; + type: ChannelType; + config?: ChannelConfigInput; + enabled: boolean; + notifyManual: boolean; + monitorIds: number[]; +}; + +export function getNotificationChannels(signal?: AbortSignal) { + return getJson<{ channels: NotificationChannel[] }>('/api/channels', { signal, credentials: 'same-origin' }); +} +export function createNotificationChannel(input: NotificationChannelInput) { + return postJson<{ channel: NotificationChannel }>('/api/channels', input); +} +export function updateNotificationChannel(id: number, input: Partial) { + return patchJson<{ channel: NotificationChannel }>(`/api/channels/${id}`, input); +} +export function deleteNotificationChannel(id: number) { + return deleteJson<{ ok: true }>(`/api/channels/${id}`); +} +export function testNotificationChannel(id: number) { + return postJson<{ ok: true }>(`/api/channels/${id}/test`); +} +export function getNotificationDeliveries(id: number, limit = 20, signal?: AbortSignal) { + return getJson<{ deliveries: NotificationDelivery[] }>(`/api/channels/${id}/deliveries?limit=${limit}`, { + signal, + credentials: 'same-origin', + }); +} diff --git a/src/client/api/settings.ts b/src/client/api/settings.ts index 52778ce..f884458 100644 --- a/src/client/api/settings.ts +++ b/src/client/api/settings.ts @@ -1,15 +1,5 @@ import { getJson, postJson, putJson } from './http'; -export type NotificationSettings = { - id: number; - webhookUrl: string | null; - webhookEnabled: boolean; - createdAt: string | null; - updatedAt: string | null; -}; - -export type NotificationSettingsInput = Pick; - export type AiSettings = { id: number; enabled: boolean; @@ -28,21 +18,6 @@ export type AiSettingsInput = { apiKey?: string | null; }; -export function getNotificationSettings(signal?: AbortSignal) { - return getJson<{ settings: NotificationSettings }>('/api/settings/notifications', { - signal, - credentials: 'same-origin', - }); -} - -export function updateNotificationSettings(input: NotificationSettingsInput) { - return putJson<{ settings: NotificationSettings }>('/api/settings/notifications', input); -} - -export function testNotificationWebhook() { - return postJson<{ ok: true }>('/api/settings/notifications/test'); -} - export function getAiSettings(signal?: AbortSignal) { return getJson<{ settings: AiSettings }>('/api/settings/ai', { signal, diff --git a/src/client/components/settings/NotificationChannelDialog.tsx b/src/client/components/settings/NotificationChannelDialog.tsx new file mode 100644 index 0000000..01e0dd1 --- /dev/null +++ b/src/client/components/settings/NotificationChannelDialog.tsx @@ -0,0 +1,223 @@ +import { type FormEvent, useState } from 'react'; +import { Check, ChevronDown } from 'lucide-react'; +import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import type { ChannelType, NotificationChannel, NotificationChannelInput } from '../../api/channels'; +import { useCreateNotificationChannelMutation, useUpdateNotificationChannelMutation } from '../../queries/channels'; +import { useMonitorsQuery } from '../../queries/monitors'; + +type FormState = { + name: string; + type: ChannelType; + url: string; + botToken: string; + chatId: string; + enabled: boolean; + notifyManual: boolean; + monitorIds: number[]; +}; + +function initialForm(editing: NotificationChannel | null): FormState { + return { + name: editing?.name ?? '', + type: editing?.type ?? 'slack', + url: '', + botToken: '', + chatId: editing?.config.chatId ?? '', + enabled: editing?.enabled ?? true, + notifyManual: editing?.notifyManual ?? true, + monitorIds: editing?.monitorIds ?? [], + }; +} + +export function NotificationChannelDialog({ editing, onClose }: { editing: NotificationChannel | null; onClose: () => void }) { + const createMutation = useCreateNotificationChannelMutation(); + const updateMutation = useUpdateNotificationChannelMutation(); + const monitorsQuery = useMonitorsQuery(); + const [form, setForm] = useState(() => initialForm(editing)); + const mutation = editing ? updateMutation : createMutation; + const monitors = monitorsQuery.data?.monitors ?? []; + const selected = monitors.filter((monitor) => form.monitorIds.includes(monitor.id)); + + function toggleMonitor(id: number, checked: boolean) { + setForm((current) => ({ + ...current, + monitorIds: checked ? [...current.monitorIds, id] : current.monitorIds.filter((monitorId) => monitorId !== id), + })); + } + + function submit(event: FormEvent) { + event.preventDefault(); + const secretEntered = form.type === 'telegram' ? Boolean(form.botToken.trim()) : Boolean(form.url.trim()); + const config = form.type === 'telegram' ? { botToken: form.botToken.trim(), chatId: form.chatId.trim() } : { url: form.url.trim() }; + const input: NotificationChannelInput = { + name: form.name.trim(), + type: form.type, + ...(editing && editing.type === form.type && !secretEntered ? {} : { config }), + enabled: form.enabled, + notifyManual: form.notifyManual, + monitorIds: form.monitorIds, + }; + if (editing) updateMutation.mutate({ id: editing.id, input }, { onSuccess: onClose }); + else createMutation.mutate(input, { onSuccess: onClose }); + } + + return ( + !open && !mutation.isPending && onClose()}> + + +

Alert destination

+ {editing ? `Edit ${editing.name}` : 'Add notification channel'} + Send incident activity to a team tool or custom integration. +
+
+ +
+ Provider + +
+ {form.type === 'telegram' ? ( + <> + + + + ) : ( + + )} +
+ Services + + + + + + + {monitors.map((monitor) => ( + toggleMonitor(monitor.id, checked === true)} + onSelect={(event) => event.preventDefault()} + > + + + + + + {monitor.name} + + ))} + + + + Leave empty to notify for every service. +
+
+ setForm({ ...form, enabled })} /> + +
+
+ setForm({ ...form, notifyManual })} + /> + +
+ {form.type === 'webhook' && ( +
+ Raw payload +
{`{ "event": "down", "monitor": { … }, "statusCode": 500, "error": "…", "at": "…" }`}
+
+ )} +
+ + +
+ {mutation.isError && ( +

+ {mutation.error.message} +

+ )} +
+
+
+ ); +} diff --git a/src/client/components/settings/NotificationChannelsPanel.tsx b/src/client/components/settings/NotificationChannelsPanel.tsx new file mode 100644 index 0000000..7709c27 --- /dev/null +++ b/src/client/components/settings/NotificationChannelsPanel.tsx @@ -0,0 +1,189 @@ +import { useState } from 'react'; +import { History, Pencil, Plus, Send, Trash2 } from 'lucide-react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Empty, EmptyDescription, EmptyTitle } from '@/components/ui/empty'; +import type { NotificationChannel } from '../../api/channels'; +import { + useDeleteNotificationChannelMutation, + useNotificationDeliveriesQuery, + useNotificationChannelsQuery, + useTestNotificationChannelMutation, +} from '../../queries/channels'; +import { useMonitorsQuery } from '../../queries/monitors'; +import { NotificationChannelDialog } from './NotificationChannelDialog'; + +function DeliveryHistory({ channel }: { channel: NotificationChannel }) { + const deliveriesQuery = useNotificationDeliveriesQuery(channel.id); + if (deliveriesQuery.isPending) return
Loading delivery history…
; + if (deliveriesQuery.isError) return
Unable to load delivery history.
; + if (deliveriesQuery.data.deliveries.length === 0) return
No deliveries recorded yet.
; + return ( +
+ {deliveriesQuery.data.deliveries.map((delivery) => ( +
+ {delivery.ok ? 'Delivered' : 'Failed'} + {delivery.event.replaceAll('_', ' ')} + {new Date(delivery.createdAt).toLocaleString()} + {delivery.statusCode ? `HTTP ${delivery.statusCode}` : delivery.error} + + {delivery.attempts} attempt{delivery.attempts === 1 ? '' : 's'} + +
+ ))} +
+ ); +} + +export function NotificationChannelsPanel() { + const channelsQuery = useNotificationChannelsQuery(); + const monitorsQuery = useMonitorsQuery(); + const deleteMutation = useDeleteNotificationChannelMutation(); + const testMutation = useTestNotificationChannelMutation(); + const [dialog, setDialog] = useState<{ open: boolean; editing: NotificationChannel | null }>({ open: false, editing: null }); + const [deleting, setDeleting] = useState(null); + const [historyId, setHistoryId] = useState(null); + const monitorNames = new Map(monitorsQuery.data?.monitors.map((monitor) => [monitor.id, monitor.name])); + + return ( + <> +
+

Route automatic and manual incident activity to the right team.

+ +
+ {channelsQuery.isPending ? ( +
Loading notification channels…
+ ) : channelsQuery.isError ? ( + + Unable to load notification channels + + ) : channelsQuery.data.channels.length === 0 ? ( + + No notification channels + Add Slack, Discord, Telegram, or a raw webhook destination. + + ) : ( +
+ {channelsQuery.data.channels.map((channel) => ( +
+
+
+
+ {channel.name} + {channel.type} + {!channel.enabled && Disabled} +
+
+ {channel.lastDelivery ? ( + + {channel.lastDelivery.ok ? 'Delivered' : 'Failed'} + + ) : ( + No deliveries + )} + {channel.lastDelivery && ( + + {new Date(channel.lastDelivery.createdAt).toLocaleString()} · {channel.lastDelivery.event.replaceAll('_', ' ')} + + )} +
+
+ {channel.monitorIds.length === 0 ? ( + All services + ) : ( + channel.monitorIds.map((id) => {monitorNames.get(id) ?? `Service ${id}`}) + )} +
+
+
+ + + + +
+
+ {historyId === channel.id && } +
+ ))} +
+ )} + {testMutation.isError &&

{testMutation.error.message}

} + {testMutation.isSuccess &&

Test notification delivered.

} + {dialog.open && setDialog({ open: false, editing: null })} />} + !open && !deleteMutation.isPending && setDeleting(null)}> + + +

Confirm

+ Delete {deleting?.name}? + This removes its routing and delivery history. Incident history is unchanged. +
+ + + + + + + + +
+
+ + ); +} diff --git a/src/client/pages/SettingsPage.tsx b/src/client/pages/SettingsPage.tsx index edec721..dad4431 100644 --- a/src/client/pages/SettingsPage.tsx +++ b/src/client/pages/SettingsPage.tsx @@ -1,75 +1,16 @@ import { type FormEvent, useState } from 'react'; -import { ArrowLeft, BellRing, Send, Sparkles, Wrench } from 'lucide-react'; +import { ArrowLeft, BellRing, Sparkles, Wrench } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Empty, EmptyTitle } from '@/components/ui/empty'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; -import type { AiSettings, NotificationSettings } from '../api/settings'; +import type { AiSettings } from '../api/settings'; import { AppHeader } from '../components/AppHeader'; import { MaintenanceWindowsPanel } from '../components/settings/MaintenanceWindowsPanel'; +import { NotificationChannelsPanel } from '../components/settings/NotificationChannelsPanel'; import { navigate } from '../lib/router'; -import { - useAiSettingsQuery, - useNotificationSettingsQuery, - useTestAiSettingsMutation, - useTestNotificationWebhookMutation, - useUpdateAiSettingsMutation, - useUpdateNotificationSettingsMutation, -} from '../queries/settings'; - -function SettingsForm({ settings }: { settings: NotificationSettings }) { - const [webhookUrl, setWebhookUrl] = useState(settings.webhookUrl ?? ''); - const [webhookEnabled, setWebhookEnabled] = useState(settings.webhookEnabled); - const updateMutation = useUpdateNotificationSettingsMutation(); - const testMutation = useTestNotificationWebhookMutation(); - - function submit(event: FormEvent) { - event.preventDefault(); - updateMutation.mutate({ webhookUrl: webhookUrl.trim() || null, webhookEnabled }); - } - - return ( -
- -
- - -
-
- - -
- {updateMutation.isSuccess &&

Notification settings saved.

} - {testMutation.isSuccess &&

Test webhook delivered successfully.

} - {(updateMutation.isError || testMutation.isError) && ( -

{(updateMutation.error ?? testMutation.error)?.message ?? 'Request failed'}

- )} -
- ); -} +import { useAiSettingsQuery, useTestAiSettingsMutation, useUpdateAiSettingsMutation } from '../queries/settings'; function AiSettingsForm({ settings }: { settings: AiSettings }) { const [baseUrl, setBaseUrl] = useState(settings.baseUrl ?? 'https://api.openai.com/v1'); @@ -148,7 +89,6 @@ function AiSettingsForm({ settings }: { settings: AiSettings }) { } export function SettingsPage() { - const settingsQuery = useNotificationSettingsQuery(); const aiSettingsQuery = useAiSettingsQuery(); return (
@@ -169,19 +109,11 @@ export function SettingsPage() {
-

Incident webhook

-

Upwatch sends a compact JSON payload for down and recovery events. Delivery failures never interrupt monitoring.

+

Notification channels

+

Route incidents to Slack, Discord, Telegram, or existing webhook integrations, with delivery history.

- {settingsQuery.isPending ? ( -
Loading settings…
- ) : settingsQuery.isError ? ( - - Unable to load notification settings - - ) : ( - - )} + @@ -222,16 +154,6 @@ export function SettingsPage() { -
-

Payload preview

-
{`{
-  "event": "down",
-  "monitor": { "id": 12, "name": "API", "url": "https://api.example.com" },
-  "statusCode": 500,
-  "error": "Expected HTTP 200, received 500",
-  "at": "2026-08-28T03:25:00.000Z"
-}`}
-
); diff --git a/src/client/queries/channels.ts b/src/client/queries/channels.ts new file mode 100644 index 0000000..4dbb7a8 --- /dev/null +++ b/src/client/queries/channels.ts @@ -0,0 +1,40 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { + createNotificationChannel, + deleteNotificationChannel, + getNotificationChannels, + getNotificationDeliveries, + testNotificationChannel, + updateNotificationChannel, + type NotificationChannelInput, +} from '../api/channels'; +import { queryClient } from '../lib/query-client'; + +export const channelKeys = { + all: ['channels'] as const, + deliveries: (id: number) => ['channels', id, 'deliveries'] as const, +}; +export function useNotificationChannelsQuery() { + return useQuery({ queryKey: channelKeys.all, queryFn: ({ signal }) => getNotificationChannels(signal), refetchInterval: 30_000 }); +} +function invalidateChannels() { + return queryClient.invalidateQueries({ queryKey: channelKeys.all }); +} +export function useCreateNotificationChannelMutation() { + return useMutation({ mutationFn: (input: NotificationChannelInput) => createNotificationChannel(input), onSuccess: invalidateChannels }); +} +export function useUpdateNotificationChannelMutation() { + return useMutation({ + mutationFn: ({ id, input }: { id: number; input: Partial }) => updateNotificationChannel(id, input), + onSuccess: invalidateChannels, + }); +} +export function useDeleteNotificationChannelMutation() { + return useMutation({ mutationFn: deleteNotificationChannel, onSuccess: invalidateChannels }); +} +export function useTestNotificationChannelMutation() { + return useMutation({ mutationFn: testNotificationChannel, onSuccess: invalidateChannels }); +} +export function useNotificationDeliveriesQuery(id: number) { + return useQuery({ queryKey: channelKeys.deliveries(id), queryFn: ({ signal }) => getNotificationDeliveries(id, 20, signal) }); +} diff --git a/src/client/queries/settings.ts b/src/client/queries/settings.ts index 40954a7..d0687f3 100644 --- a/src/client/queries/settings.ts +++ b/src/client/queries/settings.ts @@ -1,40 +1,12 @@ import { useMutation, useQuery } from '@tanstack/react-query'; -import { - getAiSettings, - getNotificationSettings, - testAiSettings, - testNotificationWebhook, - updateAiSettings, - updateNotificationSettings, - type AiSettingsInput, - type NotificationSettingsInput, -} from '../api/settings'; +import { getAiSettings, testAiSettings, updateAiSettings, type AiSettingsInput } from '../api/settings'; import { queryClient } from '../lib/query-client'; export const settingsKeys = { all: ['settings'] as const, - notifications: () => [...settingsKeys.all, 'notifications'] as const, ai: () => [...settingsKeys.all, 'ai'] as const, }; -export function useNotificationSettingsQuery() { - return useQuery({ - queryKey: settingsKeys.notifications(), - queryFn: ({ signal }) => getNotificationSettings(signal), - }); -} - -export function useUpdateNotificationSettingsMutation() { - return useMutation({ - mutationFn: (input: NotificationSettingsInput) => updateNotificationSettings(input), - onSuccess: (data) => queryClient.setQueryData(settingsKeys.notifications(), data), - }); -} - -export function useTestNotificationWebhookMutation() { - return useMutation({ mutationFn: testNotificationWebhook }); -} - export function useAiSettingsQuery() { return useQuery({ queryKey: settingsKeys.ai(), diff --git a/src/client/styles.css b/src/client/styles.css index 7e28a17..015e3bc 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -1403,6 +1403,256 @@ button { color: #16754f; background: #f2fbf7; } +.channel-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 20px 26px; + border-bottom: 1px solid #ededed; +} +.channel-panel-header p { + margin: 0; + font-size: 13px; + color: var(--muted); +} +.channel-panel-header svg, +.channel-actions svg { + width: 15px; + height: 15px; +} +.channel-empty { + padding: 44px 24px; +} +.channel-row { + display: flex; + flex-direction: column; + padding: 21px 26px; +} +.channel-row-summary { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 24px; +} +.channel-row + .channel-row { + border-top: 1px solid #ededed; +} +.channel-main { + min-width: 0; +} +.channel-title, +.channel-delivery, +.channel-services, +.channel-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} +.channel-title > strong { + font-size: 14px; + font-weight: 600; +} +.channel-delivery { + margin: 8px 0 10px; + font-size: 11px; + color: var(--faint); +} +.channel-services span, +.channel-services em { + padding: 4px 7px; + border: 1px solid #dedede; + border-radius: 5px; + font-size: 11px; + font-style: normal; + color: var(--muted); + background: #fafafa; +} +.channel-actions { + flex: 0 0 auto; +} +.compact-button { + min-height: 34px; + padding: 7px 10px; +} +.channel-feedback { + margin: 0 26px 20px; +} +.channel-history { + display: grid; + width: 100%; + gap: 1px; + margin-top: 18px; + border: 1px solid #e6e6e6; + border-radius: 6px; + overflow: hidden; + background: #e6e6e6; +} +.channel-history-row { + display: grid; + grid-template-columns: 78px minmax(100px, 0.8fr) minmax(145px, 1fr) minmax(120px, 1fr) auto; + align-items: center; + gap: 12px; + padding: 10px 12px; + font-size: 11px; + background: #fff; +} +.channel-history-row strong { + font-weight: 600; + text-transform: capitalize; +} +.channel-history-row span, +.channel-history-row small, +.channel-history-state { + color: var(--muted); +} +.channel-history-state { + width: 100%; + margin-top: 16px; + font-size: 12px; +} +.channel-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 17px 16px; + min-height: 0; + margin: 0; + padding: 20px 26px 0; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-color: #bdc9c3 transparent; + scrollbar-width: thin; +} +.channel-dialog { + display: flex; + width: min(720px, calc(100vw - 32px)); + max-width: 720px; + max-height: min(90dvh, 760px); + flex-direction: column; + gap: 0; + overflow: hidden; + padding: 0; + border-radius: 12px; + box-shadow: + 0 24px 70px rgb(22 62 45 / 0.15), + 0 4px 16px rgb(0 0 0 / 0.07); +} +.channel-dialog-header { + position: relative; + flex: 0 0 auto; + gap: 5px; + padding: 24px 64px 20px 26px; + border-bottom: 1px solid #e9ecea; + background: linear-gradient(180deg, #fff, #fdfefd); +} +.channel-dialog-header .overline { + margin-bottom: 4px; +} +.channel-dialog-header [data-slot='dialog-title'] { + font-size: 20px; + line-height: 1.25; + letter-spacing: -0.35px; +} +.channel-dialog-header [data-slot='dialog-description'] { + font-size: 13px; + line-height: 1.5; +} +.channel-dialog > [data-slot='dialog-close'] { + top: 19px; + right: 20px; +} +.channel-form > .field { + min-width: 0; +} +.channel-form .field { + gap: 6px; +} +.channel-endpoint-field, +.channel-form > .channel-services, +.channel-form > .channel-payload-preview, +.channel-form > .form-error, +.channel-form > .form-actions.channel-dialog-footer { + grid-column: 1 / -1; +} +.channel-form > .channel-services { + display: block; + padding-top: 18px; + border-top: 1px solid #edf0ee; +} +.channel-form > .channel-services legend { + float: left; + margin-bottom: 7px; +} +.channel-form > .channel-services .maintenance-service-select { + clear: both; +} +.channel-form > .channel-services .field-helper { + margin-top: 6px; +} +.channel-toggle-option { + min-width: 0; + align-items: flex-start; + padding: 14px; + border: 1px solid #e2e6e4; + border-radius: 8px; + background: #fbfcfb; + transition: + border-color 160ms ease, + background-color 160ms ease; +} +.channel-toggle-option:hover { + border-color: #cbd6d1; + background: #f8fbf9; +} +.channel-toggle-option [role='switch'] { + margin-top: 1px; +} +.channel-toggle-option label { + min-width: 0; +} +.channel-toggle-option small { + font-size: 11px; + line-height: 1.45; +} +.channel-dialog-footer { + position: sticky; + z-index: 2; + bottom: 0; + display: flex; + justify-content: flex-end; + gap: 9px; + width: auto; + margin: 3px -26px 0; + padding: 15px 26px; + border-top: 1px solid #e4e8e6; + background: rgb(250 251 250 / 0.97); + backdrop-filter: blur(8px); +} +.channel-dialog-footer .primary-button, +.channel-dialog-footer .secondary-button { + min-height: 38px; + padding: 7px 15px; +} +.channel-payload-preview { + padding: 12px; + border: 1px solid #e3e3e3; + border-radius: 6px; + background: #fafafa; +} +.channel-payload-preview > span { + display: block; + margin-bottom: 7px; + font-size: 12px; + font-weight: 600; +} +.channel-payload-preview pre { + margin: 0; + overflow-x: auto; + font-size: 11px; + color: var(--muted); +} .maintenance-panel-header { display: flex; align-items: center; @@ -2444,6 +2694,36 @@ button { } @media (max-width: 520px) { + .channel-dialog { + width: calc(100vw - 20px); + max-height: calc(100dvh - 20px); + border-radius: 10px; + } + .channel-dialog-header { + padding: 21px 54px 18px 20px; + } + .channel-dialog > [data-slot='dialog-close'] { + top: 16px; + right: 14px; + } + .channel-form { + grid-template-columns: 1fr; + padding: 18px 20px 0; + } + .channel-endpoint-field, + .channel-form > .channel-services, + .channel-form > .channel-payload-preview, + .channel-form > .form-error, + .channel-dialog-footer { + grid-column: auto; + } + .channel-dialog-footer { + margin: 3px -20px 0; + padding: 14px 20px; + } + .channel-dialog-footer button { + flex: 1; + } .auth-page { padding: 24px 16px; } @@ -2525,15 +2805,32 @@ button { padding: 20px; } .maintenance-panel-header, - .maintenance-window-row { + .maintenance-window-row, + .channel-panel-header, + .channel-row { padding-right: 20px; padding-left: 20px; } .maintenance-panel-header, - .maintenance-window-row { + .maintenance-window-row, + .channel-panel-header, + .channel-row { align-items: flex-start; flex-direction: column; } + .channel-actions { + align-self: stretch; + } + .channel-row-summary { + align-items: flex-start; + flex-direction: column; + } + .channel-history { + overflow-x: auto; + } + .channel-history-row { + min-width: 650px; + } .maintenance-window-actions { align-self: flex-end; margin-top: -48px; diff --git a/src/worker/checks/run-due-checks.ts b/src/worker/checks/run-due-checks.ts index 2b1d676..2e11d74 100644 --- a/src/worker/checks/run-due-checks.ts +++ b/src/worker/checks/run-due-checks.ts @@ -3,7 +3,7 @@ import { generateIncidentMessage } from '../ai/incident-message'; import { getDb } from '../db/client'; import { monitors } from '../db/schema'; import { loadActiveMaintenance } from '../maintenance/windows'; -import { sendIncidentAlert } from '../notifications/webhook'; +import { dispatchNotification, MAX_NOTIFICATIONS_PER_RUN, type NotificationBudget } from '../notifications/dispatch'; import { type AlertTransition, buildResultStatements } from './persist-result'; import { runCheck, runCheckWithRetries, type RetryBudget } from './run-check'; @@ -72,16 +72,25 @@ export async function runDueChecks(env: Env, ctx?: Pick { if (item.transition !== 'opened' && item.transition !== 'resolved') return []; const kind: AlertTransition = item.transition; const work: Promise[] = [ - sendIncidentAlert(env, { - monitor: item.monitor, - kind, - result: item.result, - at: item.checkedAt, - }), + dispatchNotification( + env, + { + monitor: { id: item.monitor.id, name: item.monitor.name, url: item.monitor.url }, + kind: kind === 'opened' ? 'down' : 'recovered', + incidentId: null, + title: kind === 'opened' ? `${item.monitor.name} is down` : `${item.monitor.name} recovered`, + body: item.result.error, + statusCode: item.result.statusCode, + error: item.result.error, + at: item.checkedAt, + }, + notificationBudget, + ), ]; if (item.transition === 'opened' && item.monitor.alertsEnabled && aiMessagesQueued < MAX_AI_MESSAGES_PER_RUN) { aiMessagesQueued += 1; diff --git a/src/worker/db/schema.ts b/src/worker/db/schema.ts index 7331ddf..a235498 100644 --- a/src/worker/db/schema.ts +++ b/src/worker/db/schema.ts @@ -7,14 +7,6 @@ export const adminCredentials = sqliteTable('admin_credentials', { updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), }); -export const notificationSettings = sqliteTable('notification_settings', { - id: integer('id').primaryKey(), - webhookUrl: text('webhook_url'), - webhookEnabled: integer('webhook_enabled', { mode: 'boolean' }).notNull().default(false), - createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), - updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), -}); - export const aiSettings = sqliteTable('ai_settings', { id: integer('id').primaryKey(), enabled: integer('enabled', { mode: 'boolean' }).notNull().default(false), @@ -172,6 +164,56 @@ export const incidentUpdates = sqliteTable( (table) => [index('incident_updates_incident_id_created_at_idx').on(table.incidentId, table.createdAt)], ); +export const notificationChannels = sqliteTable( + 'notification_channels', + { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + type: text('type').notNull(), + config: text('config').notNull(), + enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true), + notifyManual: integer('notify_manual', { mode: 'boolean' }).notNull().default(true), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), + }, + (table) => [index('notification_channels_enabled_idx').on(table.enabled)], +); + +export const notificationChannelMonitors = sqliteTable( + 'notification_channel_monitors', + { + channelId: integer('channel_id') + .notNull() + .references(() => notificationChannels.id, { onDelete: 'cascade' }), + monitorId: integer('monitor_id') + .notNull() + .references(() => monitors.id, { onDelete: 'cascade' }), + }, + (table) => [ + primaryKey({ columns: [table.channelId, table.monitorId] }), + index('notification_channel_monitors_monitor_id_idx').on(table.monitorId), + ], +); + +export const notificationDeliveries = sqliteTable( + 'notification_deliveries', + { + id: integer('id').primaryKey({ autoIncrement: true }), + channelId: integer('channel_id') + .notNull() + .references(() => notificationChannels.id, { onDelete: 'cascade' }), + incidentId: integer('incident_id').references(() => incidents.id, { onDelete: 'cascade' }), + monitorId: integer('monitor_id').references(() => monitors.id, { onDelete: 'cascade' }), + event: text('event').notNull(), + ok: integer('ok', { mode: 'boolean' }).notNull(), + statusCode: integer('status_code'), + error: text('error'), + attempts: integer('attempts').notNull().default(1), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), + }, + (table) => [index('notification_deliveries_channel_id_created_at_idx').on(table.channelId, table.createdAt)], +); + export const monitorDailyStats = sqliteTable( 'monitor_daily_stats', { diff --git a/src/worker/index.ts b/src/worker/index.ts index d4dc0bb..bbd3d86 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import { csrf } from 'hono/csrf'; import { runDueChecks } from './checks/run-due-checks'; import authRoutes from './routes/auth'; +import channelRoutes from './routes/channels'; import incidentRoutes from './routes/incidents'; import maintenanceRoutes from './routes/maintenance'; import monitorRoutes from './routes/monitors'; @@ -27,6 +28,7 @@ app.get('/api/health', async (context) => { }); app.route('/', authRoutes); +app.route('/api/channels', channelRoutes); app.route('/api/incidents', incidentRoutes); app.route('/api/maintenance', maintenanceRoutes); app.route('/api/monitors', monitorRoutes); diff --git a/src/worker/notifications/dispatch.ts b/src/worker/notifications/dispatch.ts new file mode 100644 index 0000000..d67e997 --- /dev/null +++ b/src/worker/notifications/dispatch.ts @@ -0,0 +1,195 @@ +import { and, desc, eq, inArray, isNotNull, isNull } from 'drizzle-orm'; +import { getDb } from '../db/client'; +import { incidents, incidentMonitors, notificationChannelMonitors, notificationChannels, notificationDeliveries } from '../db/schema'; +import { + CHANNEL_TYPES, + formatChannel, + parseChannelConfig, + type ChannelType, + type NotificationEvent, + type OutboundRequest, +} from './providers'; + +export const MAX_NOTIFICATIONS_PER_RUN = 40; +export type NotificationBudget = { remaining: number }; + +type DeliveryResult = { ok: boolean; statusCode: number | null; error: string | null; attempts: number }; + +function isChannelType(value: string): value is ChannelType { + return CHANNEL_TYPES.some((type) => type === value); +} + +async function sendRequest(request: OutboundRequest): Promise { + let attempts = 0; + for (;;) { + attempts += 1; + try { + const response = await fetch(request.url, { method: 'POST', headers: request.headers, body: request.body }); + await response.body?.cancel(); + if (response.ok) return { ok: true, statusCode: response.status, error: null, attempts }; + const retryable = response.status === 429 || response.status >= 500; + if (!retryable || attempts >= 2) { + return { ok: false, statusCode: response.status, error: `HTTP ${response.status}`, attempts }; + } + } catch (error) { + if (attempts >= 2) { + return { + ok: false, + statusCode: null, + error: error instanceof Error ? error.message.slice(0, 500) : String(error).slice(0, 500), + attempts, + }; + } + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } +} + +async function persistDeliveries(env: Env, event: NotificationEvent, results: Array<{ channelId: number; result: DeliveryResult }>) { + if (results.length === 0) return; + const db = getDb(env); + const createdAt = new Date(); + const statements = results.map(({ channelId, result }) => + db.insert(notificationDeliveries).values({ + channelId, + incidentId: event.incidentId, + monitorId: event.monitor?.id ?? null, + event: event.kind, + ok: result.ok, + statusCode: result.statusCode, + error: result.error, + attempts: result.attempts, + createdAt, + }), + ); + await db.batch(statements as [(typeof statements)[number], ...typeof statements]); +} + +export async function dispatchNotification(env: Env, event: NotificationEvent, budget?: NotificationBudget): Promise { + const db = getDb(env); + let effectiveEvent = event; + if (event.incidentId === null && event.monitor && (event.kind === 'down' || event.kind === 'recovered')) { + const [incident] = await db + .select({ id: incidents.id }) + .from(incidents) + .innerJoin(incidentMonitors, eq(incidentMonitors.incidentId, incidents.id)) + .where( + and( + eq(incidentMonitors.monitorId, event.monitor.id), + eq(incidents.source, 'auto'), + event.kind === 'down' ? isNull(incidents.resolvedAt) : isNotNull(incidents.resolvedAt), + ), + ) + .orderBy(desc(incidents.updatedAt)) + .limit(1); + if (incident) effectiveEvent = { ...event, incidentId: incident.id }; + } + let targetMonitorIds = event.monitor ? [event.monitor.id] : []; + if (!event.monitor && event.incidentId !== null) { + const rows = await db + .select({ monitorId: incidentMonitors.monitorId }) + .from(incidentMonitors) + .where(eq(incidentMonitors.incidentId, event.incidentId)); + targetMonitorIds = rows.map((row) => row.monitorId); + } + if (event.monitor) { + const monitor = await env.DB.prepare('SELECT alerts_enabled FROM monitors WHERE id = ?').bind(event.monitor.id).first<{ + alerts_enabled: number; + }>(); + if (!monitor || monitor.alerts_enabled !== 1) return; + } + + const channels = await db + .select() + .from(notificationChannels) + .where( + event.kind === 'manual_opened' || event.kind === 'manual_update' + ? and(eq(notificationChannels.enabled, true), eq(notificationChannels.notifyManual, true)) + : eq(notificationChannels.enabled, true), + ); + if (channels.length === 0) return; + + const assignments = await db + .select() + .from(notificationChannelMonitors) + .where( + inArray( + notificationChannelMonitors.channelId, + channels.map((channel) => channel.id), + ), + ); + const monitorIdsByChannel = new Map(); + for (const assignment of assignments) { + const ids = monitorIdsByChannel.get(assignment.channelId); + if (ids) ids.push(assignment.monitorId); + else monitorIdsByChannel.set(assignment.channelId, [assignment.monitorId]); + } + const routed = channels.filter((channel) => { + const monitorIds = monitorIdsByChannel.get(channel.id) ?? []; + return monitorIds.length === 0 || monitorIds.some((monitorId) => targetMonitorIds.includes(monitorId)); + }); + + const available = Math.max(0, budget?.remaining ?? MAX_NOTIFICATIONS_PER_RUN); + const sendable = routed.slice(0, available); + if (budget) budget.remaining -= sendable.length; + const skipped = routed.slice(sendable.length).map((channel) => ({ + channelId: channel.id, + result: { ok: false, statusCode: null, error: 'skipped: per-run limit', attempts: 0 }, + })); + + const settled = await Promise.allSettled( + sendable.map(async (channel) => { + if (!isChannelType(channel.type)) throw new Error(`Unsupported channel type: ${channel.type}`); + let rawConfig: unknown; + try { + rawConfig = JSON.parse(channel.config); + } catch { + throw new Error('Invalid stored channel configuration'); + } + const config = parseChannelConfig(channel.type, rawConfig); + if (typeof config === 'string') throw new Error(config); + return { channelId: channel.id, result: await sendRequest(formatChannel(channel.type, config, effectiveEvent)) }; + }), + ); + const delivered = settled.map((result, index) => + result.status === 'fulfilled' + ? result.value + : { + channelId: sendable[index].id, + result: { + ok: false, + statusCode: null, + error: result.reason instanceof Error ? result.reason.message.slice(0, 500) : String(result.reason).slice(0, 500), + attempts: 0, + }, + }, + ); + await persistDeliveries(env, effectiveEvent, [...delivered, ...skipped]); +} + +export async function dispatchTest(env: Env, channelId: number): Promise<{ ok: boolean; error: string | null }> { + const db = getDb(env); + const [channel] = await db.select().from(notificationChannels).where(eq(notificationChannels.id, channelId)).limit(1); + if (!channel) return { ok: false, error: 'Notification channel not found' }; + const event: NotificationEvent = { + kind: 'test', + monitor: null, + incidentId: null, + title: 'Upwatch test', + body: 'Your notification channel is configured correctly.', + statusCode: 200, + error: null, + at: new Date(), + }; + let result: DeliveryResult; + try { + if (!isChannelType(channel.type)) throw new Error(`Unsupported channel type: ${channel.type}`); + const config = parseChannelConfig(channel.type, JSON.parse(channel.config) as unknown); + if (typeof config === 'string') throw new Error(config); + result = await sendRequest(formatChannel(channel.type, config, event)); + } catch (error) { + result = { ok: false, statusCode: null, error: error instanceof Error ? error.message : String(error), attempts: 0 }; + } + await persistDeliveries(env, event, [{ channelId, result }]); + return { ok: result.ok, error: result.error }; +} diff --git a/src/worker/notifications/providers/discord.ts b/src/worker/notifications/providers/discord.ts new file mode 100644 index 0000000..28605ca --- /dev/null +++ b/src/worker/notifications/providers/discord.ts @@ -0,0 +1,30 @@ +import type { Provider, UrlConfig } from './types'; +import { eventColor, eventLabel, parseSafeUrl, secretPreview } from './types'; + +export const discordProvider: Provider = { + parseConfig: parseSafeUrl, + maskConfig: (config) => ({ url: secretPreview(config.url), configSet: true }), + format(config, event) { + const fields = [ + event.monitor ? { name: 'Service', value: event.monitor.name, inline: true } : null, + event.statusCode !== null ? { name: 'Status code', value: String(event.statusCode), inline: true } : null, + event.error ? { name: 'Error', value: event.error.slice(0, 1024), inline: false } : null, + ].filter(Boolean); + return { + url: config.url, + headers: { 'Content-Type': 'application/json', 'User-Agent': 'Upwatch/1.0 (+notification)' }, + body: JSON.stringify({ + embeds: [ + { + title: `${eventLabel(event.kind)} · ${event.monitor?.name ?? 'Upwatch'}`, + ...(event.monitor ? { url: event.monitor.url } : {}), + description: event.body ?? event.title, + color: Number.parseInt(eventColor(event.kind).slice(1), 16), + fields, + timestamp: event.at.toISOString(), + }, + ], + }), + }; + }, +}; diff --git a/src/worker/notifications/providers/index.ts b/src/worker/notifications/providers/index.ts new file mode 100644 index 0000000..336db51 --- /dev/null +++ b/src/worker/notifications/providers/index.ts @@ -0,0 +1,28 @@ +import { discordProvider } from './discord'; +import { slackProvider } from './slack'; +import { telegramProvider } from './telegram'; +import type { ChannelConfig, ChannelType, NotificationEvent, OutboundRequest } from './types'; +import { webhookProvider } from './webhook'; + +export * from './types'; +export { discordProvider, slackProvider, telegramProvider, webhookProvider }; + +export function parseChannelConfig(type: ChannelType, value: unknown): ChannelConfig | string { + if (type === 'telegram') return telegramProvider.parseConfig(value); + return ({ slack: slackProvider, discord: discordProvider, webhook: webhookProvider } as const)[type].parseConfig(value); +} + +export function maskChannelConfig(type: ChannelType, config: ChannelConfig) { + if (type === 'telegram') return telegramProvider.maskConfig(config as { botToken: string; chatId: string }); + return ({ slack: slackProvider, discord: discordProvider, webhook: webhookProvider } as const)[type].maskConfig( + config as { url: string }, + ); +} + +export function formatChannel(type: ChannelType, config: ChannelConfig, event: NotificationEvent): OutboundRequest { + if (type === 'telegram') return telegramProvider.format(config as { botToken: string; chatId: string }, event); + return ({ slack: slackProvider, discord: discordProvider, webhook: webhookProvider } as const)[type].format( + config as { url: string }, + event, + ); +} diff --git a/src/worker/notifications/providers/slack.ts b/src/worker/notifications/providers/slack.ts new file mode 100644 index 0000000..b5daaa5 --- /dev/null +++ b/src/worker/notifications/providers/slack.ts @@ -0,0 +1,31 @@ +import type { Provider, UrlConfig } from './types'; +import { eventColor, eventLabel, parseSafeUrl, secretPreview } from './types'; + +export const slackProvider: Provider = { + parseConfig: parseSafeUrl, + maskConfig: (config) => ({ url: secretPreview(config.url), configSet: true }), + format(config, event) { + const fields = [ + event.monitor ? { type: 'mrkdwn', text: `*Service*\n${event.monitor.name}` } : null, + event.monitor ? { type: 'mrkdwn', text: `*URL*\n${event.monitor.url}` } : null, + event.statusCode !== null ? { type: 'mrkdwn', text: `*Status code*\n${event.statusCode}` } : null, + { type: 'mrkdwn', text: `*Time*\n${event.at.toISOString()}` }, + ].filter(Boolean); + return { + url: config.url, + headers: { 'Content-Type': 'application/json', 'User-Agent': 'Upwatch/1.0 (+notification)' }, + body: JSON.stringify({ + attachments: [ + { + color: eventColor(event.kind), + blocks: [ + { type: 'header', text: { type: 'plain_text', text: `${eventLabel(event.kind)} · ${event.monitor?.name ?? 'Upwatch'}` } }, + { type: 'section', text: { type: 'mrkdwn', text: event.body ?? event.title }, fields }, + ...(event.error ? [{ type: 'context', elements: [{ type: 'mrkdwn', text: `Error: ${event.error}` }] }] : []), + ], + }, + ], + }), + }; + }, +}; diff --git a/src/worker/notifications/providers/telegram.ts b/src/worker/notifications/providers/telegram.ts new file mode 100644 index 0000000..4a82185 --- /dev/null +++ b/src/worker/notifications/providers/telegram.ts @@ -0,0 +1,34 @@ +import type { Provider, TelegramConfig } from './types'; +import { eventLabel, isRecord, secretPreview } from './types'; + +function escapeHtml(value: string) { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); +} + +export const telegramProvider: Provider = { + parseConfig(value) { + if (!isRecord(value)) return 'Invalid Telegram configuration'; + const botToken = typeof value.botToken === 'string' ? value.botToken.trim() : ''; + const chatId = typeof value.chatId === 'string' ? value.chatId.trim() : ''; + if (!botToken || !/^[\w-]+:[\w-]+$/.test(botToken)) return 'Enter a valid Telegram bot token'; + if (!chatId || chatId.length > 100) return 'Enter a valid Telegram chat ID'; + return { botToken, chatId }; + }, + maskConfig: (config) => ({ botToken: secretPreview(config.botToken), chatId: config.chatId, configSet: true }), + format(config, event) { + const lines = [ + `${escapeHtml(eventLabel(event.kind))}`, + `${escapeHtml(event.monitor?.name ?? event.title)}`, + event.monitor ? escapeHtml(event.monitor.url) : null, + event.body ? escapeHtml(event.body) : null, + event.statusCode !== null ? `Status: ${event.statusCode}` : null, + event.error ? `Error: ${escapeHtml(event.error)}` : null, + escapeHtml(event.at.toISOString()), + ].filter(Boolean); + return { + url: `https://api.telegram.org/bot${config.botToken}/sendMessage`, + headers: { 'Content-Type': 'application/json', 'User-Agent': 'Upwatch/1.0 (+notification)' }, + body: JSON.stringify({ chat_id: config.chatId, text: lines.join('\n'), parse_mode: 'HTML', disable_web_page_preview: true }), + }; + }, +}; diff --git a/src/worker/notifications/providers/types.ts b/src/worker/notifications/providers/types.ts new file mode 100644 index 0000000..a029fbf --- /dev/null +++ b/src/worker/notifications/providers/types.ts @@ -0,0 +1,62 @@ +import { isSafeRemoteUrl } from '../../lib/safe-url'; + +export const CHANNEL_TYPES = ['slack', 'discord', 'telegram', 'webhook'] as const; +export type ChannelType = (typeof CHANNEL_TYPES)[number]; + +export type UrlConfig = { url: string }; +export type TelegramConfig = { botToken: string; chatId: string }; +export type ChannelConfig = UrlConfig | TelegramConfig; + +export type NotificationEvent = { + kind: 'down' | 'recovered' | 'manual_opened' | 'manual_update' | 'test'; + monitor: { id: number; name: string; url: string } | null; + incidentId: number | null; + title: string; + body: string | null; + statusCode: number | null; + error: string | null; + at: Date; +}; + +export type OutboundRequest = { url: string; headers: Record; body: string }; + +export type Provider = { + parseConfig(value: unknown): TConfig | string; + maskConfig(config: TConfig): Record; + format(config: TConfig, event: NotificationEvent): OutboundRequest; +}; + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function parseSafeUrl(value: unknown): UrlConfig | string { + if (!isRecord(value) || typeof value.url !== 'string') return 'A URL is required'; + try { + const url = new URL(value.url.trim()); + if (!isSafeRemoteUrl(url)) throw new Error('unsafe URL'); + return { url: url.toString() }; + } catch { + return 'Enter a valid public http or https URL'; + } +} + +export function secretPreview(value: string) { + return `••••••${value.slice(-4)}`; +} + +export function eventLabel(kind: NotificationEvent['kind']) { + return { + down: 'Service down', + recovered: 'Service recovered', + manual_opened: 'Incident opened', + manual_update: 'Incident update', + test: 'Test notification', + }[kind]; +} + +export function eventColor(kind: NotificationEvent['kind']) { + if (kind === 'down' || kind === 'manual_opened') return '#dc2626'; + if (kind === 'recovered') return '#16a34a'; + return '#2563eb'; +} diff --git a/src/worker/notifications/providers/webhook.ts b/src/worker/notifications/providers/webhook.ts new file mode 100644 index 0000000..2204438 --- /dev/null +++ b/src/worker/notifications/providers/webhook.ts @@ -0,0 +1,24 @@ +import type { NotificationEvent, Provider, UrlConfig } from './types'; +import { parseSafeUrl, secretPreview } from './types'; + +export const webhookProvider: Provider = { + parseConfig: parseSafeUrl, + maskConfig: (config) => ({ url: secretPreview(config.url), configSet: true }), + format(config, event) { + return { + url: config.url, + headers: { 'Content-Type': 'application/json', 'User-Agent': 'Upwatch/1.0 (+incident webhook)' }, + body: JSON.stringify({ + event: event.kind === 'manual_opened' ? 'down' : event.kind === 'manual_update' ? 'down' : event.kind, + monitor: event.monitor ?? { id: 0, name: 'Upwatch test', url: 'https://example.com/health' }, + statusCode: event.statusCode, + error: event.error, + at: event.at.toISOString(), + }), + }; + }, +}; + +export function formatLegacyWebhook(config: UrlConfig, event: NotificationEvent) { + return webhookProvider.format(config, event); +} diff --git a/src/worker/notifications/webhook.ts b/src/worker/notifications/webhook.ts deleted file mode 100644 index 04bcea4..0000000 --- a/src/worker/notifications/webhook.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { eq } from 'drizzle-orm'; -import type { CheckResult, Monitor } from '../checks/run-check'; -import { getDb } from '../db/client'; -import { notificationSettings } from '../db/schema'; - -export type IncidentAlert = { - monitor: Monitor; - kind: 'opened' | 'resolved'; - result: CheckResult; - at: Date; -}; - -type WebhookPayload = { - event: 'down' | 'recovered' | 'test'; - monitor: { id: number; name: string; url: string }; - statusCode: number | null; - error: string | null; - at: string; -}; - -async function postWebhook(url: string, payload: WebhookPayload): Promise { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': 'Upwatch/1.0 (+incident webhook)', - }, - body: JSON.stringify(payload), - }); - await response.body?.cancel(); - return response.ok; -} - -export async function sendTestWebhook(url: string): Promise { - try { - return await postWebhook(url, { - event: 'test', - monitor: { id: 0, name: 'Upwatch test', url: 'https://example.com/health' }, - statusCode: 200, - error: null, - at: new Date().toISOString(), - }); - } catch (error) { - console.error( - JSON.stringify({ - message: 'test webhook failed', - error: error instanceof Error ? error.message : String(error), - }), - ); - return false; - } -} - -export async function sendIncidentAlert(env: Env, alert: IncidentAlert): Promise { - if (!alert.monitor.alertsEnabled) return false; - - try { - const [settings] = await getDb(env).select().from(notificationSettings).where(eq(notificationSettings.id, 1)).limit(1); - if (!settings?.webhookEnabled || !settings.webhookUrl) return false; - - const ok = await postWebhook(settings.webhookUrl, { - event: alert.kind === 'opened' ? 'down' : 'recovered', - monitor: { - id: alert.monitor.id, - name: alert.monitor.name, - url: alert.monitor.url, - }, - statusCode: alert.result.statusCode, - error: alert.result.error, - at: alert.at.toISOString(), - }); - if (!ok) { - console.warn( - JSON.stringify({ - message: 'incident webhook returned an error', - monitorId: alert.monitor.id, - }), - ); - } - return ok; - } catch (error) { - console.error( - JSON.stringify({ - message: 'incident webhook failed', - error: error instanceof Error ? error.message : String(error), - monitorId: alert.monitor.id, - }), - ); - return false; - } -} diff --git a/src/worker/routes/channels.ts b/src/worker/routes/channels.ts new file mode 100644 index 0000000..a466515 --- /dev/null +++ b/src/worker/routes/channels.ts @@ -0,0 +1,250 @@ +import { desc, eq, inArray } from 'drizzle-orm'; +import { Hono } from 'hono'; +import { getDb, type Database } from '../db/client'; +import { notificationChannelMonitors, notificationChannels, notificationDeliveries, monitors } from '../db/schema'; +import { requireAuth, type AuthVariables } from '../lib/require-auth'; +import { dispatchTest } from '../notifications/dispatch'; +import { CHANNEL_TYPES, maskChannelConfig, parseChannelConfig, type ChannelConfig, type ChannelType } from '../notifications/providers'; +import { parseInteger } from './monitors'; + +type ParsedChannelInput = { + name?: string; + type?: ChannelType; + config?: ChannelConfig; + enabled?: boolean; + notifyManual?: boolean; + monitorIds?: number[]; +}; +type ParseResult = { ok: true; value: ParsedChannelInput } | { ok: false; message: string }; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function parseId(raw: string) { + const parsed = parseInteger(Number(raw), 'id', 1, Number.MAX_SAFE_INTEGER); + return parsed.ok ? parsed.value : null; +} + +function isChannelType(value: unknown): value is ChannelType { + return typeof value === 'string' && CHANNEL_TYPES.some((type) => type === value); +} + +export function parseChannelInput(body: unknown, partial = false, currentType?: ChannelType): ParseResult { + if (!isRecord(body)) return { ok: false, message: 'Invalid request body' }; + const value: ParsedChannelInput = {}; + if (!partial || 'name' in body) { + if (typeof body.name !== 'string' || body.name.trim().length < 1 || body.name.trim().length > 100) { + return { ok: false, message: 'Name must be between 1 and 100 characters' }; + } + value.name = body.name.trim(); + } + if (!partial || 'type' in body) { + if (!isChannelType(body.type)) return { ok: false, message: 'type must be slack, discord, telegram, or webhook' }; + value.type = body.type; + } + const effectiveType = value.type ?? currentType; + if (!partial || 'config' in body || ('type' in body && body.type !== currentType)) { + if (!effectiveType) return { ok: false, message: 'A channel type is required' }; + const config = parseChannelConfig(effectiveType, body.config); + if (typeof config === 'string') return { ok: false, message: config }; + value.config = config; + } + for (const field of ['enabled', 'notifyManual'] as const) { + if (!partial || field in body) { + if (typeof body[field] !== 'boolean') return { ok: false, message: `${field} must be a boolean` }; + value[field] = body[field]; + } + } + if (!partial || 'monitorIds' in body) { + if (!Array.isArray(body.monitorIds) || body.monitorIds.some((id) => !Number.isSafeInteger(id) || id <= 0)) { + return { ok: false, message: 'monitorIds must be an array of positive integers' }; + } + value.monitorIds = [...new Set(body.monitorIds as number[])]; + } + return { ok: true, value }; +} + +async function allMonitorsExist(db: Database, monitorIds: number[]) { + if (monitorIds.length === 0) return true; + const rows = await db.select({ id: monitors.id }).from(monitors).where(inArray(monitors.id, monitorIds)); + return rows.length === monitorIds.length; +} + +function publicChannel(channel: typeof notificationChannels.$inferSelect, monitorIds: number[], lastDelivery: unknown) { + const type = channel.type as ChannelType; + let config: Record = { configSet: false }; + if (isChannelType(type)) { + try { + const parsed = parseChannelConfig(type, JSON.parse(channel.config) as unknown); + if (typeof parsed !== 'string') config = maskChannelConfig(type, parsed); + } catch { + config = { configSet: false }; + } + } + return { ...channel, type, config, monitorIds, lastDelivery }; +} + +const channelRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>(); +channelRoutes.use('*', requireAuth); + +channelRoutes.get('/', async (context) => { + const db = getDb(context.env); + const [channels, assignments] = await Promise.all([ + db.select().from(notificationChannels).orderBy(notificationChannels.createdAt), + db.select().from(notificationChannelMonitors), + ]); + const monitorIdsByChannel = new Map(); + for (const assignment of assignments) { + const ids = monitorIdsByChannel.get(assignment.channelId); + if (ids) ids.push(assignment.monitorId); + else monitorIdsByChannel.set(assignment.channelId, [assignment.monitorId]); + } + const lastDeliveries = await Promise.all( + channels.map(async (channel) => { + const [delivery] = await db + .select() + .from(notificationDeliveries) + .where(eq(notificationDeliveries.channelId, channel.id)) + .orderBy(desc(notificationDeliveries.createdAt)) + .limit(1); + return delivery ?? null; + }), + ); + return context.json({ + channels: channels.map((channel, index) => publicChannel(channel, monitorIdsByChannel.get(channel.id) ?? [], lastDeliveries[index])), + }); +}); + +channelRoutes.post('/', async (context) => { + let body: unknown; + try { + body = await context.req.json(); + } catch { + return context.json({ message: 'Invalid request body' }, 400); + } + const parsed = parseChannelInput(body); + if (!parsed.ok) return context.json({ message: parsed.message }, 400); + const db = getDb(context.env); + const monitorIds = parsed.value.monitorIds!; + if (!(await allMonitorsExist(db, monitorIds))) return context.json({ message: 'One or more monitors do not exist' }, 400); + const now = new Date(); + const [channel] = await db + .insert(notificationChannels) + .values({ + name: parsed.value.name!, + type: parsed.value.type!, + config: JSON.stringify(parsed.value.config), + enabled: parsed.value.enabled!, + notifyManual: parsed.value.notifyManual!, + createdAt: now, + updatedAt: now, + }) + .returning(); + if (monitorIds.length > 0) { + await db.batch( + monitorIds.map((monitorId) => db.insert(notificationChannelMonitors).values({ channelId: channel.id, monitorId })) as [ + ReturnType, + ...ReturnType[], + ], + ); + } + return context.json({ channel: publicChannel(channel, monitorIds, null) }, 201); +}); + +channelRoutes.patch('/:id', async (context) => { + const id = parseId(context.req.param('id')); + if (id === null) return context.json({ message: 'Notification channel not found' }, 404); + const db = getDb(context.env); + const [existing] = await db.select().from(notificationChannels).where(eq(notificationChannels.id, id)).limit(1); + if (!existing || !isChannelType(existing.type)) return context.json({ message: 'Notification channel not found' }, 404); + let body: unknown; + try { + body = await context.req.json(); + } catch { + return context.json({ message: 'Invalid request body' }, 400); + } + const parsed = parseChannelInput(body, true, existing.type); + if (!parsed.ok) return context.json({ message: parsed.message }, 400); + if (Object.keys(parsed.value).length === 0) return context.json({ message: 'Provide at least one field to update' }, 400); + if (parsed.value.monitorIds && !(await allMonitorsExist(db, parsed.value.monitorIds))) { + return context.json({ message: 'One or more monitors do not exist' }, 400); + } + const { monitorIds, config, ...changes } = parsed.value; + const statements: Parameters[0][number][] = [ + db + .update(notificationChannels) + .set({ ...changes, ...(config ? { config: JSON.stringify(config) } : {}), updatedAt: new Date() }) + .where(eq(notificationChannels.id, id)), + ]; + if (monitorIds) { + statements.push(db.delete(notificationChannelMonitors).where(eq(notificationChannelMonitors.channelId, id))); + statements.push(...monitorIds.map((monitorId) => db.insert(notificationChannelMonitors).values({ channelId: id, monitorId }))); + } + await db.batch(statements as [(typeof statements)[number], ...typeof statements]); + const [channel] = await db.select().from(notificationChannels).where(eq(notificationChannels.id, id)).limit(1); + const assignments = await db + .select({ monitorId: notificationChannelMonitors.monitorId }) + .from(notificationChannelMonitors) + .where(eq(notificationChannelMonitors.channelId, id)); + return context.json({ + channel: publicChannel( + channel, + assignments.map((row) => row.monitorId), + null, + ), + }); +}); + +channelRoutes.delete('/:id', async (context) => { + const id = parseId(context.req.param('id')); + if (id === null) return context.json({ message: 'Notification channel not found' }, 404); + const db = getDb(context.env); + const [channel] = await db + .select({ id: notificationChannels.id }) + .from(notificationChannels) + .where(eq(notificationChannels.id, id)) + .limit(1); + if (!channel) return context.json({ message: 'Notification channel not found' }, 404); + await db.batch([ + db.delete(notificationChannelMonitors).where(eq(notificationChannelMonitors.channelId, id)), + db.delete(notificationDeliveries).where(eq(notificationDeliveries.channelId, id)), + db.delete(notificationChannels).where(eq(notificationChannels.id, id)), + ]); + return context.json({ ok: true }); +}); + +channelRoutes.post('/:id/test', async (context) => { + const id = parseId(context.req.param('id')); + if (id === null) return context.json({ message: 'Notification channel not found' }, 404); + const result = await dispatchTest(context.env, id); + if (!result.ok) + return context.json( + { message: result.error ?? 'Notification delivery failed' }, + result.error === 'Notification channel not found' ? 404 : 502, + ); + return context.json({ ok: true }); +}); + +channelRoutes.get('/:id/deliveries', async (context) => { + const id = parseId(context.req.param('id')); + if (id === null) return context.json({ message: 'Notification channel not found' }, 404); + const rawLimit = Number(context.req.query('limit') ?? 20); + const limit = Number.isSafeInteger(rawLimit) ? Math.min(100, Math.max(1, rawLimit)) : 20; + const db = getDb(context.env); + const [channel] = await db + .select({ id: notificationChannels.id }) + .from(notificationChannels) + .where(eq(notificationChannels.id, id)) + .limit(1); + if (!channel) return context.json({ message: 'Notification channel not found' }, 404); + const deliveries = await db + .select() + .from(notificationDeliveries) + .where(eq(notificationDeliveries.channelId, id)) + .orderBy(desc(notificationDeliveries.createdAt)) + .limit(limit); + return context.json({ deliveries }); +}); + +export default channelRoutes; diff --git a/src/worker/routes/incidents.ts b/src/worker/routes/incidents.ts index 4653d44..6625aec 100644 --- a/src/worker/routes/incidents.ts +++ b/src/worker/routes/incidents.ts @@ -4,6 +4,7 @@ import { IncidentDraftError, draftIncidentUpdate, type IncidentStatus } from '.. import { getDb, type Database } from '../db/client'; import { incidentMonitors, incidents, incidentUpdates, monitors } from '../db/schema'; import { requireAuth, type AuthVariables } from '../lib/require-auth'; +import { dispatchNotification } from '../notifications/dispatch'; import { parseInteger } from './monitors'; const STATUSES = new Set(['investigating', 'identified', 'monitoring', 'resolved']); @@ -227,6 +228,18 @@ incidentRoutes.post('/', async (context) => { const results = await context.env.DB.batch(statements); const id = Number(results[0].meta.last_row_id); const incident = await loadIncident(db, id); + context.executionCtx.waitUntil( + dispatchNotification(context.env, { + kind: 'manual_opened', + monitor: null, + incidentId: id, + title: parsed.value.title!, + body: parsed.value.body!, + statusCode: null, + error: null, + at: new Date(now), + }), + ); return context.json({ incident }, 201); }); @@ -328,6 +341,18 @@ incidentRoutes.post('/:id/updates', async (context) => { }) .where(eq(incidents.id, id)), ]); + context.executionCtx.waitUntil( + dispatchNotification(context.env, { + kind: resolved ? 'recovered' : 'manual_update', + monitor: null, + incidentId: id, + title: existing.title ?? `Incident ${id}`, + body: parsed.value.body, + statusCode: null, + error: null, + at: now, + }), + ); return context.json({ incident: await loadIncident(db, id) }); }); diff --git a/src/worker/routes/monitors.ts b/src/worker/routes/monitors.ts index 943ec6a..4dc1560 100644 --- a/src/worker/routes/monitors.ts +++ b/src/worker/routes/monitors.ts @@ -8,7 +8,7 @@ import { checks, incidentMonitors, incidents, maintenanceWindowMonitors, monitor import { requireAuth, type AuthVariables } from '../lib/require-auth'; import { loadActiveMaintenance } from '../maintenance/windows'; import { isSafeRemoteUrl } from '../lib/safe-url'; -import { sendIncidentAlert } from '../notifications/webhook'; +import { dispatchNotification } from '../notifications/dispatch'; type MonitorMethod = 'GET' | 'HEAD' | 'POST'; @@ -576,7 +576,16 @@ monitorRoutes.post('/:id/check', async (context) => { const { statements, transition } = buildResultStatements(db, monitor, result, checkedAt, activeMaintenance.has(monitor.id)); await db.batch(statements as [(typeof statements)[number], ...typeof statements]); if (transition === 'opened' || transition === 'resolved') { - await sendIncidentAlert(context.env, { monitor, kind: transition, result, at: checkedAt }); + await dispatchNotification(context.env, { + monitor: { id: monitor.id, name: monitor.name, url: monitor.url }, + kind: transition === 'opened' ? 'down' : 'recovered', + incidentId: null, + title: transition === 'opened' ? `${monitor.name} is down` : `${monitor.name} recovered`, + body: result.error, + statusCode: result.statusCode, + error: result.error, + at: checkedAt, + }); if (transition === 'opened') { await generateIncidentMessage(context.env, { monitor, result }); } diff --git a/src/worker/routes/settings.ts b/src/worker/routes/settings.ts index bb47a9b..a681bce 100644 --- a/src/worker/routes/settings.ts +++ b/src/worker/routes/settings.ts @@ -4,15 +4,9 @@ import { requestCompletion } from '../ai/client'; import { SAMPLE_INCIDENT_CONTEXT } from '../ai/incident-context'; import { INCIDENT_MESSAGE_SYSTEM_PROMPT, sanitizeIncidentMessage } from '../ai/incident-message'; import { getDb } from '../db/client'; -import { aiSettings, notificationSettings } from '../db/schema'; +import { aiSettings } from '../db/schema'; import { requireAuth, type AuthVariables } from '../lib/require-auth'; import { isSafeRemoteUrl } from '../lib/safe-url'; -import { sendTestWebhook } from '../notifications/webhook'; - -type NotificationInput = { - webhookUrl: string | null; - webhookEnabled: boolean; -}; type AiInput = { enabled: boolean; @@ -21,30 +15,6 @@ type AiInput = { apiKey?: string; }; -function parseNotificationInput(value: unknown): NotificationInput | string { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return 'Invalid request body'; - } - const body = value as Record; - if (typeof body.webhookEnabled !== 'boolean') { - return 'webhookEnabled must be a boolean'; - } - const rawUrl = typeof body.webhookUrl === 'string' ? body.webhookUrl.trim() : body.webhookUrl; - if (rawUrl !== null && typeof rawUrl !== 'string') return 'webhookUrl must be a URL or null'; - let webhookUrl = rawUrl || null; - if (webhookUrl) { - try { - const url = new URL(webhookUrl); - if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('protocol'); - webhookUrl = url.toString(); - } catch { - return 'Enter a valid http or https webhook URL'; - } - } - if (body.webhookEnabled && !webhookUrl) return 'A webhook URL is required when alerts are enabled'; - return { webhookUrl, webhookEnabled: body.webhookEnabled }; -} - function parseAiInput(value: unknown): AiInput | string { if (typeof value !== 'object' || value === null || Array.isArray(value)) { return 'Invalid request body'; @@ -95,44 +65,6 @@ function publicAiSettings(settings: typeof aiSettings.$inferSelect | undefined) const settingsRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>(); settingsRoutes.use('*', requireAuth); -settingsRoutes.get('/notifications', async (context) => { - const [settings] = await getDb(context.env).select().from(notificationSettings).where(eq(notificationSettings.id, 1)).limit(1); - return context.json({ - settings: settings ?? { id: 1, webhookUrl: null, webhookEnabled: false, createdAt: null, updatedAt: null }, - }); -}); - -settingsRoutes.put('/notifications', async (context) => { - let body: unknown; - try { - body = await context.req.json(); - } catch { - return context.json({ message: 'Invalid request body' }, 400); - } - const input = parseNotificationInput(body); - if (typeof input === 'string') return context.json({ message: input }, 400); - - const db = getDb(context.env); - const now = new Date(); - const [settings] = await db - .insert(notificationSettings) - .values({ id: 1, ...input, createdAt: now, updatedAt: now }) - .onConflictDoUpdate({ - target: notificationSettings.id, - set: { ...input, updatedAt: now }, - }) - .returning(); - return context.json({ settings }); -}); - -settingsRoutes.post('/notifications/test', async (context) => { - const [settings] = await getDb(context.env).select().from(notificationSettings).where(eq(notificationSettings.id, 1)).limit(1); - if (!settings?.webhookUrl) return context.json({ message: 'Save a webhook URL first' }, 400); - const delivered = await sendTestWebhook(settings.webhookUrl); - if (!delivered) return context.json({ message: 'Webhook delivery failed' }, 502); - return context.json({ ok: true }); -}); - settingsRoutes.get('/ai', async (context) => { const [settings] = await getDb(context.env).select().from(aiSettings).where(eq(aiSettings.id, 1)).limit(1); return context.json({ settings: publicAiSettings(settings) }); diff --git a/src/worker/scheduled/cleanup.ts b/src/worker/scheduled/cleanup.ts index 3842b6a..7a7909d 100644 --- a/src/worker/scheduled/cleanup.ts +++ b/src/worker/scheduled/cleanup.ts @@ -1,10 +1,11 @@ import { lt } from 'drizzle-orm'; import { getDb } from '../db/client'; -import { checks, loginAttempts, monitorDailyStats, sessions } from '../db/schema'; +import { checks, loginAttempts, monitorDailyStats, notificationDeliveries, sessions } from '../db/schema'; const LOGIN_ATTEMPT_RETENTION_MS = 60 * 60 * 1000; const CHECK_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; const DAILY_STATS_RETENTION_MS = 400 * 24 * 60 * 60 * 1000; +const DELIVERY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; export async function cleanupExpiredAuthRecords(env: Env) { const db = getDb(env); @@ -15,5 +16,6 @@ export async function cleanupExpiredAuthRecords(env: Env) { db.delete(loginAttempts).where(lt(loginAttempts.attemptedAt, new Date(now.getTime() - LOGIN_ATTEMPT_RETENTION_MS))), db.delete(checks).where(lt(checks.checkedAt, new Date(now.getTime() - CHECK_RETENTION_MS))), db.delete(monitorDailyStats).where(lt(monitorDailyStats.day, new Date(now.getTime() - DAILY_STATS_RETENTION_MS))), + db.delete(notificationDeliveries).where(lt(notificationDeliveries.createdAt, new Date(now.getTime() - DELIVERY_RETENTION_MS))), ]); } diff --git a/test/ai.spec.ts b/test/ai.spec.ts index ede29c7..05258f7 100644 --- a/test/ai.spec.ts +++ b/test/ai.spec.ts @@ -42,7 +42,9 @@ async function resetDatabase() { env.DB.prepare('DELETE FROM incidents'), env.DB.prepare('DELETE FROM monitor_daily_stats'), env.DB.prepare('DELETE FROM ai_settings'), - env.DB.prepare('DELETE FROM notification_settings'), + env.DB.prepare('DELETE FROM notification_deliveries'), + env.DB.prepare('DELETE FROM notification_channel_monitors'), + env.DB.prepare('DELETE FROM notification_channels'), env.DB.prepare('DELETE FROM monitors'), env.DB.prepare('DELETE FROM login_attempts'), env.DB.prepare('DELETE FROM sessions'), diff --git a/test/channels.spec.ts b/test/channels.spec.ts new file mode 100644 index 0000000..e0b37d2 --- /dev/null +++ b/test/channels.spec.ts @@ -0,0 +1,142 @@ +import { applyD1Migrations, type D1Migration } from 'cloudflare:test'; +import { env } from 'cloudflare:workers'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { dispatchNotification } from '../src/worker/notifications/dispatch'; +import { discordProvider, slackProvider, telegramProvider, webhookProvider } from '../src/worker/notifications/providers'; +import { parseChannelInput } from '../src/worker/routes/channels'; + +const event = { + kind: 'down' as const, + monitor: { id: 7, name: 'API ', url: 'https://api.example.com' }, + incidentId: null, + title: 'API is down', + body: 'Requests are failing.', + statusCode: 500, + error: 'Expected <200>', + at: new Date('2026-08-28T03:25:00Z'), +}; + +async function insertChannel(name: string, config: string, overrides = '') { + const now = Date.now(); + const result = await env.DB.prepare( + `INSERT INTO notification_channels (name, type, config, enabled, notify_manual, created_at, updated_at) + VALUES (?, 'webhook', ?, 1, 1, ?, ?) ${overrides}`, + ) + .bind(name, config, now, now) + .run(); + return Number(result.meta.last_row_id); +} + +describe('notification channels', () => { + beforeAll(async () => { + const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] }; + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); + }); + beforeEach(async () => { + await env.DB.batch([ + env.DB.prepare('DELETE FROM notification_deliveries'), + env.DB.prepare('DELETE FROM notification_channel_monitors'), + env.DB.prepare('DELETE FROM notification_channels'), + env.DB.prepare('DELETE FROM incident_monitors'), + env.DB.prepare('DELETE FROM incidents'), + env.DB.prepare('DELETE FROM monitors'), + ]); + const now = Date.now(); + await env.DB.prepare( + `INSERT INTO monitors (id, name, url, method, expected_status, interval_seconds, timeout_ms, enabled, alerts_enabled, + retry_count, failure_threshold, consecutive_failures, last_ok, created_at, updated_at) + VALUES (7, 'API', 'https://api.example.com', 'GET', 200, 300, 10000, 1, 1, 0, 1, 0, 1, ?, ?)`, + ) + .bind(now, now) + .run(); + }); + afterEach(() => vi.unstubAllGlobals()); + + it('formats provider-specific payloads and preserves the raw webhook payload', () => { + const webhook = webhookProvider.format({ url: 'https://hooks.example.com' }, event); + expect(JSON.parse(webhook.body)).toEqual({ + event: 'down', + monitor: event.monitor, + statusCode: 500, + error: 'Expected <200>', + at: '2026-08-28T03:25:00.000Z', + }); + const testWebhook = webhookProvider.format( + { url: 'https://hooks.example.com' }, + { ...event, kind: 'test', monitor: null, incidentId: null, title: 'Upwatch test', body: null, statusCode: 200, error: null }, + ); + expect(JSON.parse(testWebhook.body).monitor).toEqual({ id: 0, name: 'Upwatch test', url: 'https://example.com/health' }); + expect(JSON.parse(slackProvider.format({ url: 'https://hooks.slack.com/test' }, event).body).attachments[0].color).toBe('#dc2626'); + expect(JSON.parse(discordProvider.format({ url: 'https://discord.com/api/webhooks/test' }, event).body).embeds[0]).toMatchObject({ + color: 14427686, + timestamp: '2026-08-28T03:25:00.000Z', + }); + const telegram = telegramProvider.format({ botToken: '123:abc', chatId: '-10' }, event); + expect(telegram.url).toBe('https://api.telegram.org/bot123:abc/sendMessage'); + expect(JSON.parse(telegram.body).text).toContain('API <prod>'); + }); + + it('rejects private channel URLs and unknown providers', () => { + expect( + parseChannelInput({ + name: 'Local', + type: 'webhook', + config: { url: 'http://127.0.0.1/x' }, + enabled: true, + notifyManual: true, + monitorIds: [], + }), + ).toMatchObject({ ok: false }); + expect( + parseChannelInput({ name: 'Unknown', type: 'email', config: {}, enabled: true, notifyManual: true, monitorIds: [] }), + ).toMatchObject({ ok: false }); + }); + + it('fans out, records delivery, and retries a server failure once', async () => { + await insertChannel('All services', '{"url":"https://hooks.example.test/events"}'); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 500 })) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + vi.stubGlobal('fetch', fetchMock); + await dispatchNotification(env, event); + expect(fetchMock).toHaveBeenCalledTimes(2); + const delivery = await env.DB.prepare('SELECT ok, status_code, attempts FROM notification_deliveries').first<{ + ok: number; + status_code: number; + attempts: number; + }>(); + expect(delivery).toEqual({ ok: 1, status_code: 204, attempts: 2 }); + }); + + it('does not retry a 400 response', async () => { + await insertChannel('All services', '{"url":"https://hooks.example.test/events"}'); + const fetchMock = vi.fn(async () => new Response(null, { status: 400 })); + vi.stubGlobal('fetch', fetchMock); + await dispatchNotification(env, event); + expect(fetchMock).toHaveBeenCalledTimes(1); + const delivery = await env.DB.prepare('SELECT ok, error, attempts FROM notification_deliveries').first(); + expect(delivery).toEqual({ ok: 0, error: 'HTTP 400', attempts: 1 }); + }); + + it('honors monitor assignments and alertsEnabled', async () => { + const channelId = await insertChannel('Other service', '{"url":"https://hooks.example.test/events"}'); + const now = Date.now(); + await env.DB.prepare( + `INSERT INTO monitors (id, name, url, method, expected_status, interval_seconds, timeout_ms, enabled, alerts_enabled, + retry_count, failure_threshold, consecutive_failures, created_at, updated_at) + VALUES (8, 'Web', 'https://web.example.com', 'GET', 200, 300, 10000, 1, 1, 0, 1, 0, ?, ?)`, + ) + .bind(now, now) + .run(); + await env.DB.prepare('INSERT INTO notification_channel_monitors (channel_id, monitor_id) VALUES (?, 8)').bind(channelId).run(); + const fetchMock = vi.fn(async () => new Response(null, { status: 204 })); + vi.stubGlobal('fetch', fetchMock); + await dispatchNotification(env, event); + expect(fetchMock).not.toHaveBeenCalled(); + await env.DB.prepare('UPDATE monitors SET alerts_enabled = 0 WHERE id = 7').run(); + await env.DB.prepare('DELETE FROM notification_channel_monitors').run(); + await dispatchNotification(env, event); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/test/checks.spec.ts b/test/checks.spec.ts index 12d7eba..2063862 100644 --- a/test/checks.spec.ts +++ b/test/checks.spec.ts @@ -13,7 +13,9 @@ async function clearMonitoringTables() { env.DB.prepare('DELETE FROM incidents'), env.DB.prepare('DELETE FROM monitor_daily_stats'), env.DB.prepare('DELETE FROM ai_settings'), - env.DB.prepare('DELETE FROM notification_settings'), + env.DB.prepare('DELETE FROM notification_deliveries'), + env.DB.prepare('DELETE FROM notification_channel_monitors'), + env.DB.prepare('DELETE FROM notification_channels'), env.DB.prepare('DELETE FROM monitors'), ]); } @@ -225,7 +227,8 @@ describe('scheduled monitor checks', () => { const now = Date.now(); await env.DB.batch([ env.DB.prepare( - "INSERT INTO notification_settings (id, webhook_url, webhook_enabled, created_at, updated_at) VALUES (1, 'https://hooks.example.test/events', 1, ?, ?)", + `INSERT INTO notification_channels (name, type, config, enabled, notify_manual, created_at, updated_at) + VALUES ('Legacy webhook', 'webhook', '{"url":"https://hooks.example.test/events"}', 1, 1, ?, ?)`, ).bind(now, now), env.DB.prepare( "INSERT INTO ai_settings (id, enabled, base_url, api_key, model, created_at, updated_at) VALUES (1, 1, 'https://ai.example.test/v1', 'secret', 'test-model', ?, ?)", diff --git a/test/incidents.spec.ts b/test/incidents.spec.ts index 586125c..41ea8d0 100644 --- a/test/incidents.spec.ts +++ b/test/incidents.spec.ts @@ -6,6 +6,9 @@ import { hashPassword } from '../src/worker/lib/password'; const PASSWORD = 'correct-horse-battery-staple'; async function reset() { await env.DB.batch([ + env.DB.prepare('DELETE FROM notification_deliveries'), + env.DB.prepare('DELETE FROM notification_channel_monitors'), + env.DB.prepare('DELETE FROM notification_channels'), env.DB.prepare('DELETE FROM incident_updates'), env.DB.prepare('DELETE FROM incident_monitors'), env.DB.prepare('DELETE FROM incidents'), diff --git a/test/maintenance.spec.ts b/test/maintenance.spec.ts index 098deaa..6d32813 100644 --- a/test/maintenance.spec.ts +++ b/test/maintenance.spec.ts @@ -28,7 +28,9 @@ async function resetDatabase() { env.DB.prepare('DELETE FROM incident_monitors'), env.DB.prepare('DELETE FROM incidents'), env.DB.prepare('DELETE FROM monitor_daily_stats'), - env.DB.prepare('DELETE FROM notification_settings'), + env.DB.prepare('DELETE FROM notification_deliveries'), + env.DB.prepare('DELETE FROM notification_channel_monitors'), + env.DB.prepare('DELETE FROM notification_channels'), env.DB.prepare('DELETE FROM monitors'), ]); } diff --git a/test/monitors.spec.ts b/test/monitors.spec.ts index 9818664..8526bf4 100644 --- a/test/monitors.spec.ts +++ b/test/monitors.spec.ts @@ -21,7 +21,9 @@ async function seedAdmin() { env.DB.prepare('DELETE FROM incident_monitors'), env.DB.prepare('DELETE FROM incidents'), env.DB.prepare('DELETE FROM monitor_daily_stats'), - env.DB.prepare('DELETE FROM notification_settings'), + env.DB.prepare('DELETE FROM notification_deliveries'), + env.DB.prepare('DELETE FROM notification_channel_monitors'), + env.DB.prepare('DELETE FROM notification_channels'), env.DB.prepare('DELETE FROM monitors'), env.DB.prepare('DELETE FROM login_attempts'), env.DB.prepare('DELETE FROM sessions'), diff --git a/test/status.spec.ts b/test/status.spec.ts index 8c0bc87..b46de1d 100644 --- a/test/status.spec.ts +++ b/test/status.spec.ts @@ -26,7 +26,9 @@ async function resetDatabase() { env.DB.prepare('DELETE FROM checks'), env.DB.prepare('DELETE FROM incidents'), env.DB.prepare('DELETE FROM monitor_daily_stats'), - env.DB.prepare('DELETE FROM notification_settings'), + env.DB.prepare('DELETE FROM notification_deliveries'), + env.DB.prepare('DELETE FROM notification_channel_monitors'), + env.DB.prepare('DELETE FROM notification_channels'), env.DB.prepare('DELETE FROM monitors'), env.DB.prepare('DELETE FROM login_attempts'), env.DB.prepare('DELETE FROM sessions'), diff --git a/test/webhook.spec.ts b/test/webhook.spec.ts deleted file mode 100644 index 551b3dc..0000000 --- a/test/webhook.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { applyD1Migrations, type D1Migration } from 'cloudflare:test'; -import { env } from 'cloudflare:workers'; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { sendIncidentAlert } from '../src/worker/notifications/webhook'; -import type { Monitor } from '../src/worker/checks/run-check'; - -describe('incident webhooks', () => { - beforeAll(async () => { - const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] }; - await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); - }); - beforeEach(async () => { - await env.DB.prepare('DELETE FROM notification_settings').run(); - const now = Date.now(); - await env.DB.prepare( - "INSERT INTO notification_settings (id, webhook_url, webhook_enabled, created_at, updated_at) VALUES (1, 'https://hooks.example.test/events', 1, ?, ?)", - ) - .bind(now, now) - .run(); - }); - afterEach(() => vi.unstubAllGlobals()); - - const monitor = { - id: 7, - name: 'API', - url: 'https://api.example.com', - method: 'GET', - expectedStatus: 200, - intervalSeconds: 300, - timeoutMs: 10000, - enabled: true, - alertsEnabled: true, - retryCount: 1, - failureThreshold: 2, - consecutiveFailures: 0, - lastOk: true, - lastStatusCode: 200, - lastLatencyMs: 30, - lastError: null, - lastCheckedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - } satisfies Monitor; - - it('sends the compact down payload', async () => { - const fetchMock = vi.fn(async () => new Response(null, { status: 204 })); - vi.stubGlobal('fetch', fetchMock); - const at = new Date('2026-08-28T03:25:00Z'); - expect( - await sendIncidentAlert(env, { monitor, kind: 'opened', result: { ok: false, statusCode: 500, latencyMs: 42, error: 'Down' }, at }), - ).toBe(true); - const [, init] = fetchMock.mock.calls[0]; - expect(JSON.parse(String(init?.body))).toEqual({ - event: 'down', - monitor: { id: 7, name: 'API', url: 'https://api.example.com' }, - statusCode: 500, - error: 'Down', - at: '2026-08-28T03:25:00.000Z', - }); - }); - - it('swallows webhook network failures', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => { - throw new Error('network unavailable'); - }), - ); - await expect( - sendIncidentAlert(env, { - monitor, - kind: 'resolved', - result: { ok: true, statusCode: 200, latencyMs: 20, error: null }, - at: new Date(), - }), - ).resolves.toBe(false); - }); -});