diff --git a/migrations/0011_hard_mystique.sql b/migrations/0011_hard_mystique.sql new file mode 100644 index 0000000..29b1432 --- /dev/null +++ b/migrations/0011_hard_mystique.sql @@ -0,0 +1,4 @@ +CREATE INDEX `checks_checked_at_idx` ON `checks` (`checked_at`);--> statement-breakpoint +CREATE INDEX `login_attempts_attempted_at_idx` ON `login_attempts` (`attempted_at`);--> statement-breakpoint +CREATE INDEX `monitor_daily_stats_day_idx` ON `monitor_daily_stats` (`day`);--> statement-breakpoint +CREATE INDEX `notification_deliveries_created_at_idx` ON `notification_deliveries` (`created_at`); \ No newline at end of file diff --git a/migrations/meta/0011_snapshot.json b/migrations/meta/0011_snapshot.json new file mode 100644 index 0000000..b833e53 --- /dev/null +++ b/migrations/meta/0011_snapshot.json @@ -0,0 +1,1492 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "063be0b8-c825-40c1-9c7a-d11dbef74f03", + "prevId": "3722898d-5812-4cc0-9466-aea6cb2ffc57", + "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_events": { + "name": "ai_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "kind": { + "name": "kind", + "type": "text", + "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 + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "context_preview": { + "name": "context_preview", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_preview": { + "name": "output_preview", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ai_events_created_at_idx": { + "name": "ai_events_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ai_events_incident_id_incidents_id_fk": { + "name": "ai_events_incident_id_incidents_id_fk", + "tableFrom": "ai_events", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ai_events_monitor_id_monitors_id_fk": { + "name": "ai_events_monitor_id_monitors_id_fk", + "tableFrom": "ai_events", + "tableTo": "monitors", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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 + }, + "autopilot_enabled": { + "name": "autopilot_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "autopilot_followup_minutes": { + "name": "autopilot_followup_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 15 + }, + "autopilot_max_updates": { + "name": "autopilot_max_updates", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 6 + }, + "autopilot_advance_status": { + "name": "autopilot_advance_status", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "autopilot_degraded_incidents": { + "name": "autopilot_degraded_incidents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 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 + }, + "degraded": { + "name": "degraded", + "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 + }, + "checks_checked_at_idx": { + "name": "checks_checked_at_idx", + "columns": [ + "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'" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'down'" + }, + "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 + }, + "login_attempts_attempted_at_idx": { + "name": "login_attempts_attempted_at_idx", + "columns": [ + "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 + }, + "monitor_daily_stats_day_idx": { + "name": "monitor_daily_stats_day_idx", + "columns": [ + "day" + ], + "isUnique": false + } + }, + "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 + }, + "expect_keyword": { + "name": "expect_keyword", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keyword_inverted": { + "name": "keyword_inverted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "request_headers": { + "name": "request_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_body": { + "name": "request_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "degraded_latency_ms": { + "name": "degraded_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 + }, + "consecutive_slow": { + "name": "consecutive_slow", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_ok": { + "name": "last_ok", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_degraded": { + "name": "last_degraded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 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 + }, + "notification_deliveries_created_at_idx": { + "name": "notification_deliveries_created_at_idx", + "columns": [ + "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 0ab1107..ef4a75c 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1788183346274, "tag": "0010_slippery_mattie_franklin", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1788187390249, + "tag": "0011_hard_mystique", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/client/components/StatusHistoryBar.tsx b/src/client/components/StatusHistoryBar.tsx index 299712e..e6b6506 100644 --- a/src/client/components/StatusHistoryBar.tsx +++ b/src/client/components/StatusHistoryBar.tsx @@ -1,12 +1,7 @@ import type { PublicService } from '../api/status'; -import { useMediaQuery } from '../lib/useMediaQuery'; const DAY_MS = 24 * 60 * 60 * 1000; -// Desktop mirrors the API's full 90-day history. Compact screens use the most -// recent 45 days so each daily bar remains legible. const HISTORY_DAYS = 90; -const HISTORY_DAYS_COMPACT = 45; -const COMPACT_QUERY = '(max-width: 520px)'; type HistoryEntry = PublicService['history'][number]; @@ -32,25 +27,23 @@ function dayTitle(day: number, uptimePct: number | null | undefined) { } export function StatusHistoryBar({ history }: { history: HistoryEntry[] }) { - const compact = useMediaQuery(COMPACT_QUERY); - const windowDays = compact ? HISTORY_DAYS_COMPACT : HISTORY_DAYS; const historyByDay = new Map(history.map((entry) => [entry.day, entry.uptimePct])); const now = new Date(); const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); - const days = Array.from({ length: windowDays }, (_day, index) => { - const day = today - (windowDays - 1 - index) * DAY_MS; + const days = Array.from({ length: HISTORY_DAYS }, (_day, index) => { + const day = today - (HISTORY_DAYS - 1 - index) * DAY_MS; return { day, uptimePct: historyByDay.get(day) }; }); return (
-
+
{days.map(({ day, uptimePct }) => ( ))}
diff --git a/src/worker/autopilot/index.ts b/src/worker/autopilot/index.ts index 8f43258..0eee4ff 100644 --- a/src/worker/autopilot/index.ts +++ b/src/worker/autopilot/index.ts @@ -14,11 +14,12 @@ import type { CheckResult, Monitor } from '../checks/run-check'; import { getDb } from '../db/client'; import { aiSettings, incidentUpdates, incidents } from '../db/schema'; import { humanizeDuration } from '../lib/humanize'; +import { resolveRunLimits } from '../lib/runtime-config'; import { advanceStatus, computeImpact, nextFollowupDueAt, type AutopilotIncidentStatus } from './cadence'; import { loadIncidentSignal, findLatestAutoIncidentForMonitor } from './signal'; -export const MAX_AI_CALLS_PER_RUN = 12; -export const MAX_FOLLOWUP_CALLS_PER_RUN = 6; +// Per-pass ceilings come from resolveRunLimits(env): AI_CALLS_PER_RUN / AI_FOLLOWUP_CALLS_PER_RUN, +// defaulting to DEFAULT_RUN_LIMITS. They keep one autopilot pass within the free-plan subrequest budget. export const AUTOPILOT_CONCURRENCY = 4; export const AUTOPILOT_DEADLINE_MS = 45_000; @@ -215,6 +216,7 @@ async function processFollowup(env: Env, settings: Settings, incident: typeof in async function loadFollowupTasks(env: Env, settings: Settings, excluded: Set): Promise { const db = getDb(env); + const maxFollowups = resolveRunLimits(env).aiFollowupCallsPerRun; const rows = await db .select({ incident: incidents, @@ -264,7 +266,7 @@ async function loadFollowupTasks(env: Env, settings: Settings, excluded: Set processFollowup(env, settings, row.incident), }); - if (tasks.length >= MAX_FOLLOWUP_CALLS_PER_RUN) break; + if (tasks.length >= maxFollowups) break; } return tasks; } @@ -277,7 +279,7 @@ export async function runAutopilot( const db = getDb(env); const [settings] = await db.select().from(aiSettings).where(eq(aiSettings.id, 1)).limit(1); if (!settings?.enabled || !settings.autopilotEnabled || !settings.baseUrl || !settings.apiKey || !settings.model) return summary; - const budget = input.budget ?? { remaining: MAX_AI_CALLS_PER_RUN, deadline: Date.now() + AUTOPILOT_DEADLINE_MS }; + const budget = input.budget ?? { remaining: resolveRunLimits(env).aiCallsPerRun, deadline: Date.now() + AUTOPILOT_DEADLINE_MS }; budget.deadline ??= Date.now() + AUTOPILOT_DEADLINE_MS; const tasks: Task[] = []; const excluded = new Set(); diff --git a/src/worker/checks/run-check.ts b/src/worker/checks/run-check.ts index 506a376..b516a04 100644 --- a/src/worker/checks/run-check.ts +++ b/src/worker/checks/run-check.ts @@ -11,7 +11,11 @@ export type CheckResult = { error: string | null; }; -export const MAX_BODY_MATCH_BYTES = 256 * 1024; +// Keyword matching decodes and lowercases this many bytes of the response body on the hot +// path. 64 KiB covers page titles, health-check payloads and status JSON while keeping the +// per-check CPU cost small — the free plan allows only 10 ms of CPU per invocation and one +// scheduled run decodes bodies for every keyword monitor. +export const MAX_BODY_MATCH_BYTES = 64 * 1024; function requestHeaders(monitor: Monitor) { let configured: Record = {}; diff --git a/src/worker/checks/run-due-checks.ts b/src/worker/checks/run-due-checks.ts index 2d172e3..4515a58 100644 --- a/src/worker/checks/run-due-checks.ts +++ b/src/worker/checks/run-due-checks.ts @@ -2,16 +2,18 @@ import { and, eq, sql } from 'drizzle-orm'; import type { AutopilotEvent } from '../autopilot'; import { getDb } from '../db/client'; import { aiSettings, monitors } from '../db/schema'; +import { DEFAULT_RUN_LIMITS, resolveRunLimits } from '../lib/runtime-config'; import { loadActiveMaintenance } from '../maintenance/windows'; import { buildAlertEvent } from '../notifications/compose'; -import { dispatchNotification, MAX_NOTIFICATIONS_PER_RUN, type NotificationBudget } from '../notifications/dispatch'; +import { dispatchNotification, type NotificationBudget } from '../notifications/dispatch'; import { buildResultStatements } from './persist-result'; import { runCheck, runCheckWithRetries, type RetryBudget } from './run-check'; const MAX_MONITORS_PER_RUN = 40; const CONCURRENCY = 10; const MAX_BATCH_STATEMENTS = 100; -export const MAX_RETRY_ATTEMPTS_PER_RUN = 60; +/** Default retry ceiling for one scheduled run; override with the RETRY_ATTEMPTS_PER_RUN env var. */ +export const MAX_RETRY_ATTEMPTS_PER_RUN = DEFAULT_RUN_LIMITS.retryAttemptsPerRun; const RETRY_DEADLINE_MS = 90_000; export type DueCheckSummary = { @@ -40,11 +42,12 @@ export async function runDueChecks(env: Env, ctx?: Pick 0) await db.batch(statementChunk as [(typeof statementChunk)[number], ...typeof statementChunk]); - const notificationBudget: NotificationBudget = { remaining: MAX_NOTIFICATIONS_PER_RUN }; + const notificationBudget: NotificationBudget = { remaining: limits.notificationsPerRun }; const notifications = persisted.flatMap((item) => { const work: Promise[] = []; if (item.transition === 'opened' || item.transition === 'resolved') { diff --git a/src/worker/db/schema.ts b/src/worker/db/schema.ts index 89573e7..4947e41 100644 --- a/src/worker/db/schema.ts +++ b/src/worker/db/schema.ts @@ -40,7 +40,11 @@ export const loginAttempts = sqliteTable( ipAddress: text('ip_address').notNull(), attemptedAt: integer('attempted_at', { mode: 'timestamp_ms' }).notNull(), }, - (table) => [index('login_attempts_ip_attempted_at_idx').on(table.ipAddress, table.attemptedAt)], + (table) => [ + index('login_attempts_ip_attempted_at_idx').on(table.ipAddress, table.attemptedAt), + // Retention cleanup filters on attempted_at alone; the composite index above cannot serve it. + index('login_attempts_attempted_at_idx').on(table.attemptedAt), + ], ); export const monitors = sqliteTable( @@ -128,7 +132,12 @@ export const checks = sqliteTable( maintenance: integer('maintenance', { mode: 'boolean' }).notNull().default(false), degraded: integer('degraded', { mode: 'boolean' }).notNull().default(false), }, - (table) => [index('checks_monitor_id_checked_at_idx').on(table.monitorId, table.checkedAt)], + (table) => [ + index('checks_monitor_id_checked_at_idx').on(table.monitorId, table.checkedAt), + // Daily rollup and retention cleanup scan by checked_at across every monitor; without this + // index those become full-table scans, which dominate D1 "rows read" at scale. + index('checks_checked_at_idx').on(table.checkedAt), + ], ); export const incidents = sqliteTable( @@ -247,7 +256,11 @@ export const notificationDeliveries = sqliteTable( 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)], + (table) => [ + index('notification_deliveries_channel_id_created_at_idx').on(table.channelId, table.createdAt), + // Retention cleanup filters on created_at alone; the composite index above cannot serve it. + index('notification_deliveries_created_at_idx').on(table.createdAt), + ], ); export const monitorDailyStats = sqliteTable( @@ -264,5 +277,9 @@ export const monitorDailyStats = sqliteTable( minLatencyMs: integer('min_latency_ms'), maxLatencyMs: integer('max_latency_ms'), }, - (table) => [uniqueIndex('monitor_daily_stats_monitor_id_day_uidx').on(table.monitorId, table.day)], + (table) => [ + uniqueIndex('monitor_daily_stats_monitor_id_day_uidx').on(table.monitorId, table.day), + // Retention cleanup filters on day alone; the composite unique index above cannot serve it. + index('monitor_daily_stats_day_idx').on(table.day), + ], ); diff --git a/src/worker/index.ts b/src/worker/index.ts index 7cf92e2..47a1dd6 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -9,7 +9,7 @@ import maintenanceRoutes from './routes/maintenance'; import monitorRoutes from './routes/monitors'; import settingsRoutes from './routes/settings'; import statusRoutes from './routes/status'; -import { cleanupExpiredAuthRecords } from './scheduled/cleanup'; +import { cleanupExpiredAuthRecords, cleanupStaleData } from './scheduled/cleanup'; import { runDailyRollup } from './scheduled/rollup'; const app = new Hono<{ Bindings: Env }>(); @@ -41,6 +41,7 @@ export default { async scheduled(controller, env, ctx) { if (controller.cron === '5 0 * * *') { const result = await runDailyRollup(env, new Date(controller.scheduledTime)); + await cleanupStaleData(env); console.log( JSON.stringify({ message: 'daily rollup completed', diff --git a/src/worker/lib/runtime-config.ts b/src/worker/lib/runtime-config.ts new file mode 100644 index 0000000..ea69548 --- /dev/null +++ b/src/worker/lib/runtime-config.ts @@ -0,0 +1,53 @@ +/** + * Environment-tunable runtime knobs. + * + * Cloudflare's free plan caps a Worker invocation at 50 subrequests and 10 ms CPU, and the + * 5-minute cron runs checks, retries, notifications and AI autopilot inside a single + * invocation. The defaults below keep a healthy run well under that ceiling and make a large + * correlated outage shed the least-critical work first (AI, then retries) instead of failing + * mid-batch. Raise them with same-named environment variables on the Workers Paid plan, where + * the ceiling is 1,000 subrequests and 5 minutes of CPU. + */ + +export type RunLimits = { + /** Immediate check retries attempted across one scheduled run, shared by every monitor. */ + retryAttemptsPerRun: number; + /** Alert deliveries attempted across one scheduled run. */ + notificationsPerRun: number; + /** AI completion calls attempted across one autopilot pass. */ + aiCallsPerRun: number; + /** AI follow-up updates queued across one autopilot pass. */ + aiFollowupCallsPerRun: number; +}; + +export const DEFAULT_RUN_LIMITS: RunLimits = { + retryAttemptsPerRun: 15, + notificationsPerRun: 12, + aiCallsPerRun: 4, + aiFollowupCallsPerRun: 2, +}; + +/** Seconds the public status responses are held in the edge cache. 0 disables edge caching. */ +export const DEFAULT_STATUS_CACHE_SECONDS = 30; + +type EnvVars = Record; + +function boundedInt(raw: string | undefined, fallback: number, minimum: number): number { + if (raw === undefined) return fallback; + const value = Number(raw); + return Number.isInteger(value) && value >= minimum ? value : fallback; +} + +export function resolveRunLimits(env: Env): RunLimits { + const vars = env as unknown as EnvVars; + return { + retryAttemptsPerRun: boundedInt(vars.RETRY_ATTEMPTS_PER_RUN, DEFAULT_RUN_LIMITS.retryAttemptsPerRun, 1), + notificationsPerRun: boundedInt(vars.NOTIFICATIONS_PER_RUN, DEFAULT_RUN_LIMITS.notificationsPerRun, 1), + aiCallsPerRun: boundedInt(vars.AI_CALLS_PER_RUN, DEFAULT_RUN_LIMITS.aiCallsPerRun, 1), + aiFollowupCallsPerRun: boundedInt(vars.AI_FOLLOWUP_CALLS_PER_RUN, DEFAULT_RUN_LIMITS.aiFollowupCallsPerRun, 1), + }; +} + +export function resolveStatusCacheSeconds(env: Env): number { + return boundedInt((env as unknown as EnvVars).STATUS_CACHE_SECONDS, DEFAULT_STATUS_CACHE_SECONDS, 0); +} diff --git a/src/worker/routes/status.ts b/src/worker/routes/status.ts index 54f8476..6f66cd5 100644 --- a/src/worker/routes/status.ts +++ b/src/worker/routes/status.ts @@ -1,8 +1,9 @@ import { and, desc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm'; -import { Hono } from 'hono'; +import { Hono, type Context } from 'hono'; import { DEGRADED_MESSAGE, deterministicIncidentMessage } from '../ai/fallback-message'; import { getDb } from '../db/client'; import { checks, incidentMonitors, incidents, incidentUpdates, monitorDailyStats, monitors } from '../db/schema'; +import { resolveStatusCacheSeconds } from '../lib/runtime-config'; import { loadActiveMaintenance, type ActiveMaintenance } from '../maintenance/windows'; import { resolveFavicon } from './monitors'; @@ -111,9 +112,58 @@ const incidentSelection = { startStatusCode: incidents.startStatusCode, }; +function edgeCache(): EdgeCache | null { + try { + return (caches as CacheStorage & { readonly default: EdgeCache }).default; + } catch { + return null; + } +} + +// Key on origin + path only. These responses do not vary by query string, so ignoring it also +// stops `?x=1`, `?x=2`, … spray from bypassing the cache and hammering D1 on every request. +function statusCacheKey(context: Context<{ Bindings: Env }>): Request { + const url = new URL(context.req.url); + return new Request(`${url.origin}${url.pathname}`); +} + +/** + * The public status endpoints are polled by every open status-page tab every 60s. Serving them + * from the edge cache collapses that traffic to one origin computation per window and keeps the + * `checks` / `monitor_daily_stats` scans they run off D1's free-tier read budget. Set the + * STATUS_CACHE_SECONDS env var to 0 to disable. + */ +async function cachedStatusResponse(context: Context<{ Bindings: Env }>): Promise { + if (resolveStatusCacheSeconds(context.env) <= 0) return undefined; + const cache = edgeCache(); + if (!cache) return undefined; + try { + return await cache.match(statusCacheKey(context)); + } catch { + return undefined; + } +} + +function jsonWithEdgeCache(context: Context<{ Bindings: Env }>, body: unknown): Response { + const seconds = resolveStatusCacheSeconds(context.env); + if (seconds <= 0) return Response.json(body); + const response = Response.json(body, { headers: { 'Cache-Control': `public, max-age=${seconds}` } }); + const cache = edgeCache(); + if (cache) { + try { + context.executionCtx.waitUntil(cache.put(statusCacheKey(context), response.clone()).catch(() => undefined)); + } catch { + // No ExecutionContext available (e.g. unit tests): serve without populating the edge cache. + } + } + return response; +} + const statusRoutes = new Hono<{ Bindings: Env }>(); statusRoutes.get('/', async (context) => { + const cached = await cachedStatusResponse(context); + if (cached) return cached; const db = getDb(context.env); const monitorRows = await db .select({ @@ -230,7 +280,7 @@ statusRoutes.get('/', async (context) => { latestUpdate: latestUpdates.get(incident.id) ?? null, services: incidentServices.get(incident.id) ?? [], })); - return context.json({ + return jsonWithEdgeCache(context, { overall: overallStatus( services.map((service) => service.status), activeIncidentRows.filter((incident) => incident.source === 'manual').map((incident) => incident.impact), @@ -242,6 +292,8 @@ statusRoutes.get('/', async (context) => { }); statusRoutes.get('/incidents', async (context) => { + const cached = await cachedStatusResponse(context); + if (cached) return cached; const db = getDb(context.env); const limit = parseLimit(context.req.query('limit'), 20, 20); const rows = await db @@ -252,7 +304,7 @@ statusRoutes.get('/incidents', async (context) => { .limit(limit); const incidentIds = rows.map((row) => row.id); const [services, latestUpdates] = await Promise.all([loadServices(db, incidentIds), loadLatestUpdates(db, incidentIds)]); - return context.json({ + return jsonWithEdgeCache(context, { incidents: rows.map((incident) => ({ id: incident.id, title: publicIncidentTitle(incident), diff --git a/src/worker/scheduled/cleanup.ts b/src/worker/scheduled/cleanup.ts index efe9493..dc5efbb 100644 --- a/src/worker/scheduled/cleanup.ts +++ b/src/worker/scheduled/cleanup.ts @@ -8,6 +8,11 @@ const DAILY_STATS_RETENTION_MS = 400 * 24 * 60 * 60 * 1000; const DELIVERY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; export const AI_EVENT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +/** + * Cheap, high-frequency cleanup for the every-5-minute cron. Only touches the auth tables, + * which stay tiny and are indexed on the column filtered here, so each delete is a bounded + * range scan. Security-sensitive (session expiry, login rate limiting) so it must stay fresh. + */ export async function cleanupExpiredAuthRecords(env: Env) { const db = getDb(env); const now = new Date(); @@ -15,6 +20,23 @@ export async function cleanupExpiredAuthRecords(env: Env) { await db.batch([ db.delete(sessions).where(lt(sessions.expiresAt, now)), db.delete(loginAttempts).where(lt(loginAttempts.attemptedAt, new Date(now.getTime() - LOGIN_ATTEMPT_RETENTION_MS))), + ]); +} + +/** + * Retention pruning for the high-volume tables. Runs once per day from the rollup cron. + * + * At 20 monitors on 5-minute checks the `checks` table alone holds ~40k rows; running these + * deletes every 5 minutes would scan the whole table each time and blow past D1's free-tier + * "rows read" budget (~5M/day). Each column filtered below is backed by a single-column index + * so the delete only scans the expiring slice, and running daily keeps that scan to ~one day + * of rows. + */ +export async function cleanupStaleData(env: Env) { + const db = getDb(env); + const now = new Date(); + + await db.batch([ 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/autopilot.spec.ts b/test/autopilot.spec.ts index 0428bd2..a95677f 100644 --- a/test/autopilot.spec.ts +++ b/test/autopilot.spec.ts @@ -4,7 +4,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { advanceStatus, computeImpact, nextFollowupDueAt } from '../src/worker/autopilot/cadence'; import { recordAiEvent } from '../src/worker/ai/events'; import { sanitizePublicTextWithReason } from '../src/worker/ai/sanitize'; -import { AI_EVENT_RETENTION_MS, cleanupExpiredAuthRecords } from '../src/worker/scheduled/cleanup'; +import { AI_EVENT_RETENTION_MS, cleanupStaleData } from '../src/worker/scheduled/cleanup'; describe('incident autopilot', () => { beforeAll(async () => { @@ -58,7 +58,7 @@ describe('incident autopilot', () => { await env.DB.prepare("INSERT INTO ai_events (kind, outcome, created_at) VALUES ('settings_test', 'ok', ?)") .bind(Date.now() - AI_EVENT_RETENTION_MS - 1) .run(); - await cleanupExpiredAuthRecords(env); + await cleanupStaleData(env); expect((await env.DB.prepare('SELECT count(*) AS count FROM ai_events').first<{ count: number }>())?.count).toBe(0); }); diff --git a/test/cleanup.spec.ts b/test/cleanup.spec.ts new file mode 100644 index 0000000..ef6aa41 --- /dev/null +++ b/test/cleanup.spec.ts @@ -0,0 +1,122 @@ +import { applyD1Migrations, type D1Migration } from 'cloudflare:test'; +import { env } from 'cloudflare:workers'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { AI_EVENT_RETENTION_MS, cleanupExpiredAuthRecords, cleanupStaleData } from '../src/worker/scheduled/cleanup'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +async function count(table: string): Promise { + return (await env.DB.prepare(`SELECT count(*) AS count FROM ${table}`).first<{ count: number }>())?.count ?? 0; +} + +async function insertMonitor(): Promise { + const now = Date.now(); + const result = await env.DB.prepare( + `INSERT INTO monitors (name, url, method, expected_status, interval_seconds, timeout_ms, enabled, alerts_enabled, created_at, updated_at) + VALUES ('Example', 'https://example.com', 'GET', 200, 300, 10000, 1, 1, ?, ?)`, + ) + .bind(now, now) + .run(); + return Number(result.meta.last_row_id); +} + +describe('scheduled cleanup', () => { + 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 ai_events'), + env.DB.prepare('DELETE FROM notification_deliveries'), + env.DB.prepare('DELETE FROM notification_channels'), + env.DB.prepare('DELETE FROM monitor_daily_stats'), + env.DB.prepare('DELETE FROM checks'), + env.DB.prepare('DELETE FROM monitors'), + env.DB.prepare('DELETE FROM sessions'), + env.DB.prepare('DELETE FROM login_attempts'), + ]); + }); + + describe('cleanupExpiredAuthRecords', () => { + it('removes expired sessions and stale login attempts but leaves fresh ones', async () => { + const now = Date.now(); + await env.DB.batch([ + env.DB.prepare("INSERT INTO sessions (id, expires_at, created_at) VALUES ('expired', ?, ?)").bind(now - 1000, now - DAY_MS), + env.DB.prepare("INSERT INTO sessions (id, expires_at, created_at) VALUES ('active', ?, ?)").bind(now + DAY_MS, now), + env.DB.prepare('INSERT INTO login_attempts (ip_address, attempted_at) VALUES (?, ?)').bind('1.1.1.1', now - 2 * 60 * 60 * 1000), + env.DB.prepare('INSERT INTO login_attempts (ip_address, attempted_at) VALUES (?, ?)').bind('2.2.2.2', now - 60 * 1000), + ]); + + await cleanupExpiredAuthRecords(env); + + expect(await count('sessions')).toBe(1); + expect((await env.DB.prepare('SELECT id FROM sessions').first<{ id: string }>())?.id).toBe('active'); + expect(await count('login_attempts')).toBe(1); + }); + + it('does not touch high-volume retention tables', async () => { + const monitorId = await insertMonitor(); + await env.DB.prepare('INSERT INTO checks (monitor_id, ok, latency_ms, checked_at) VALUES (?, 1, 100, ?)') + .bind(monitorId, Date.now() - 30 * DAY_MS) + .run(); + + await cleanupExpiredAuthRecords(env); + + expect(await count('checks')).toBe(1); + }); + }); + + describe('cleanupStaleData', () => { + it('prunes only rows past each retention window', async () => { + const now = Date.now(); + const monitorId = await insertMonitor(); + const channel = await env.DB.prepare( + "INSERT INTO notification_channels (name, type, config, enabled, created_at, updated_at) VALUES ('c', 'webhook', '{}', 1, ?, ?)", + ) + .bind(now, now) + .run(); + const channelId = Number(channel.meta.last_row_id); + + await env.DB.batch([ + // checks: 7-day retention + env.DB.prepare('INSERT INTO checks (monitor_id, ok, latency_ms, checked_at) VALUES (?, 1, 100, ?)').bind( + monitorId, + now - 8 * DAY_MS, + ), + env.DB.prepare('INSERT INTO checks (monitor_id, ok, latency_ms, checked_at) VALUES (?, 1, 100, ?)').bind( + monitorId, + now - 1 * DAY_MS, + ), + // monitor_daily_stats: 400-day retention + env.DB.prepare('INSERT INTO monitor_daily_stats (monitor_id, day, total_checks, up_checks) VALUES (?, ?, 1, 1)').bind( + monitorId, + now - 401 * DAY_MS, + ), + env.DB.prepare('INSERT INTO monitor_daily_stats (monitor_id, day, total_checks, up_checks) VALUES (?, ?, 1, 1)').bind( + monitorId, + now - 10 * DAY_MS, + ), + // notification_deliveries: 30-day retention + env.DB.prepare( + "INSERT INTO notification_deliveries (channel_id, event, ok, attempts, created_at) VALUES (?, 'down', 1, 1, ?)", + ).bind(channelId, now - 31 * DAY_MS), + env.DB.prepare( + "INSERT INTO notification_deliveries (channel_id, event, ok, attempts, created_at) VALUES (?, 'down', 1, 1, ?)", + ).bind(channelId, now - 5 * DAY_MS), + // ai_events: 30-day retention + env.DB.prepare("INSERT INTO ai_events (kind, outcome, created_at) VALUES ('settings_test', 'ok', ?)").bind( + now - AI_EVENT_RETENTION_MS - 1, + ), + env.DB.prepare("INSERT INTO ai_events (kind, outcome, created_at) VALUES ('settings_test', 'ok', ?)").bind(now - DAY_MS), + ]); + + await cleanupStaleData(env); + + expect(await count('checks')).toBe(1); + expect(await count('monitor_daily_stats')).toBe(1); + expect(await count('notification_deliveries')).toBe(1); + expect(await count('ai_events')).toBe(1); + }); + }); +}); diff --git a/test/runtime-config.spec.ts b/test/runtime-config.spec.ts new file mode 100644 index 0000000..a4645f6 --- /dev/null +++ b/test/runtime-config.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_RUN_LIMITS, + DEFAULT_STATUS_CACHE_SECONDS, + resolveRunLimits, + resolveStatusCacheSeconds, +} from '../src/worker/lib/runtime-config'; + +const asEnv = (vars: Record) => vars as unknown as Env; + +describe('resolveRunLimits', () => { + it('returns the free-plan defaults when nothing is configured', () => { + expect(resolveRunLimits(asEnv({}))).toEqual(DEFAULT_RUN_LIMITS); + }); + + it('applies same-named environment overrides', () => { + expect( + resolveRunLimits( + asEnv({ + RETRY_ATTEMPTS_PER_RUN: '60', + NOTIFICATIONS_PER_RUN: '40', + AI_CALLS_PER_RUN: '12', + AI_FOLLOWUP_CALLS_PER_RUN: '6', + }), + ), + ).toEqual({ retryAttemptsPerRun: 60, notificationsPerRun: 40, aiCallsPerRun: 12, aiFollowupCallsPerRun: 6 }); + }); + + it('falls back to the default for non-positive or non-integer values', () => { + expect(resolveRunLimits(asEnv({ RETRY_ATTEMPTS_PER_RUN: '0' })).retryAttemptsPerRun).toBe(DEFAULT_RUN_LIMITS.retryAttemptsPerRun); + expect(resolveRunLimits(asEnv({ NOTIFICATIONS_PER_RUN: '-5' })).notificationsPerRun).toBe(DEFAULT_RUN_LIMITS.notificationsPerRun); + expect(resolveRunLimits(asEnv({ AI_CALLS_PER_RUN: 'lots' })).aiCallsPerRun).toBe(DEFAULT_RUN_LIMITS.aiCallsPerRun); + expect(resolveRunLimits(asEnv({ AI_FOLLOWUP_CALLS_PER_RUN: '2.5' })).aiFollowupCallsPerRun).toBe( + DEFAULT_RUN_LIMITS.aiFollowupCallsPerRun, + ); + }); +}); + +describe('resolveStatusCacheSeconds', () => { + it('defaults to DEFAULT_STATUS_CACHE_SECONDS', () => { + expect(resolveStatusCacheSeconds(asEnv({}))).toBe(DEFAULT_STATUS_CACHE_SECONDS); + }); + + it('accepts 0 to disable edge caching', () => { + expect(resolveStatusCacheSeconds(asEnv({ STATUS_CACHE_SECONDS: '0' }))).toBe(0); + }); + + it('accepts a positive override and rejects invalid values', () => { + expect(resolveStatusCacheSeconds(asEnv({ STATUS_CACHE_SECONDS: '120' }))).toBe(120); + expect(resolveStatusCacheSeconds(asEnv({ STATUS_CACHE_SECONDS: '-1' }))).toBe(DEFAULT_STATUS_CACHE_SECONDS); + expect(resolveStatusCacheSeconds(asEnv({ STATUS_CACHE_SECONDS: 'nope' }))).toBe(DEFAULT_STATUS_CACHE_SECONDS); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index b10fb1e..06bf129 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -8,7 +8,9 @@ export default defineConfig({ cloudflareTest({ wrangler: { configPath: './wrangler.jsonc' }, miniflare: { - bindings: { TEST_MIGRATIONS: migrations }, + // STATUS_CACHE_SECONDS '0' disables the public-status edge cache so assertions see + // fresh D1 reads instead of a response cached by an earlier test. + bindings: { TEST_MIGRATIONS: migrations, STATUS_CACHE_SECONDS: '0' }, }, }), ],