mirror of
https://github.com/Nezumi-2711/uptime-monitoring.git
synced 2026-09-22 05:41:59 +00:00
feat: add incident and post history for services
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
CREATE TABLE `incident_monitors` (
|
||||
`incident_id` integer NOT NULL,
|
||||
`monitor_id` integer NOT NULL,
|
||||
PRIMARY KEY(`incident_id`, `monitor_id`),
|
||||
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 `incident_monitors_monitor_id_idx` ON `incident_monitors` (`monitor_id`);--> statement-breakpoint
|
||||
CREATE TABLE `incident_updates` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`incident_id` integer NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`body` text NOT NULL,
|
||||
`note` text,
|
||||
`source` text DEFAULT 'manual' NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`incident_id`) REFERENCES `incidents`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `incident_updates_incident_id_created_at_idx` ON `incident_updates` (`incident_id`,`created_at`);--> statement-breakpoint
|
||||
INSERT INTO `incident_monitors` (`incident_id`, `monitor_id`)
|
||||
SELECT `id`, `monitor_id` FROM `incidents`;--> statement-breakpoint
|
||||
INSERT INTO `incident_updates` (`incident_id`, `status`, `body`, `source`, `created_at`)
|
||||
SELECT `id`, CASE WHEN `resolved_at` IS NULL THEN 'investigating' ELSE 'resolved' END,
|
||||
`ai_message`, 'ai', `created_at`
|
||||
FROM `incidents` WHERE `ai_message` IS NOT NULL;--> statement-breakpoint
|
||||
PRAGMA defer_foreign_keys = true;--> statement-breakpoint
|
||||
CREATE TABLE `__new_incidents` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`title` text,
|
||||
`status` text DEFAULT 'investigating' NOT NULL,
|
||||
`impact` text DEFAULT 'major' NOT NULL,
|
||||
`source` text DEFAULT 'auto' NOT NULL,
|
||||
`started_at` integer NOT NULL,
|
||||
`resolved_at` integer,
|
||||
`start_status_code` integer,
|
||||
`start_error` text,
|
||||
`duration_ms` integer,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_incidents`("id", "title", "status", "impact", "source", "started_at", "resolved_at", "start_status_code", "start_error", "duration_ms", "created_at", "updated_at")
|
||||
SELECT "id", NULL, CASE WHEN "resolved_at" IS NULL THEN 'investigating' ELSE 'resolved' END,
|
||||
'major', 'auto', "started_at", "resolved_at", "start_status_code", "start_error", "duration_ms", "created_at", "updated_at"
|
||||
FROM `incidents`;--> statement-breakpoint
|
||||
DROP TABLE `incidents`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_incidents` RENAME TO `incidents`;--> statement-breakpoint
|
||||
CREATE INDEX `incidents_started_at_idx` ON `incidents` (`started_at`);--> statement-breakpoint
|
||||
CREATE INDEX `incidents_resolved_at_idx` ON `incidents` (`resolved_at`);
|
||||
@@ -0,0 +1,969 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "6e3277e0-8fe4-4619-8cb4-52c7c5185416",
|
||||
"prevId": "bf7ffa06-45b1-43d1-ba9a-193681f05bf2",
|
||||
"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
|
||||
},
|
||||
"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_settings": {
|
||||
"name": "notification_settings",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"webhook_url": {
|
||||
"name": "webhook_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"webhook_enabled": {
|
||||
"name": "webhook_enabled",
|
||||
"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": {}
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,13 @@
|
||||
"when": 1788007515959,
|
||||
"tag": "0005_pink_trish_tilby",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1788012122846,
|
||||
"tag": "0006_gifted_deathstrike",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+13
-8
@@ -3,6 +3,7 @@ import { RequireAuth } from './components/RequireAuth';
|
||||
import { usePathname } from './lib/router';
|
||||
|
||||
const DashboardPage = lazy(() => import('./pages/DashboardPage').then((module) => ({ default: module.DashboardPage })));
|
||||
const IncidentDetailPage = lazy(() => import('./pages/IncidentDetailPage').then((module) => ({ default: module.IncidentDetailPage })));
|
||||
const LoginPage = lazy(() => import('./pages/LoginPage').then((module) => ({ default: module.LoginPage })));
|
||||
const MonitorDetailPage = lazy(() => import('./pages/MonitorDetailPage').then((module) => ({ default: module.MonitorDetailPage })));
|
||||
const SettingsPage = lazy(() => import('./pages/SettingsPage').then((module) => ({ default: module.SettingsPage })));
|
||||
@@ -22,15 +23,19 @@ function App() {
|
||||
if (pathname === '/') content = <StatusPage />;
|
||||
else if (pathname === '/login') content = <LoginPage />;
|
||||
else {
|
||||
const incidentMatch = pathname.match(/^\/incidents\/(\d+)\/?$/);
|
||||
const monitorMatch = pathname.match(/^\/monitors\/(\d+)\/?$/);
|
||||
const page = monitorMatch ? (
|
||||
<MonitorDetailPage id={Number(monitorMatch[1])} />
|
||||
) : pathname === '/settings' ? (
|
||||
<SettingsPage />
|
||||
) : (
|
||||
<DashboardPage />
|
||||
);
|
||||
content = <RequireAuth>{page}</RequireAuth>;
|
||||
if (incidentMatch) content = <IncidentDetailPage id={Number(incidentMatch[1])} />;
|
||||
else {
|
||||
const page = monitorMatch ? (
|
||||
<MonitorDetailPage id={Number(monitorMatch[1])} />
|
||||
) : pathname === '/settings' ? (
|
||||
<SettingsPage />
|
||||
) : (
|
||||
<DashboardPage />
|
||||
);
|
||||
content = <RequireAuth>{page}</RequireAuth>;
|
||||
}
|
||||
}
|
||||
|
||||
return <Suspense fallback={<PageFallback />}>{content}</Suspense>;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { deleteJson, getJson, patchJson, postJson } from './http';
|
||||
|
||||
export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved';
|
||||
export type IncidentImpact = 'none' | 'minor' | 'major' | 'critical';
|
||||
export type IncidentSource = 'auto' | 'manual';
|
||||
|
||||
export type IncidentUpdate = {
|
||||
id: number;
|
||||
incidentId: number;
|
||||
status: IncidentStatus;
|
||||
body: string;
|
||||
note: string | null;
|
||||
source: 'manual' | 'ai' | 'system';
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type Incident = {
|
||||
id: number;
|
||||
title: string | null;
|
||||
status: IncidentStatus;
|
||||
impact: IncidentImpact;
|
||||
source: IncidentSource;
|
||||
startedAt: string;
|
||||
resolvedAt: string | null;
|
||||
startStatusCode: number | null;
|
||||
startError: string | null;
|
||||
durationMs: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
monitorIds: number[];
|
||||
updates?: IncidentUpdate[];
|
||||
updateCount?: number;
|
||||
};
|
||||
|
||||
export type IncidentInput = {
|
||||
title: string;
|
||||
status: IncidentStatus;
|
||||
impact: IncidentImpact;
|
||||
body: string;
|
||||
note?: string | null;
|
||||
monitorIds: number[];
|
||||
};
|
||||
export type IncidentUpdateInput = { status: IncidentStatus; body: string; note?: string | null };
|
||||
|
||||
export function listIncidents(status: 'open' | 'all' = 'open', signal?: AbortSignal) {
|
||||
return getJson<{ incidents: Incident[] }>(`/api/incidents?status=${status}`, { signal, credentials: 'same-origin' });
|
||||
}
|
||||
export function getIncident(id: number, signal?: AbortSignal) {
|
||||
return getJson<{ incident: Incident }>(`/api/incidents/${id}`, { signal, credentials: 'same-origin' });
|
||||
}
|
||||
export function createIncident(input: IncidentInput) {
|
||||
return postJson<{ incident: Incident }>('/api/incidents', input);
|
||||
}
|
||||
export function updateIncident(id: number, input: Partial<Pick<IncidentInput, 'title' | 'impact' | 'monitorIds'>>) {
|
||||
return patchJson<{ incident: Incident }>(`/api/incidents/${id}`, input);
|
||||
}
|
||||
export function postIncidentUpdate(id: number, input: IncidentUpdateInput) {
|
||||
return postJson<{ incident: Incident }>(`/api/incidents/${id}/updates`, input);
|
||||
}
|
||||
export function deleteIncident(id: number) {
|
||||
return deleteJson<{ ok: true }>(`/api/incidents/${id}`);
|
||||
}
|
||||
export function draftIncident(input: { note: string; status: IncidentStatus; monitorIds: number[] }) {
|
||||
return postJson<{ title: string; body: string }>('/api/incidents/draft', input);
|
||||
}
|
||||
export function draftIncidentUpdate(id: number, input: { note: string; status: IncidentStatus }) {
|
||||
return postJson<{ body: string }>(`/api/incidents/${id}/updates/draft`, input);
|
||||
}
|
||||
@@ -47,12 +47,15 @@ export type Check = CheckResult & {
|
||||
|
||||
export type Incident = {
|
||||
id: number;
|
||||
monitorId: number;
|
||||
title: string | null;
|
||||
status: string;
|
||||
impact: string;
|
||||
source: string;
|
||||
startedAt: string;
|
||||
resolvedAt: string | null;
|
||||
startStatusCode: number | null;
|
||||
startError: string | null;
|
||||
aiMessage: string | null;
|
||||
latestUpdate: { body: string; status: string; createdAt: number } | null;
|
||||
durationMs: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -3,6 +3,21 @@ import { getJson } from './http';
|
||||
export type PublicServiceStatus = 'up' | 'down' | 'unknown' | 'maintenance';
|
||||
export type PublicOverallStatus = 'operational' | 'degraded' | 'down';
|
||||
|
||||
export type PublicIncidentUpdate = { status: string; body: string; createdAt: string };
|
||||
export type PublicIncident = {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
impact: string;
|
||||
source: string;
|
||||
startedAt: string;
|
||||
resolvedAt?: string | null;
|
||||
durationMs?: number | null;
|
||||
latestUpdate?: PublicIncidentUpdate | null;
|
||||
services: Array<{ id: number; name: string }>;
|
||||
updates?: PublicIncidentUpdate[];
|
||||
};
|
||||
|
||||
export type PublicService = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -21,8 +36,17 @@ export type PublicStatus = {
|
||||
overall: PublicOverallStatus;
|
||||
updatedAt: number;
|
||||
services: PublicService[];
|
||||
activeIncidents: PublicIncident[];
|
||||
};
|
||||
|
||||
export function getStatus(signal?: AbortSignal) {
|
||||
return getJson<PublicStatus>('/api/status', { signal });
|
||||
}
|
||||
|
||||
export function getIncidentHistory(signal?: AbortSignal) {
|
||||
return getJson<{ incidents: PublicIncident[] }>('/api/status/incidents', { signal });
|
||||
}
|
||||
|
||||
export function getPublicIncident(id: number, signal?: AbortSignal) {
|
||||
return getJson<{ incident: PublicIncident }>(`/api/status/incidents/${id}`, { signal });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { PublicIncidentUpdate } from '../api/status';
|
||||
|
||||
const timestamp = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
|
||||
export function IncidentTimeline({ updates }: { updates: PublicIncidentUpdate[] }) {
|
||||
return (
|
||||
<ol className="incident-timeline">
|
||||
{updates.map((update, index) => (
|
||||
<li key={`${update.createdAt}-${index}`}>
|
||||
<span className="incident-timeline-dot" aria-hidden="true" />
|
||||
<div>
|
||||
<header>
|
||||
<strong>{update.status}</strong>
|
||||
<time dateTime={update.createdAt}>{timestamp.format(new Date(update.createdAt))}</time>
|
||||
</header>
|
||||
<p>{update.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ApiError } from '../../api/http';
|
||||
import { navigate } from '../../lib/router';
|
||||
import { useAiSettingsQuery } from '../../queries/settings';
|
||||
|
||||
export function AiComposeField({
|
||||
note,
|
||||
body,
|
||||
onNoteChange,
|
||||
onBodyChange,
|
||||
onGenerate,
|
||||
isPending,
|
||||
error,
|
||||
generated,
|
||||
}: {
|
||||
note: string;
|
||||
body: string;
|
||||
onNoteChange(value: string): void;
|
||||
onBodyChange(value: string): void;
|
||||
onGenerate(): void;
|
||||
isPending: boolean;
|
||||
error: unknown;
|
||||
generated: boolean;
|
||||
}) {
|
||||
const settings = useAiSettingsQuery();
|
||||
const enabled =
|
||||
settings.data?.settings.enabled && settings.data.settings.apiKeySet && settings.data.settings.baseUrl && settings.data.settings.model;
|
||||
return (
|
||||
<div className="ai-compose-field">
|
||||
<div className="ai-compose-pane">
|
||||
<div className="ai-compose-pane-header">
|
||||
<label htmlFor="incident-note">Internal note</label>
|
||||
<small>Admin only</small>
|
||||
</div>
|
||||
<textarea
|
||||
id="incident-note"
|
||||
value={note}
|
||||
onChange={(event) => onNoteChange(event.target.value)}
|
||||
maxLength={1000}
|
||||
placeholder="redis full memory, scaling capacity"
|
||||
/>
|
||||
{enabled ? (
|
||||
<Button
|
||||
variant="unstyled"
|
||||
className="secondary-button ai-compose-button"
|
||||
type="button"
|
||||
onClick={onGenerate}
|
||||
disabled={isPending || !note.trim()}
|
||||
>
|
||||
<Sparkles className={isPending ? 'is-spinning' : ''} />{' '}
|
||||
{isPending ? 'Composing…' : generated ? 'Generate again' : 'Compose public update'}
|
||||
</Button>
|
||||
) : settings.isPending ? null : (
|
||||
<p className="ai-compose-hint">
|
||||
AI composition is unavailable.{' '}
|
||||
<button type="button" onClick={() => navigate('/settings')}>
|
||||
Configure it in Settings
|
||||
</button>
|
||||
, or write below.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="ai-compose-pane">
|
||||
<div className="ai-compose-pane-header">
|
||||
<label htmlFor="incident-public-body">Public update</label>
|
||||
<small>Customer-facing</small>
|
||||
</div>
|
||||
<textarea
|
||||
id="incident-public-body"
|
||||
value={body}
|
||||
onChange={(event) => onBodyChange(event.target.value)}
|
||||
maxLength={2000}
|
||||
required
|
||||
aria-busy={isPending}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="form-error" role="alert">
|
||||
{error instanceof ApiError && error.status === 422
|
||||
? 'AI could not create a safe update. Edit the note or write the update manually.'
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'Unable to compose update'}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { type FormEvent, useState } from 'react';
|
||||
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 type { IncidentImpact, IncidentStatus } from '../../api/incidents';
|
||||
import { useCreateIncidentMutation, useDraftIncidentMutation } from '../../queries/incidents';
|
||||
import { useMonitorsQuery } from '../../queries/monitors';
|
||||
import { AiComposeField } from './AiComposeField';
|
||||
import { INCIDENT_IMPACTS, INCIDENT_STATUSES, IncidentImpactOption, IncidentStatusOption } from './IncidentSelectOption';
|
||||
|
||||
export function IncidentDialog({ onClose }: { onClose(): void }) {
|
||||
const monitors = useMonitorsQuery();
|
||||
const create = useCreateIncidentMutation();
|
||||
const draft = useDraftIncidentMutation();
|
||||
const [form, setForm] = useState<{
|
||||
title: string;
|
||||
status: IncidentStatus;
|
||||
impact: IncidentImpact;
|
||||
body: string;
|
||||
note: string;
|
||||
monitorIds: number[];
|
||||
}>({
|
||||
title: '',
|
||||
status: 'investigating',
|
||||
impact: 'major',
|
||||
body: '',
|
||||
note: '',
|
||||
monitorIds: [],
|
||||
});
|
||||
const [generated, setGenerated] = useState(false);
|
||||
function generate() {
|
||||
draft.mutate(
|
||||
{ note: form.note, status: form.status, monitorIds: form.monitorIds },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
setForm((current) => ({ ...current, title: result.title, body: result.body }));
|
||||
setGenerated(true);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
create.mutate({ ...form, note: form.note || null }, { onSuccess: onClose });
|
||||
}
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && !create.isPending && onClose()}>
|
||||
<DialogContent className="incident-dialog max-h-[90vh] w-[calc(100%-2rem)] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<p className="overline">Incident management</p>
|
||||
<DialogTitle>Declare incident</DialogTitle>
|
||||
<DialogDescription>Turn a short internal note into a clear customer-facing update.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="incident-form" onSubmit={submit}>
|
||||
<div className="incident-form-grid">
|
||||
<div className="field">
|
||||
<span id="incident-status-label">Status</span>
|
||||
<Select value={form.status} onValueChange={(status) => setForm({ ...form, status: status as IncidentStatus })}>
|
||||
<SelectTrigger aria-labelledby="incident-status-label">
|
||||
<SelectValue>
|
||||
<IncidentStatusOption value={form.status} />
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{INCIDENT_STATUSES.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
<IncidentStatusOption value={status} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span id="incident-impact-label">Impact</span>
|
||||
<Select value={form.impact} onValueChange={(impact) => setForm({ ...form, impact: impact as IncidentImpact })}>
|
||||
<SelectTrigger aria-labelledby="incident-impact-label">
|
||||
<SelectValue>
|
||||
<IncidentImpactOption value={form.impact} />
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{INCIDENT_IMPACTS.map((impact) => (
|
||||
<SelectItem key={impact} value={impact}>
|
||||
<IncidentImpactOption value={impact} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<label className="field incident-title-field" htmlFor="incident-title">
|
||||
<span>Public title</span>
|
||||
<Input
|
||||
id="incident-title"
|
||||
value={form.title}
|
||||
onChange={(event) => setForm({ ...form, title: event.target.value })}
|
||||
maxLength={120}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<fieldset className="incident-service-picker">
|
||||
<legend>
|
||||
Affected services <small>Optional</small>
|
||||
</legend>
|
||||
<div className="incident-service-options">
|
||||
{monitors.data?.monitors.map((monitor) => (
|
||||
<label key={monitor.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.monitorIds.includes(monitor.id)}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
monitorIds: event.target.checked
|
||||
? [...form.monitorIds, monitor.id]
|
||||
: form.monitorIds.filter((id) => id !== monitor.id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{monitor.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<AiComposeField
|
||||
note={form.note}
|
||||
body={form.body}
|
||||
onNoteChange={(note) => setForm({ ...form, note })}
|
||||
onBodyChange={(body) => setForm({ ...form, body })}
|
||||
onGenerate={generate}
|
||||
isPending={draft.isPending}
|
||||
error={draft.error}
|
||||
generated={generated}
|
||||
/>
|
||||
<div className="form-actions compact-actions">
|
||||
<Button variant="unstyled" className="secondary-button" type="button" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="unstyled" className="primary-button" type="submit" disabled={create.isPending}>
|
||||
{create.isPending ? 'Publishing…' : 'Declare incident'}
|
||||
</Button>
|
||||
</div>
|
||||
{create.isError && (
|
||||
<p className="form-error" role="alert">
|
||||
{create.error.message}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Activity, CircleCheck, CircleMinus, Crosshair, OctagonAlert, Search, Siren, TriangleAlert, type LucideIcon } from 'lucide-react';
|
||||
import type { IncidentImpact, IncidentStatus } from '../../api/incidents';
|
||||
|
||||
export const INCIDENT_STATUSES: IncidentStatus[] = ['investigating', 'identified', 'monitoring', 'resolved'];
|
||||
export const INCIDENT_IMPACTS: IncidentImpact[] = ['none', 'minor', 'major', 'critical'];
|
||||
|
||||
const statusOptions: Record<IncidentStatus, { label: string; icon: LucideIcon; tone: string }> = {
|
||||
investigating: { label: 'Investigating', icon: Search, tone: 'blue' },
|
||||
identified: { label: 'Identified', icon: Crosshair, tone: 'violet' },
|
||||
monitoring: { label: 'Monitoring', icon: Activity, tone: 'amber' },
|
||||
resolved: { label: 'Resolved', icon: CircleCheck, tone: 'green' },
|
||||
};
|
||||
|
||||
const impactOptions: Record<IncidentImpact, { label: string; icon: LucideIcon; tone: string }> = {
|
||||
none: { label: 'None', icon: CircleMinus, tone: 'neutral' },
|
||||
minor: { label: 'Minor', icon: TriangleAlert, tone: 'yellow' },
|
||||
major: { label: 'Major', icon: OctagonAlert, tone: 'orange' },
|
||||
critical: { label: 'Critical', icon: Siren, tone: 'red' },
|
||||
};
|
||||
|
||||
function IncidentOption({ label, icon: Icon, tone }: { label: string; icon: LucideIcon; tone: string }) {
|
||||
return (
|
||||
<span className="incident-select-option">
|
||||
<span className={`incident-select-icon incident-select-icon-${tone}`} aria-hidden="true">
|
||||
<Icon />
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function IncidentStatusOption({ value }: { value: IncidentStatus }) {
|
||||
return <IncidentOption {...statusOptions[value]} />;
|
||||
}
|
||||
|
||||
export function IncidentImpactOption({ value }: { value: IncidentImpact }) {
|
||||
return <IncidentOption {...impactOptions[value]} />;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { type FormEvent, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import type { Incident, IncidentStatus } from '../../api/incidents';
|
||||
import { useDraftIncidentUpdateMutation, usePostIncidentUpdateMutation } from '../../queries/incidents';
|
||||
import { AiComposeField } from './AiComposeField';
|
||||
import { INCIDENT_STATUSES, IncidentStatusOption } from './IncidentSelectOption';
|
||||
|
||||
export function IncidentUpdateDialog({ incident, onClose }: { incident: Incident; onClose(): void }) {
|
||||
const post = usePostIncidentUpdateMutation();
|
||||
const draft = useDraftIncidentUpdateMutation(incident.id);
|
||||
const [status, setStatus] = useState<IncidentStatus>(incident.status);
|
||||
const [note, setNote] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [generated, setGenerated] = useState(false);
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
post.mutate({ id: incident.id, input: { status, body, note: note || null } }, { onSuccess: onClose });
|
||||
}
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && !post.isPending && onClose()}>
|
||||
<DialogContent className="incident-dialog max-h-[90vh] w-[calc(100%-2rem)] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<p className="overline">Incident update</p>
|
||||
<DialogTitle>{incident.title ?? 'Service disruption'}</DialogTitle>
|
||||
<DialogDescription>Publish the next update and advance the incident lifecycle.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="incident-form" onSubmit={submit}>
|
||||
<div className="field">
|
||||
<span id="update-status-label">Status</span>
|
||||
<Select value={status} onValueChange={(value) => setStatus(value as IncidentStatus)}>
|
||||
<SelectTrigger aria-labelledby="update-status-label">
|
||||
<SelectValue>
|
||||
<IncidentStatusOption value={status} />
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{INCIDENT_STATUSES.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
<IncidentStatusOption value={value} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<AiComposeField
|
||||
note={note}
|
||||
body={body}
|
||||
onNoteChange={setNote}
|
||||
onBodyChange={setBody}
|
||||
onGenerate={() =>
|
||||
draft.mutate(
|
||||
{ note, status },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
setBody(result.body);
|
||||
setGenerated(true);
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
isPending={draft.isPending}
|
||||
error={draft.error}
|
||||
generated={generated}
|
||||
/>
|
||||
<div className="form-actions compact-actions">
|
||||
<Button variant="unstyled" className="secondary-button" type="button" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="unstyled" className="primary-button" type="submit" disabled={post.isPending}>
|
||||
{post.isPending ? 'Publishing…' : status === 'resolved' ? 'Resolve incident' : 'Post update'}
|
||||
</Button>
|
||||
</div>
|
||||
{post.isError && (
|
||||
<p className="form-error" role="alert">
|
||||
{post.error.message}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, TriangleAlert } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Incident } from '../../api/incidents';
|
||||
import { navigate } from '../../lib/router';
|
||||
import { useIncidentsQuery } from '../../queries/incidents';
|
||||
import { IncidentDialog } from './IncidentDialog';
|
||||
import { IncidentUpdateDialog } from './IncidentUpdateDialog';
|
||||
|
||||
export function IncidentsPanel() {
|
||||
const query = useIncidentsQuery('open');
|
||||
const [declareOpen, setDeclareOpen] = useState(false);
|
||||
const [updating, setUpdating] = useState<Incident | null>(null);
|
||||
const incidents = query.data?.incidents ?? [];
|
||||
return (
|
||||
<section className="dashboard-panel incidents-panel">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<p className="overline">Communication</p>
|
||||
<h2>Active incidents</h2>
|
||||
<p>Customer-facing incident lifecycle and updates.</p>
|
||||
</div>
|
||||
<Button variant="unstyled" className="primary-button" onClick={() => setDeclareOpen(true)}>
|
||||
<Plus /> Declare incident
|
||||
</Button>
|
||||
</header>
|
||||
{incidents.length === 0 ? (
|
||||
<div className="incidents-empty">
|
||||
<TriangleAlert />
|
||||
<div>
|
||||
<strong>No active incidents</strong>
|
||||
<p>Declare an incident when service impact is not detected by HTTP probes.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dashboard-incident-list">
|
||||
{incidents.map((incident) => (
|
||||
<article key={incident.id}>
|
||||
<div>
|
||||
<button type="button" onClick={() => navigate(`/incidents/${incident.id}`)}>
|
||||
{incident.title ?? 'Service disruption'}
|
||||
</button>
|
||||
<span>
|
||||
{incident.monitorIds.length} services · {incident.updateCount ?? 0} updates
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant={incident.impact === 'critical' ? 'offline' : 'checking'}>{incident.status}</Badge>
|
||||
<Button variant="unstyled" className="secondary-button" onClick={() => setUpdating(incident)}>
|
||||
Post update
|
||||
</Button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{declareOpen && <IncidentDialog onClose={() => setDeclareOpen(false)} />}
|
||||
{updating && <IncidentUpdateDialog incident={updating} onClose={() => setUpdating(null)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Monitor } from '../api/monitors';
|
||||
import { AppHeader } from '../components/AppHeader';
|
||||
import { DashboardFooter } from '../components/dashboard/DashboardFooter';
|
||||
import { DashboardOverview } from '../components/dashboard/DashboardOverview';
|
||||
import { IncidentsPanel } from '../components/dashboard/IncidentsPanel';
|
||||
import { MonitorFormDialog } from '../components/dashboard/MonitorFormDialog';
|
||||
import { MonitorListPanel } from '../components/dashboard/MonitorListPanel';
|
||||
|
||||
@@ -35,6 +36,7 @@ export function DashboardPage() {
|
||||
{formOpen ? <MonitorFormDialog key={editing?.id ?? 'create'} editing={editing} onClose={closeForm} /> : null}
|
||||
|
||||
<MonitorListPanel formOpen={formOpen} onAddMonitor={openCreateForm} onEdit={openEditForm} />
|
||||
<IncidentsPanel />
|
||||
</main>
|
||||
|
||||
<DashboardFooter />
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ArrowLeft, TriangleAlert, Zap } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { IncidentTimeline } from '../components/IncidentTimeline';
|
||||
import { navigate } from '../lib/router';
|
||||
import { usePublicIncidentQuery } from '../queries/status';
|
||||
|
||||
const dateTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short' });
|
||||
|
||||
export function IncidentDetailPage({ id }: { id: number }) {
|
||||
const query = usePublicIncidentQuery(id);
|
||||
return (
|
||||
<div className="status-page-shell">
|
||||
<header className="dashboard-header status-header">
|
||||
<div className="dashboard-header-inner status-header-inner">
|
||||
<a className="brand" href="/" aria-label="Upwatch public status">
|
||||
<Zap className="brand-mark" fill="currentColor" /> <span>upwatch</span>
|
||||
</a>
|
||||
<Button variant="unstyled" className="status-header-action" onClick={() => navigate('/')}>
|
||||
Status page
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="status-main incident-detail-page">
|
||||
<button className="incident-back" type="button" onClick={() => navigate('/')}>
|
||||
<ArrowLeft /> All service status
|
||||
</button>
|
||||
{query.isPending ? (
|
||||
<p className="status-loading" aria-busy="true">
|
||||
Loading incident…
|
||||
</p>
|
||||
) : query.isError || !query.data ? (
|
||||
<section className="incident-detail-card">
|
||||
<TriangleAlert />
|
||||
<h1>Incident not found</h1>
|
||||
<p>{query.error?.message}</p>
|
||||
</section>
|
||||
) : (
|
||||
<article className="incident-detail-card">
|
||||
<header className="incident-detail-header">
|
||||
<div>
|
||||
<p className="overline">Incident report</p>
|
||||
<h1>{query.data.incident.title}</h1>
|
||||
</div>
|
||||
<Badge variant={query.data.incident.status === 'resolved' ? 'online' : 'offline'}>{query.data.incident.status}</Badge>
|
||||
</header>
|
||||
<p className="incident-detail-meta">Started {dateTime.format(new Date(query.data.incident.startedAt))}</p>
|
||||
{query.data.incident.services.length > 0 && (
|
||||
<p className="incident-detail-services">
|
||||
<strong>Affected services:</strong> {query.data.incident.services.map((service) => service.name).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
<IncidentTimeline updates={query.data.incident.updates ?? []} />
|
||||
</article>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -236,13 +236,16 @@ export function MonitorDetailPage({ id }: { id: number }) {
|
||||
<article className={incident.resolvedAt ? 'resolved' : 'open'} key={incident.id}>
|
||||
<span>{incident.resolvedAt ? <CheckCircle2 /> : <Clock3 />}</span>
|
||||
<div>
|
||||
<strong>{incident.resolvedAt ? 'Resolved incident' : 'Incident in progress'}</strong>
|
||||
<button type="button" className="incident-detail-link" onClick={() => navigate(`/incidents/${incident.id}`)}>
|
||||
<strong>{incident.title ?? (incident.resolvedAt ? 'Resolved incident' : 'Incident in progress')}</strong>
|
||||
<Badge variant={incident.resolvedAt ? 'online' : 'offline'}>{incident.status}</Badge>
|
||||
</button>
|
||||
<p>
|
||||
{incident.aiMessage ??
|
||||
{incident.latestUpdate?.body ??
|
||||
incident.startError ??
|
||||
(incident.startStatusCode ? `HTTP ${incident.startStatusCode}` : 'Endpoint became unavailable')}
|
||||
</p>
|
||||
{incident.aiMessage && incident.startError && <small className="incident-raw">{incident.startError}</small>}
|
||||
{incident.latestUpdate && incident.startError && <small className="incident-raw">{incident.startError}</small>}
|
||||
<small>
|
||||
{formatDate(incident.startedAt)} · {formatDuration(incident.durationMs, incident.startedAt)}
|
||||
</small>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { SiteIcon } from '../components/SiteIcon';
|
||||
import { StatusHistoryBar } from '../components/StatusHistoryBar';
|
||||
import { navigate } from '../lib/router';
|
||||
import { useSessionQuery } from '../queries/auth';
|
||||
import { useStatusQuery } from '../queries/status';
|
||||
import { useIncidentHistoryQuery, useStatusQuery } from '../queries/status';
|
||||
|
||||
const OVERALL_COPY: Record<PublicOverallStatus, { title: string; detail: string }> = {
|
||||
operational: {
|
||||
@@ -54,10 +54,11 @@ function OverallIcon({ status }: { status: PublicOverallStatus }) {
|
||||
|
||||
export function StatusPage() {
|
||||
const statusQuery = useStatusQuery();
|
||||
const historyQuery = useIncidentHistoryQuery();
|
||||
const sessionQuery = useSessionQuery();
|
||||
const [now, setNow] = useState(Date.now);
|
||||
const status = statusQuery.data;
|
||||
const activeIncidents = status?.services.filter((service) => service.message) ?? [];
|
||||
const activeIncidents = status?.activeIncidents ?? [];
|
||||
const maintenanceServices = status?.services.filter((service) => service.maintenance) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
@@ -180,25 +181,31 @@ export function StatusPage() {
|
||||
</div>
|
||||
</div>
|
||||
<span className="active-incidents-count">
|
||||
{activeIncidents.length} {activeIncidents.length === 1 ? 'service' : 'services'} affected
|
||||
{activeIncidents.length} {activeIncidents.length === 1 ? 'incident' : 'incidents'} active
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="active-incident-list">
|
||||
{activeIncidents.map((service) => (
|
||||
<article className="active-incident-row" key={service.id}>
|
||||
{activeIncidents.map((incident) => (
|
||||
<article className="active-incident-row" key={incident.id}>
|
||||
<div className="active-incident-service">
|
||||
<span className="active-incident-service-icon">
|
||||
<SiteIcon monitorId={service.id} favicon="public" />
|
||||
<TriangleAlert />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{service.name}</strong>
|
||||
<span>Service disruption</span>
|
||||
<button type="button" onClick={() => navigate(`/incidents/${incident.id}`)}>
|
||||
<strong>{incident.title}</strong>
|
||||
</button>
|
||||
<span>
|
||||
{incident.services.length
|
||||
? incident.services.map((service) => service.name).join(', ')
|
||||
: 'General service incident'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p>{service.message}</p>
|
||||
<p>{incident.latestUpdate?.body}</p>
|
||||
<span className="active-incident-state">
|
||||
<i /> Investigating
|
||||
<i /> {incident.status}
|
||||
</span>
|
||||
</article>
|
||||
))}
|
||||
@@ -260,6 +267,28 @@ export function StatusPage() {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{(historyQuery.data?.incidents.length ?? 0) > 0 && (
|
||||
<section className="past-incidents" aria-labelledby="past-incidents-title">
|
||||
<header>
|
||||
<div>
|
||||
<p className="overline">Last 30 days</p>
|
||||
<h2 id="past-incidents-title">Past incidents</h2>
|
||||
</div>
|
||||
</header>
|
||||
<div>
|
||||
{historyQuery.data!.incidents.map((incident) => (
|
||||
<button key={incident.id} type="button" onClick={() => navigate(`/incidents/${incident.id}`)}>
|
||||
<span>
|
||||
<strong>{incident.title}</strong>
|
||||
<small>{new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(new Date(incident.startedAt))}</small>
|
||||
</span>
|
||||
<Badge variant="online">Resolved</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
createIncident,
|
||||
deleteIncident,
|
||||
draftIncident,
|
||||
draftIncidentUpdate,
|
||||
getIncident,
|
||||
listIncidents,
|
||||
postIncidentUpdate,
|
||||
updateIncident,
|
||||
type IncidentInput,
|
||||
type IncidentStatus,
|
||||
type IncidentUpdateInput,
|
||||
} from '../api/incidents';
|
||||
import { queryClient } from '../lib/query-client';
|
||||
import { statusKeys } from './status';
|
||||
|
||||
export const incidentKeys = {
|
||||
all: ['incidents'] as const,
|
||||
list: (status: 'open' | 'all') => [...incidentKeys.all, status] as const,
|
||||
detail: (id: number) => [...incidentKeys.all, 'detail', id] as const,
|
||||
};
|
||||
export function useIncidentsQuery(status: 'open' | 'all' = 'open') {
|
||||
return useQuery({ queryKey: incidentKeys.list(status), queryFn: ({ signal }) => listIncidents(status, signal), refetchInterval: 60_000 });
|
||||
}
|
||||
export function useIncidentQuery(id: number) {
|
||||
return useQuery({ queryKey: incidentKeys.detail(id), queryFn: ({ signal }) => getIncident(id, signal) });
|
||||
}
|
||||
function invalidateIncidents() {
|
||||
void queryClient.invalidateQueries({ queryKey: incidentKeys.all });
|
||||
return queryClient.invalidateQueries({ queryKey: statusKeys.all });
|
||||
}
|
||||
export function useCreateIncidentMutation() {
|
||||
return useMutation({ mutationFn: (input: IncidentInput) => createIncident(input), onSuccess: invalidateIncidents });
|
||||
}
|
||||
export function useUpdateIncidentMutation() {
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: Partial<Pick<IncidentInput, 'title' | 'impact' | 'monitorIds'>> }) =>
|
||||
updateIncident(id, input),
|
||||
onSuccess: invalidateIncidents,
|
||||
});
|
||||
}
|
||||
export function usePostIncidentUpdateMutation() {
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: IncidentUpdateInput }) => postIncidentUpdate(id, input),
|
||||
onSuccess: invalidateIncidents,
|
||||
});
|
||||
}
|
||||
export function useDeleteIncidentMutation() {
|
||||
return useMutation({ mutationFn: deleteIncident, onSuccess: invalidateIncidents });
|
||||
}
|
||||
export function useDraftIncidentMutation() {
|
||||
return useMutation({ mutationFn: (input: { note: string; status: IncidentStatus; monitorIds: number[] }) => draftIncident(input) });
|
||||
}
|
||||
export function useDraftIncidentUpdateMutation(id: number) {
|
||||
return useMutation({ mutationFn: (input: { note: string; status: IncidentStatus }) => draftIncidentUpdate(id, input) });
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getStatus } from '../api/status';
|
||||
import { getIncidentHistory, getPublicIncident, getStatus } from '../api/status';
|
||||
|
||||
export const statusKeys = {
|
||||
all: ['public-status'] as const,
|
||||
history: ['public-status', 'incidents'] as const,
|
||||
incident: (id: number) => ['public-status', 'incidents', id] as const,
|
||||
};
|
||||
|
||||
export function useStatusQuery() {
|
||||
@@ -13,3 +15,15 @@ export function useStatusQuery() {
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useIncidentHistoryQuery() {
|
||||
return useQuery({
|
||||
queryKey: statusKeys.history,
|
||||
queryFn: ({ signal }) => getIncidentHistory(signal),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePublicIncidentQuery(id: number) {
|
||||
return useQuery({ queryKey: statusKeys.incident(id), queryFn: ({ signal }) => getPublicIncident(id, signal) });
|
||||
}
|
||||
|
||||
@@ -2632,6 +2632,430 @@ button {
|
||||
}
|
||||
}
|
||||
|
||||
.incidents-panel {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.incidents-empty,
|
||||
.dashboard-incident-list article,
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
.incidents-empty {
|
||||
justify-content: flex-start;
|
||||
padding: 28px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 16px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.dashboard-incident-list article {
|
||||
padding: 18px 22px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.dashboard-incident-list article > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
.dashboard-incident-list button:first-child,
|
||||
.active-incident-service button {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.incident-dialog [data-slot='dialog-header'] {
|
||||
gap: 5px;
|
||||
padding-right: 36px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.incident-dialog [data-slot='dialog-header'] .overline {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.incident-dialog [data-slot='dialog-title'] {
|
||||
font-size: 19px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.incident-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
.incident-form > * {
|
||||
grid-column: 1;
|
||||
}
|
||||
.incident-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 2fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.incident-title-field {
|
||||
min-width: 0;
|
||||
}
|
||||
.incident-select-option {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.incident-select-icon {
|
||||
display: inline-flex;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex: 0 0 22px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.incident-select-icon svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.incident-select-icon-blue {
|
||||
color: #2563a8;
|
||||
background: #eaf3ff;
|
||||
}
|
||||
.incident-select-icon-violet {
|
||||
color: #6d4bad;
|
||||
background: #f1ecfb;
|
||||
}
|
||||
.incident-select-icon-amber {
|
||||
color: #9a6700;
|
||||
background: #fff5d6;
|
||||
}
|
||||
.incident-select-icon-green {
|
||||
color: #17865b;
|
||||
background: #e5f7ef;
|
||||
}
|
||||
.incident-select-icon-neutral {
|
||||
color: #666;
|
||||
background: #eeeeee;
|
||||
}
|
||||
.incident-select-icon-yellow {
|
||||
color: #8a6500;
|
||||
background: #fff5c2;
|
||||
}
|
||||
.incident-select-icon-orange {
|
||||
color: #b45309;
|
||||
background: #ffedd5;
|
||||
}
|
||||
.incident-select-icon-red {
|
||||
color: #b42318;
|
||||
background: #fee9e7;
|
||||
}
|
||||
.incident-form textarea {
|
||||
min-height: 138px;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
.incident-form textarea::placeholder {
|
||||
color: var(--faint);
|
||||
}
|
||||
.incident-form textarea:hover {
|
||||
border-color: #aaa;
|
||||
}
|
||||
.incident-form textarea:focus-visible {
|
||||
border-color: var(--primary-deep);
|
||||
box-shadow: 0 0 0 3px rgb(36 180 126 / 0.14);
|
||||
}
|
||||
.incident-service-picker {
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.incident-service-picker legend {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #353535;
|
||||
}
|
||||
.incident-service-picker legend small,
|
||||
.ai-compose-pane-header small {
|
||||
margin-left: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.incident-service-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-height: 104px;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.incident-service-options label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 32px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid #dedede;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #444;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background 160ms ease;
|
||||
}
|
||||
.incident-service-options label:hover {
|
||||
border-color: #bcbcbc;
|
||||
background: #fdfdfd;
|
||||
}
|
||||
.incident-service-options label:has(input:checked) {
|
||||
border-color: #8dd9b9;
|
||||
background: rgb(62 207 142 / 0.09);
|
||||
}
|
||||
.incident-service-options input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: var(--primary-deep);
|
||||
}
|
||||
.ai-compose-field {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.ai-compose-pane {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.ai-compose-pane-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.ai-compose-pane-header label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #353535;
|
||||
}
|
||||
.ai-compose-pane-header small {
|
||||
margin-left: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-compose-button {
|
||||
align-self: flex-start;
|
||||
min-height: 36px;
|
||||
margin-top: 2px;
|
||||
padding-block: 6px;
|
||||
}
|
||||
.ai-compose-hint {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.ai-compose-hint button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.incident-form > .form-actions {
|
||||
align-items: center;
|
||||
margin-top: 2px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.incident-form > .form-actions button {
|
||||
min-height: 40px;
|
||||
padding-inline: 18px;
|
||||
}
|
||||
.incident-form > .form-actions .secondary-button {
|
||||
border-color: #d4d4d4;
|
||||
color: #444;
|
||||
background: #fff;
|
||||
box-shadow:
|
||||
0 1px 2px rgb(0 0 0 / 0.05),
|
||||
inset 0 1px rgb(255 255 255 / 0.7);
|
||||
}
|
||||
.incident-form > .form-actions .secondary-button:hover:not(:disabled) {
|
||||
border-color: #aaa;
|
||||
color: var(--foreground);
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.incident-form > .form-actions .primary-button {
|
||||
min-width: 148px;
|
||||
box-shadow:
|
||||
0 1px 2px rgb(0 0 0 / 0.08),
|
||||
inset 0 1px rgb(255 255 255 / 0.28);
|
||||
}
|
||||
.past-incidents,
|
||||
.incident-detail-card {
|
||||
margin-top: 32px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
background: var(--card);
|
||||
overflow: hidden;
|
||||
}
|
||||
.past-incidents > header,
|
||||
.past-incidents button,
|
||||
.incident-detail-card {
|
||||
padding: 22px 24px;
|
||||
}
|
||||
.past-incidents button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.past-incidents button span {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
.incident-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 28px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
.incident-back svg {
|
||||
width: 16px;
|
||||
}
|
||||
.incident-detail-header,
|
||||
.incident-timeline header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
.incident-detail-header h1 {
|
||||
margin-top: 6px;
|
||||
font-size: clamp(28px, 5vw, 46px);
|
||||
}
|
||||
.incident-detail-meta,
|
||||
.incident-detail-services {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.incident-timeline {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin-top: 30px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.incident-timeline li {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr;
|
||||
gap: 14px;
|
||||
padding-bottom: 28px;
|
||||
}
|
||||
.incident-timeline li:not(:last-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 15px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--border);
|
||||
}
|
||||
.incident-timeline-dot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 3px solid var(--card);
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
}
|
||||
.incident-timeline strong {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.incident-timeline time {
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.incident-detail-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.incident-dialog {
|
||||
padding: 22px;
|
||||
}
|
||||
.dashboard-incident-list article,
|
||||
.panel-heading {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.incident-form-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.incident-title-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.ai-compose-field {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 440px) {
|
||||
.incident-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.incident-title-field {
|
||||
grid-column: auto;
|
||||
}
|
||||
.incident-form > .form-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
.incident-form > .form-actions button {
|
||||
flex: 1;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
.incident-form > .form-actions .primary-button {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: 'DM Sans', 'Helvetica Neue', sans-serif;
|
||||
--font-mono: 'IBM Plex Mono', monospace;
|
||||
|
||||
@@ -8,7 +8,12 @@ type CompletionBody = {
|
||||
choices?: Array<{ message?: { content?: unknown } }>;
|
||||
};
|
||||
|
||||
export async function requestCompletion(settings: CompletionSettings, system: string, user: string): Promise<string | null> {
|
||||
export async function requestCompletion(
|
||||
settings: CompletionSettings,
|
||||
system: string,
|
||||
user: string,
|
||||
maxTokens = 160,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(`${settings.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
@@ -22,7 +27,7 @@ export async function requestCompletion(settings: CompletionSettings, system: st
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
max_tokens: 160,
|
||||
max_tokens: maxTokens,
|
||||
temperature: 0.2,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
export const INCIDENT_REASSURANCE =
|
||||
'The problem was detected automatically, our team has been alerted, and we are working to restore normal service as soon as possible.';
|
||||
|
||||
export const RECOVERY_UPDATE_BODY = 'The service has recovered and is responding normally again.';
|
||||
|
||||
/** Plain-language, non-technical description of the impact, used when no AI message is available. */
|
||||
export function describeFailure(statusCode: number | null): string {
|
||||
if (statusCode === null) return 'This service is currently unreachable and may not load for visitors.';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { and, desc, eq, gte, sql } from 'drizzle-orm';
|
||||
import type { CheckResult, Monitor } from '../checks/run-check';
|
||||
import type { Database } from '../db/client';
|
||||
import { checks, incidents } from '../db/schema';
|
||||
import { checks, incidentMonitors, incidents } from '../db/schema';
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
@@ -154,7 +154,8 @@ export async function buildIncidentContext(db: Database, monitor: Monitor, resul
|
||||
const [priorIncidents] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(incidents)
|
||||
.where(and(eq(incidents.monitorId, monitor.id), gte(incidents.startedAt, new Date(now - 30 * DAY_MS))));
|
||||
.innerJoin(incidentMonitors, eq(incidentMonitors.incidentId, incidents.id))
|
||||
.where(and(eq(incidentMonitors.monitorId, monitor.id), gte(incidents.startedAt, new Date(now - 30 * DAY_MS))));
|
||||
// The incident that triggered this run is already persisted, so discount it.
|
||||
const priorCount = Math.max(0, Number(priorIncidents?.count ?? 0) - 1);
|
||||
lines.push(
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDb } from '../db/client';
|
||||
import { aiSettings } from '../db/schema';
|
||||
import { requestCompletion } from './client';
|
||||
import { sanitizePublicText } from './sanitize';
|
||||
|
||||
export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved';
|
||||
|
||||
export const STATUS_GUIDANCE: Record<IncidentStatus, string> = {
|
||||
investigating: 'Acknowledge an issue affecting users and say the team is investigating. Do not imply that the cause is known.',
|
||||
identified: 'Say that the cause has been identified and a fix is being implemented, describing only the user-visible impact.',
|
||||
monitoring: 'Say that a fix has been applied and the team is monitoring the service to confirm it remains stable.',
|
||||
resolved: 'Confirm that the service is operating normally again and add a brief thank-you.',
|
||||
};
|
||||
|
||||
export const INCIDENT_DRAFT_SYSTEM_PROMPT = [
|
||||
"You turn an operator's short internal note into a public status update.",
|
||||
'The note is written for engineers and may contain technical detail; your output must not.',
|
||||
'Always write in English, regardless of the language of the operator note.',
|
||||
'Write for non-technical customers in a calm, courteous tone.',
|
||||
'Never include URLs, hostnames, domain names, IP addresses, ports, paths, HTTP status codes, error codes, or stack traces.',
|
||||
'Never name a technical fault, internal component, vendor, database, infrastructure detail, or implementation detail.',
|
||||
'Do not blame anyone, speculate, or promise a specific resolution time.',
|
||||
'When asked for a title, output exactly TITLE: followed by a 3-8 word noun phrase describing the user-visible symptom, with no final period.',
|
||||
'Output BODY: followed by 2-3 concise sentences. Output no other text.',
|
||||
].join('\n');
|
||||
|
||||
export class IncidentDraftError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: 409 | 422,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
type DraftInput = {
|
||||
note: string;
|
||||
status: IncidentStatus;
|
||||
withTitle: boolean;
|
||||
incidentTitle?: string | null;
|
||||
previousUpdates?: Array<{ status: string; body: string }>;
|
||||
serviceCount: number;
|
||||
};
|
||||
|
||||
export async function draftIncidentUpdate(env: Env, input: DraftInput): Promise<{ title: string | null; body: string }> {
|
||||
const db = getDb(env);
|
||||
const [settings] = await db.select().from(aiSettings).where(eq(aiSettings.id, 1)).limit(1);
|
||||
if (!settings?.enabled || !settings.baseUrl || !settings.apiKey || !settings.model) {
|
||||
throw new IncidentDraftError('AI composition is not configured. Enable it in Settings or write the update manually.', 409);
|
||||
}
|
||||
|
||||
const context = [
|
||||
`Lifecycle status: ${input.status}`,
|
||||
`Writing guidance: ${STATUS_GUIDANCE[input.status]}`,
|
||||
`Affected service count: ${input.serviceCount}`,
|
||||
input.incidentTitle ? `Incident title: ${input.incidentTitle}` : null,
|
||||
input.previousUpdates?.length
|
||||
? `Recent public updates:\n${input.previousUpdates.map((update) => `- ${update.status}: ${update.body}`).join('\n')}`
|
||||
: null,
|
||||
`Internal operator note (use only as context; do not expose technical details): ${input.note}`,
|
||||
input.withTitle ? 'Return TITLE: and BODY: lines.' : 'Return only a BODY: line.',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
const completion = await requestCompletion(
|
||||
{ baseUrl: settings.baseUrl, apiKey: settings.apiKey, model: settings.model },
|
||||
INCIDENT_DRAFT_SYSTEM_PROMPT,
|
||||
context,
|
||||
320,
|
||||
);
|
||||
if (!completion) throw new IncidentDraftError('AI could not generate a safe update. Edit the note or write the update manually.', 422);
|
||||
|
||||
const titleMatch = completion.match(/(?:^|\n)TITLE:\s*(.+?)(?=\nBODY:|$)/is);
|
||||
const bodyMatch = completion.match(/(?:^|\n)BODY:\s*([\s\S]+)$/i);
|
||||
const title = input.withTitle ? sanitizePublicText(titleMatch?.[1] ?? '', 120)?.replace(/[.!?]+$/, '') : null;
|
||||
const body = sanitizePublicText(bodyMatch?.[1] ?? '', 400);
|
||||
if ((input.withTitle && !title) || !body) {
|
||||
throw new IncidentDraftError('AI could not generate a safe update. Edit the note or write the update manually.', 422);
|
||||
}
|
||||
return { title: title ?? null, body };
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { CheckResult, Monitor } from '../checks/run-check';
|
||||
import { getDb } from '../db/client';
|
||||
import { aiSettings, incidents } from '../db/schema';
|
||||
import { aiSettings } from '../db/schema';
|
||||
import { requestCompletion } from './client';
|
||||
import { buildIncidentContext } from './incident-context';
|
||||
import { sanitizePublicText } from './sanitize';
|
||||
|
||||
export const INCIDENT_MESSAGE_SYSTEM_PROMPT = [
|
||||
"You write short public status updates for a website's visitors.",
|
||||
@@ -21,20 +22,7 @@ export const INCIDENT_MESSAGE_SYSTEM_PROMPT = [
|
||||
].join('\n');
|
||||
|
||||
export function sanitizeIncidentMessage(value: string): string | null {
|
||||
const message = value
|
||||
.replace(/\r/g, '')
|
||||
.trim()
|
||||
.replace(/^(?:message|update|status)\s*:\s*/i, '')
|
||||
.replace(/^(["'])([\s\S]*)\1$/, '$2')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 280);
|
||||
if (!message) return null;
|
||||
// Reject anything that leaked a technical detail past the prompt.
|
||||
if (/https?:\/\//i.test(message)) return null;
|
||||
if (/\b(?:\d{1,3}\.){3}\d{1,3}\b/.test(message)) return null;
|
||||
if (/\bHTTP[\s/]?\d{3}\b/i.test(message) || /\b[45]\d{2}\s+(?:error|status|response)\b/i.test(message)) return null;
|
||||
return message;
|
||||
return sanitizePublicText(value, 280);
|
||||
}
|
||||
|
||||
export async function generateIncidentMessage(env: Env, input: { monitor: Monitor; result: CheckResult }): Promise<string | null> {
|
||||
@@ -53,10 +41,18 @@ export async function generateIncidentMessage(env: Env, input: { monitor: Monito
|
||||
const message = sanitizeIncidentMessage(content);
|
||||
if (!message) return null;
|
||||
|
||||
await db
|
||||
.update(incidents)
|
||||
.set({ aiMessage: message, updatedAt: new Date() })
|
||||
.where(and(eq(incidents.monitorId, input.monitor.id), isNull(incidents.resolvedAt)));
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO incident_updates (incident_id, status, body, source, created_at)
|
||||
SELECT i.id, 'investigating', ?, 'ai', ?
|
||||
FROM incidents i
|
||||
JOIN incident_monitors im ON im.incident_id = i.id
|
||||
WHERE im.monitor_id = ? AND i.source = 'auto' AND i.resolved_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM incident_updates iu WHERE iu.incident_id = i.id AND iu.source = 'ai'
|
||||
)`,
|
||||
)
|
||||
.bind(message, Date.now(), input.monitor.id)
|
||||
.run();
|
||||
return message;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export function sanitizePublicText(value: string, maxLength: number): string | null {
|
||||
const message = value
|
||||
.replace(/\r/g, '')
|
||||
.trim()
|
||||
.replace(/^(?:message|update|status|title|body)\s*:\s*/i, '')
|
||||
.replace(/^(["'])([\s\S]*)\1$/, '$2')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, maxLength);
|
||||
if (!message) return null;
|
||||
if (/https?:\/\//i.test(message)) return null;
|
||||
if (/\b(?:\d{1,3}\.){3}\d{1,3}\b/.test(message)) return null;
|
||||
if (/\bHTTP[\s/]?\d{3}\b/i.test(message) || /\b[45]\d{2}\s+(?:error|status|response)\b/i.test(message)) return null;
|
||||
return message;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||
import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||
import type { Database } from '../db/client';
|
||||
import { checks, incidents, monitors } from '../db/schema';
|
||||
import { checks, incidentMonitors, incidents, incidentUpdates, monitors } from '../db/schema';
|
||||
import { RECOVERY_UPDATE_BODY } from '../ai/fallback-message';
|
||||
import type { CheckResult, Monitor } from './run-check';
|
||||
|
||||
export type IncidentTransition = 'opened' | 'resolved' | null;
|
||||
@@ -37,25 +38,48 @@ export function buildResultStatements(db: Database, monitor: Monitor, result: Ch
|
||||
if (monitor.lastOk !== false && !result.ok) {
|
||||
statements.push(
|
||||
db.insert(incidents).values({
|
||||
monitorId: monitor.id,
|
||||
status: 'investigating',
|
||||
impact: 'major',
|
||||
source: 'auto',
|
||||
startedAt: checkedAt,
|
||||
startStatusCode: result.statusCode,
|
||||
startError: result.error,
|
||||
createdAt: checkedAt,
|
||||
updatedAt: checkedAt,
|
||||
}),
|
||||
db.insert(incidentMonitors).values({ incidentId: sql`last_insert_rowid()`, monitorId: monitor.id }),
|
||||
);
|
||||
transition = 'opened';
|
||||
} else if (monitor.lastOk === false && result.ok) {
|
||||
const openIncidentIds = db
|
||||
.select({ id: incidentMonitors.incidentId })
|
||||
.from(incidentMonitors)
|
||||
.innerJoin(incidents, eq(incidents.id, incidentMonitors.incidentId))
|
||||
.where(and(eq(incidentMonitors.monitorId, monitor.id), eq(incidents.source, 'auto'), isNull(incidents.resolvedAt)));
|
||||
statements.push(
|
||||
db.insert(incidentUpdates).select(
|
||||
db
|
||||
.select({
|
||||
id: sql<number | null>`null`.as('id'),
|
||||
incidentId: incidents.id,
|
||||
status: sql<string>`'resolved'`.as('status'),
|
||||
body: sql<string>`${RECOVERY_UPDATE_BODY}`.as('body'),
|
||||
note: sql<string | null>`null`.as('note'),
|
||||
source: sql<string>`'system'`.as('source'),
|
||||
createdAt: sql<Date>`${checkedAt.getTime()}`.as('created_at'),
|
||||
})
|
||||
.from(incidents)
|
||||
.where(and(eq(incidents.source, 'auto'), isNull(incidents.resolvedAt), inArray(incidents.id, openIncidentIds))),
|
||||
),
|
||||
db
|
||||
.update(incidents)
|
||||
.set({
|
||||
status: 'resolved',
|
||||
resolvedAt: checkedAt,
|
||||
durationMs: sql`${checkedAt.getTime()} - ${incidents.startedAt}`,
|
||||
updatedAt: checkedAt,
|
||||
})
|
||||
.where(and(eq(incidents.monitorId, monitor.id), isNull(incidents.resolvedAt))),
|
||||
.where(and(eq(incidents.source, 'auto'), isNull(incidents.resolvedAt), inArray(incidents.id, openIncidentIds))),
|
||||
);
|
||||
transition = 'resolved';
|
||||
}
|
||||
|
||||
+34
-5
@@ -121,19 +121,48 @@ export const incidents = sqliteTable(
|
||||
'incidents',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
monitorId: integer('monitor_id')
|
||||
.notNull()
|
||||
.references(() => monitors.id, { onDelete: 'cascade' }),
|
||||
title: text('title'),
|
||||
status: text('status').notNull().default('investigating'),
|
||||
impact: text('impact').notNull().default('major'),
|
||||
source: text('source').notNull().default('auto'),
|
||||
startedAt: integer('started_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
resolvedAt: integer('resolved_at', { mode: 'timestamp_ms' }),
|
||||
startStatusCode: integer('start_status_code'),
|
||||
startError: text('start_error'),
|
||||
aiMessage: text('ai_message'),
|
||||
durationMs: integer('duration_ms'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
},
|
||||
(table) => [index('incidents_monitor_id_started_at_idx').on(table.monitorId, table.startedAt)],
|
||||
(table) => [index('incidents_started_at_idx').on(table.startedAt), index('incidents_resolved_at_idx').on(table.resolvedAt)],
|
||||
);
|
||||
|
||||
export const incidentMonitors = sqliteTable(
|
||||
'incident_monitors',
|
||||
{
|
||||
incidentId: integer('incident_id')
|
||||
.notNull()
|
||||
.references(() => incidents.id, { onDelete: 'cascade' }),
|
||||
monitorId: integer('monitor_id')
|
||||
.notNull()
|
||||
.references(() => monitors.id, { onDelete: 'cascade' }),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.incidentId, table.monitorId] }), index('incident_monitors_monitor_id_idx').on(table.monitorId)],
|
||||
);
|
||||
|
||||
export const incidentUpdates = sqliteTable(
|
||||
'incident_updates',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
incidentId: integer('incident_id')
|
||||
.notNull()
|
||||
.references(() => incidents.id, { onDelete: 'cascade' }),
|
||||
status: text('status').notNull(),
|
||||
body: text('body').notNull(),
|
||||
note: text('note'),
|
||||
source: text('source').notNull().default('manual'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
},
|
||||
(table) => [index('incident_updates_incident_id_created_at_idx').on(table.incidentId, table.createdAt)],
|
||||
);
|
||||
|
||||
export const monitorDailyStats = sqliteTable(
|
||||
|
||||
@@ -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 incidentRoutes from './routes/incidents';
|
||||
import maintenanceRoutes from './routes/maintenance';
|
||||
import monitorRoutes from './routes/monitors';
|
||||
import settingsRoutes from './routes/settings';
|
||||
@@ -26,6 +27,7 @@ app.get('/api/health', async (context) => {
|
||||
});
|
||||
|
||||
app.route('/', authRoutes);
|
||||
app.route('/api/incidents', incidentRoutes);
|
||||
app.route('/api/maintenance', maintenanceRoutes);
|
||||
app.route('/api/monitors', monitorRoutes);
|
||||
app.route('/api/settings', settingsRoutes);
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { desc, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
import { IncidentDraftError, draftIncidentUpdate, type IncidentStatus } from '../ai/incident-draft';
|
||||
import { getDb, type Database } from '../db/client';
|
||||
import { incidentMonitors, incidents, incidentUpdates, monitors } from '../db/schema';
|
||||
import { requireAuth, type AuthVariables } from '../lib/require-auth';
|
||||
import { parseInteger } from './monitors';
|
||||
|
||||
const STATUSES = new Set<IncidentStatus>(['investigating', 'identified', 'monitoring', 'resolved']);
|
||||
const IMPACTS = new Set(['none', 'minor', 'major', 'critical']);
|
||||
|
||||
type ParsedIncidentInput = {
|
||||
title?: string;
|
||||
impact?: string;
|
||||
status?: IncidentStatus;
|
||||
body?: string;
|
||||
note?: string | null;
|
||||
monitorIds?: number[];
|
||||
};
|
||||
type ParseResult = { ok: true; value: ParsedIncidentInput } | { ok: false; message: string };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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 parseLimit(raw: string | undefined, fallback = 50, maximum = 200) {
|
||||
if (!raw) return fallback;
|
||||
const parsed = parseInteger(Number(raw), 'limit', 1, maximum);
|
||||
return parsed.ok ? parsed.value : fallback;
|
||||
}
|
||||
|
||||
function parseText(value: unknown, label: string, minimum: number, maximum: number) {
|
||||
if (typeof value !== 'string' || value.trim().length < minimum || value.trim().length > maximum) {
|
||||
return { ok: false as const, message: `${label} must be between ${minimum} and ${maximum} characters` };
|
||||
}
|
||||
return { ok: true as const, value: value.trim() };
|
||||
}
|
||||
|
||||
export function parseIncidentInput(body: unknown, partial = false): ParseResult {
|
||||
if (!isRecord(body)) return { ok: false, message: 'Invalid request body' };
|
||||
const value: ParsedIncidentInput = {};
|
||||
if (!partial || 'title' in body) {
|
||||
const parsed = parseText(body.title, 'Title', 1, 120);
|
||||
if (!parsed.ok) return parsed;
|
||||
value.title = parsed.value;
|
||||
}
|
||||
if (!partial || 'impact' in body) {
|
||||
if (typeof body.impact !== 'string' || !IMPACTS.has(body.impact)) return { ok: false, message: 'Invalid impact' };
|
||||
value.impact = body.impact;
|
||||
}
|
||||
if (!partial || 'status' in body) {
|
||||
if (typeof body.status !== 'string' || !STATUSES.has(body.status as IncidentStatus)) {
|
||||
return { ok: false, message: 'Invalid incident status' };
|
||||
}
|
||||
value.status = body.status as IncidentStatus;
|
||||
}
|
||||
if (!partial || 'body' in body) {
|
||||
const parsed = parseText(body.body, 'Body', 1, 2000);
|
||||
if (!parsed.ok) return parsed;
|
||||
value.body = parsed.value;
|
||||
}
|
||||
if ('note' in body) {
|
||||
if (body.note !== null && (typeof body.note !== 'string' || body.note.length > 1000)) {
|
||||
return { ok: false, message: 'Note must be at most 1000 characters' };
|
||||
}
|
||||
value.note = typeof body.note === 'string' && body.note.trim() ? body.note.trim() : null;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
async function loadMonitorIds(db: Database, incidentIds: number[]) {
|
||||
if (incidentIds.length === 0) return new Map<number, number[]>();
|
||||
const rows = await db
|
||||
.select({ incidentId: incidentMonitors.incidentId, monitorId: incidentMonitors.monitorId })
|
||||
.from(incidentMonitors)
|
||||
.where(inArray(incidentMonitors.incidentId, incidentIds));
|
||||
const result = new Map<number, number[]>();
|
||||
for (const row of rows) {
|
||||
const ids = result.get(row.incidentId);
|
||||
if (ids) ids.push(row.monitorId);
|
||||
else result.set(row.incidentId, [row.monitorId]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function loadIncident(db: Database, id: number) {
|
||||
const [incident] = await db.select().from(incidents).where(eq(incidents.id, id)).limit(1);
|
||||
if (!incident) return null;
|
||||
const [monitorIds, updates] = await Promise.all([
|
||||
loadMonitorIds(db, [id]),
|
||||
db.select().from(incidentUpdates).where(eq(incidentUpdates.incidentId, id)).orderBy(incidentUpdates.createdAt),
|
||||
]);
|
||||
return { ...incident, monitorIds: monitorIds.get(id) ?? [], updates };
|
||||
}
|
||||
|
||||
async function readJson(context: { req: { json(): Promise<unknown> } }) {
|
||||
try {
|
||||
return { ok: true as const, body: await context.req.json() };
|
||||
} catch {
|
||||
return { ok: false as const, body: null };
|
||||
}
|
||||
}
|
||||
|
||||
const incidentRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>();
|
||||
incidentRoutes.use('*', requireAuth);
|
||||
|
||||
incidentRoutes.get('/', async (context) => {
|
||||
const db = getDb(context.env);
|
||||
const status = context.req.query('status') ?? 'open';
|
||||
if (status !== 'open' && status !== 'all') return context.json({ message: 'status must be open or all' }, 400);
|
||||
const limit = parseLimit(context.req.query('limit'));
|
||||
const rows = await db
|
||||
.select({
|
||||
id: incidents.id,
|
||||
title: incidents.title,
|
||||
status: incidents.status,
|
||||
impact: incidents.impact,
|
||||
source: incidents.source,
|
||||
startedAt: incidents.startedAt,
|
||||
resolvedAt: incidents.resolvedAt,
|
||||
durationMs: incidents.durationMs,
|
||||
createdAt: incidents.createdAt,
|
||||
updatedAt: incidents.updatedAt,
|
||||
updateCount: sql<number>`(select count(*) from incident_updates where incident_id = ${incidents.id})`,
|
||||
})
|
||||
.from(incidents)
|
||||
.where(status === 'open' ? isNull(incidents.resolvedAt) : undefined)
|
||||
.orderBy(desc(incidents.startedAt))
|
||||
.limit(limit);
|
||||
const monitorIds = await loadMonitorIds(
|
||||
db,
|
||||
rows.map((row) => row.id),
|
||||
);
|
||||
return context.json({ incidents: rows.map((row) => ({ ...row, monitorIds: monitorIds.get(row.id) ?? [] })) });
|
||||
});
|
||||
|
||||
incidentRoutes.post('/draft', async (context) => {
|
||||
const input = await readJson(context);
|
||||
if (!input.ok || !isRecord(input.body)) return context.json({ message: 'Invalid request body' }, 400);
|
||||
const note = parseText(input.body.note, 'Note', 1, 1000);
|
||||
if (!note.ok) return context.json({ message: note.message }, 400);
|
||||
if (typeof input.body.status !== 'string' || !STATUSES.has(input.body.status as IncidentStatus)) {
|
||||
return context.json({ message: 'Invalid incident status' }, 400);
|
||||
}
|
||||
const monitorIds = input.body.monitorIds ?? [];
|
||||
if (!Array.isArray(monitorIds) || monitorIds.some((id) => !Number.isSafeInteger(id) || id <= 0)) {
|
||||
return context.json({ message: 'monitorIds must be an array of positive integers' }, 400);
|
||||
}
|
||||
try {
|
||||
return context.json(
|
||||
await draftIncidentUpdate(context.env, {
|
||||
note: note.value,
|
||||
status: input.body.status as IncidentStatus,
|
||||
withTitle: true,
|
||||
serviceCount: new Set(monitorIds).size,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof IncidentDraftError) return context.json({ message: error.message }, error.status);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
incidentRoutes.get('/:id', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Incident not found' }, 404);
|
||||
const incident = await loadIncident(getDb(context.env), id);
|
||||
return incident ? context.json({ incident }) : context.json({ message: 'Incident not found' }, 404);
|
||||
});
|
||||
|
||||
incidentRoutes.post('/', async (context) => {
|
||||
const input = await readJson(context);
|
||||
if (!input.ok) return context.json({ message: 'Invalid request body' }, 400);
|
||||
const parsed = parseIncidentInput(input.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 = Date.now();
|
||||
const statements: D1PreparedStatement[] = [
|
||||
context.env.DB.prepare(
|
||||
`INSERT INTO incidents (title, status, impact, source, started_at, resolved_at, duration_ms, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'manual', ?, ?, ?, ?, ?)`,
|
||||
).bind(
|
||||
parsed.value.title,
|
||||
parsed.value.status,
|
||||
parsed.value.impact,
|
||||
now,
|
||||
parsed.value.status === 'resolved' ? now : null,
|
||||
parsed.value.status === 'resolved' ? 0 : null,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
];
|
||||
if (monitorIds.length > 0) {
|
||||
statements.push(
|
||||
context.env.DB.prepare(
|
||||
`WITH inserted_incident(id) AS MATERIALIZED (SELECT last_insert_rowid())
|
||||
INSERT INTO incident_monitors (incident_id, monitor_id)
|
||||
SELECT inserted_incident.id, column1 FROM inserted_incident, (VALUES ${monitorIds.map(() => '(?)').join(', ')})`,
|
||||
).bind(...monitorIds),
|
||||
);
|
||||
}
|
||||
statements.push(
|
||||
context.env.DB.prepare(
|
||||
`INSERT INTO incident_updates (incident_id, status, body, note, source, created_at)
|
||||
VALUES (${monitorIds.length > 0 ? '(SELECT incident_id FROM incident_monitors ORDER BY rowid DESC LIMIT 1)' : 'last_insert_rowid()'}, ?, ?, ?, 'manual', ?)`,
|
||||
).bind(parsed.value.status, parsed.value.body, parsed.value.note ?? null, now),
|
||||
);
|
||||
const results = await context.env.DB.batch(statements);
|
||||
const id = Number(results[0].meta.last_row_id);
|
||||
const incident = await loadIncident(db, id);
|
||||
return context.json({ incident }, 201);
|
||||
});
|
||||
|
||||
incidentRoutes.patch('/:id', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Incident not found' }, 404);
|
||||
const input = await readJson(context);
|
||||
if (!input.ok) return context.json({ message: 'Invalid request body' }, 400);
|
||||
const parsed = parseIncidentInput(input.body, true);
|
||||
if (!parsed.ok) return context.json({ message: parsed.message }, 400);
|
||||
const allowed = { title: parsed.value.title, impact: parsed.value.impact, monitorIds: parsed.value.monitorIds };
|
||||
if (Object.values(allowed).every((value) => value === undefined)) return context.json({ message: 'Provide a field to update' }, 400);
|
||||
const db = getDb(context.env);
|
||||
if (!(await loadIncident(db, id))) return context.json({ message: 'Incident not found' }, 404);
|
||||
if (parsed.value.monitorIds && !(await allMonitorsExist(db, parsed.value.monitorIds))) {
|
||||
return context.json({ message: 'One or more monitors do not exist' }, 400);
|
||||
}
|
||||
const statements = [];
|
||||
if (parsed.value.title !== undefined || parsed.value.impact !== undefined) {
|
||||
statements.push(
|
||||
db
|
||||
.update(incidents)
|
||||
.set({ title: parsed.value.title, impact: parsed.value.impact, updatedAt: new Date() })
|
||||
.where(eq(incidents.id, id)),
|
||||
);
|
||||
}
|
||||
if (parsed.value.monitorIds) {
|
||||
statements.push(db.delete(incidentMonitors).where(eq(incidentMonitors.incidentId, id)));
|
||||
statements.push(...parsed.value.monitorIds.map((monitorId) => db.insert(incidentMonitors).values({ incidentId: id, monitorId })));
|
||||
}
|
||||
if (statements.length > 0) await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
|
||||
return context.json({ incident: await loadIncident(db, id) });
|
||||
});
|
||||
|
||||
incidentRoutes.post('/:id/updates/draft', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Incident not found' }, 404);
|
||||
const input = await readJson(context);
|
||||
if (!input.ok || !isRecord(input.body)) return context.json({ message: 'Invalid request body' }, 400);
|
||||
const note = parseText(input.body.note, 'Note', 1, 1000);
|
||||
if (!note.ok) return context.json({ message: note.message }, 400);
|
||||
if (typeof input.body.status !== 'string' || !STATUSES.has(input.body.status as IncidentStatus)) {
|
||||
return context.json({ message: 'Invalid incident status' }, 400);
|
||||
}
|
||||
const db = getDb(context.env);
|
||||
const incident = await loadIncident(db, id);
|
||||
if (!incident) return context.json({ message: 'Incident not found' }, 404);
|
||||
const previousUpdates = await db
|
||||
.select({ status: incidentUpdates.status, body: incidentUpdates.body })
|
||||
.from(incidentUpdates)
|
||||
.where(eq(incidentUpdates.incidentId, id))
|
||||
.orderBy(desc(incidentUpdates.createdAt))
|
||||
.limit(3);
|
||||
try {
|
||||
const draft = await draftIncidentUpdate(context.env, {
|
||||
note: note.value,
|
||||
status: input.body.status as IncidentStatus,
|
||||
withTitle: false,
|
||||
incidentTitle: incident.title,
|
||||
previousUpdates: previousUpdates.reverse(),
|
||||
serviceCount: incident.monitorIds.length,
|
||||
});
|
||||
return context.json({ body: draft.body });
|
||||
} catch (error) {
|
||||
if (error instanceof IncidentDraftError) return context.json({ message: error.message }, error.status);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
incidentRoutes.post('/:id/updates', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Incident not found' }, 404);
|
||||
const input = await readJson(context);
|
||||
if (!input.ok) return context.json({ message: 'Invalid request body' }, 400);
|
||||
const parsed = parseIncidentInput(input.body, true);
|
||||
if (!parsed.ok) return context.json({ message: parsed.message }, 400);
|
||||
if (!parsed.value.status || !parsed.value.body) return context.json({ message: 'Status and body are required' }, 400);
|
||||
const db = getDb(context.env);
|
||||
const [existing] = await db.select().from(incidents).where(eq(incidents.id, id)).limit(1);
|
||||
if (!existing) return context.json({ message: 'Incident not found' }, 404);
|
||||
const now = new Date();
|
||||
const resolved = parsed.value.status === 'resolved';
|
||||
await db.batch([
|
||||
db.insert(incidentUpdates).values({
|
||||
incidentId: id,
|
||||
status: parsed.value.status,
|
||||
body: parsed.value.body,
|
||||
note: parsed.value.note ?? null,
|
||||
source: 'manual',
|
||||
createdAt: now,
|
||||
}),
|
||||
db
|
||||
.update(incidents)
|
||||
.set({
|
||||
status: parsed.value.status,
|
||||
resolvedAt: resolved ? now : null,
|
||||
durationMs: resolved ? Math.max(0, now.getTime() - existing.startedAt.getTime()) : null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(incidents.id, id)),
|
||||
]);
|
||||
return context.json({ incident: await loadIncident(db, id) });
|
||||
});
|
||||
|
||||
incidentRoutes.delete('/:id', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Incident not found' }, 404);
|
||||
const db = getDb(context.env);
|
||||
const [existing] = await db.select({ id: incidents.id }).from(incidents).where(eq(incidents.id, id)).limit(1);
|
||||
if (!existing) return context.json({ message: 'Incident not found' }, 404);
|
||||
await db.batch([
|
||||
db.delete(incidentMonitors).where(eq(incidentMonitors.incidentId, id)),
|
||||
db.delete(incidentUpdates).where(eq(incidentUpdates.incidentId, id)),
|
||||
db.delete(incidents).where(eq(incidents.id, id)),
|
||||
]);
|
||||
return context.json({ ok: true });
|
||||
});
|
||||
|
||||
export default incidentRoutes;
|
||||
@@ -134,9 +134,9 @@ maintenanceRoutes.post('/', async (context) => {
|
||||
now.getTime(),
|
||||
),
|
||||
...monitorIds.map((monitorId) =>
|
||||
context.env.DB.prepare(
|
||||
'INSERT INTO maintenance_window_monitors (window_id, monitor_id) VALUES ((SELECT max(id) FROM maintenance_windows), ?)',
|
||||
).bind(monitorId),
|
||||
context.env.DB.prepare('INSERT INTO maintenance_window_monitors (window_id, monitor_id) VALUES (last_insert_rowid(), ?)').bind(
|
||||
monitorId,
|
||||
),
|
||||
),
|
||||
]);
|
||||
const id = Number(results[0].meta.last_row_id);
|
||||
@@ -185,7 +185,7 @@ maintenanceRoutes.patch('/:id', async (context) => {
|
||||
}
|
||||
|
||||
const { monitorIds, ...changes } = parsed.value;
|
||||
const statements = [
|
||||
const statements: Parameters<Database['batch']>[0][number][] = [
|
||||
db
|
||||
.update(maintenanceWindows)
|
||||
.set({ ...changes, updatedAt: new Date() })
|
||||
|
||||
@@ -4,7 +4,7 @@ import { generateIncidentMessage } from '../ai/incident-message';
|
||||
import { buildResultStatements } from '../checks/persist-result';
|
||||
import { runCheck } from '../checks/run-check';
|
||||
import { getDb } from '../db/client';
|
||||
import { checks, incidents, maintenanceWindowMonitors, monitors } from '../db/schema';
|
||||
import { checks, incidentMonitors, incidents, maintenanceWindowMonitors, monitors } from '../db/schema';
|
||||
import { requireAuth, type AuthVariables } from '../lib/require-auth';
|
||||
import { loadActiveMaintenance } from '../maintenance/windows';
|
||||
import { isSafeRemoteUrl } from '../lib/safe-url';
|
||||
@@ -347,13 +347,38 @@ monitorRoutes.get('/:id/incidents', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Monitor not found' }, 404);
|
||||
const limit = parseLimit(context.req.query('limit'), 50, 200);
|
||||
const rows = await getDb(context.env)
|
||||
.select()
|
||||
const db = getDb(context.env);
|
||||
const rows = await db
|
||||
.select({
|
||||
id: incidents.id,
|
||||
title: incidents.title,
|
||||
status: incidents.status,
|
||||
impact: incidents.impact,
|
||||
source: incidents.source,
|
||||
startedAt: incidents.startedAt,
|
||||
resolvedAt: incidents.resolvedAt,
|
||||
startStatusCode: incidents.startStatusCode,
|
||||
startError: incidents.startError,
|
||||
durationMs: incidents.durationMs,
|
||||
createdAt: incidents.createdAt,
|
||||
updatedAt: incidents.updatedAt,
|
||||
latestUpdate: sql<{ body: string; status: string; createdAt: number } | null>`(
|
||||
select json_object('body', body, 'status', status, 'createdAt', created_at)
|
||||
from incident_updates where incident_id = ${incidents.id}
|
||||
order by created_at desc, id desc limit 1
|
||||
)`,
|
||||
})
|
||||
.from(incidents)
|
||||
.where(eq(incidents.monitorId, id))
|
||||
.innerJoin(incidentMonitors, eq(incidentMonitors.incidentId, incidents.id))
|
||||
.where(eq(incidentMonitors.monitorId, id))
|
||||
.orderBy(desc(incidents.startedAt))
|
||||
.limit(limit);
|
||||
return context.json({ incidents: rows });
|
||||
return context.json({
|
||||
incidents: rows.map((row) => ({
|
||||
...row,
|
||||
latestUpdate: typeof row.latestUpdate === 'string' ? JSON.parse(row.latestUpdate) : row.latestUpdate,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
monitorRoutes.get('/:id/stats', async (context) => {
|
||||
@@ -399,9 +424,10 @@ monitorRoutes.get('/:id/stats', async (context) => {
|
||||
const [incidentAggregate] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(incidents)
|
||||
.innerJoin(incidentMonitors, eq(incidentMonitors.incidentId, incidents.id))
|
||||
.where(
|
||||
and(
|
||||
eq(incidents.monitorId, id),
|
||||
eq(incidentMonitors.monitorId, id),
|
||||
gte(incidents.startedAt, new Date(window.start)),
|
||||
or(isNull(incidents.resolvedAt), gte(incidents.resolvedAt, new Date(window.start))),
|
||||
),
|
||||
@@ -515,6 +541,7 @@ monitorRoutes.delete('/:id', async (context) => {
|
||||
|
||||
await db.batch([
|
||||
db.delete(maintenanceWindowMonitors).where(eq(maintenanceWindowMonitors.monitorId, id)),
|
||||
db.delete(incidentMonitors).where(eq(incidentMonitors.monitorId, id)),
|
||||
db.delete(checks).where(eq(checks.monitorId, id)),
|
||||
db.delete(monitors).where(eq(monitors.id, id)),
|
||||
]);
|
||||
|
||||
+329
-213
@@ -1,213 +1,329 @@
|
||||
import { and, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
import { deterministicIncidentMessage } from '../ai/fallback-message';
|
||||
import { getDb } from '../db/client';
|
||||
import { checks, incidents, monitorDailyStats, monitors } from '../db/schema';
|
||||
import { loadActiveMaintenance, type ActiveMaintenance } from '../maintenance/windows';
|
||||
import { resolveFavicon } from './monitors';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const FAVICON_CACHE_SECONDS = 86_400;
|
||||
|
||||
type ServiceStatus = 'up' | 'down' | 'unknown' | 'maintenance';
|
||||
type OverallStatus = 'operational' | 'degraded' | 'down';
|
||||
|
||||
type HistoryEntry = {
|
||||
day: number;
|
||||
uptimePct: number | null;
|
||||
};
|
||||
|
||||
type DailyAggregate = {
|
||||
monitorId: number;
|
||||
day: Date;
|
||||
totalChecks: number;
|
||||
upChecks: number;
|
||||
};
|
||||
|
||||
type OpenIncident = {
|
||||
monitorId: number;
|
||||
aiMessage: string | null;
|
||||
startStatusCode: number | null;
|
||||
};
|
||||
|
||||
type EdgeCache = {
|
||||
match(request: RequestInfo | URL): Promise<Response | undefined>;
|
||||
put(request: RequestInfo | URL, response: Response): Promise<void>;
|
||||
};
|
||||
|
||||
function parseId(rawId: string) {
|
||||
const id = Number(rawId);
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
function roundUptime(upChecks: number, totalChecks: number) {
|
||||
return totalChecks > 0 ? Math.round((upChecks / totalChecks) * 1_000) / 10 : null;
|
||||
}
|
||||
|
||||
function serviceStatus(lastOk: boolean | null): ServiceStatus {
|
||||
if (lastOk === true) return 'up';
|
||||
if (lastOk === false) return 'down';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function overallStatus(statuses: ServiceStatus[]): OverallStatus {
|
||||
let checked = 0;
|
||||
let down = 0;
|
||||
for (const status of statuses) {
|
||||
if (status === 'unknown' || status === 'maintenance') continue;
|
||||
checked += 1;
|
||||
if (status === 'down') down += 1;
|
||||
}
|
||||
if (down === 0) return 'operational';
|
||||
if (down === checked) return 'down';
|
||||
return 'degraded';
|
||||
}
|
||||
|
||||
const statusRoutes = new Hono<{ Bindings: Env }>();
|
||||
|
||||
statusRoutes.get('/', async (context) => {
|
||||
const db = getDb(context.env);
|
||||
const monitorRows = await db
|
||||
.select({
|
||||
id: monitors.id,
|
||||
name: monitors.name,
|
||||
lastOk: monitors.lastOk,
|
||||
lastCheckedAt: monitors.lastCheckedAt,
|
||||
})
|
||||
.from(monitors)
|
||||
.where(eq(monitors.enabled, true))
|
||||
.orderBy(monitors.createdAt);
|
||||
|
||||
const now = new Date();
|
||||
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
const cutoff = today - 89 * DAY_MS;
|
||||
const monitorIds = monitorRows.map((monitor) => monitor.id);
|
||||
let historicalRows: DailyAggregate[] = [];
|
||||
let todayRows: DailyAggregate[] = [];
|
||||
let openIncidentRows: OpenIncident[] = [];
|
||||
let activeMaintenance = new Map<number, ActiveMaintenance>();
|
||||
|
||||
if (monitorIds.length > 0) {
|
||||
[historicalRows, todayRows, openIncidentRows, activeMaintenance] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
monitorId: monitorDailyStats.monitorId,
|
||||
day: monitorDailyStats.day,
|
||||
totalChecks: monitorDailyStats.totalChecks,
|
||||
upChecks: monitorDailyStats.upChecks,
|
||||
})
|
||||
.from(monitorDailyStats)
|
||||
.where(
|
||||
and(
|
||||
inArray(monitorDailyStats.monitorId, monitorIds),
|
||||
gte(monitorDailyStats.day, new Date(cutoff)),
|
||||
lt(monitorDailyStats.day, new Date(today)),
|
||||
),
|
||||
)
|
||||
.orderBy(monitorDailyStats.day),
|
||||
db
|
||||
.select({
|
||||
monitorId: checks.monitorId,
|
||||
day: sql<Date>`cast(${today} as integer)`,
|
||||
totalChecks: sql<number>`count(*)`,
|
||||
upChecks: sql<number>`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
|
||||
})
|
||||
.from(checks)
|
||||
.where(and(inArray(checks.monitorId, monitorIds), eq(checks.maintenance, false), gte(checks.checkedAt, new Date(today))))
|
||||
.groupBy(checks.monitorId),
|
||||
db
|
||||
.select({
|
||||
monitorId: incidents.monitorId,
|
||||
aiMessage: incidents.aiMessage,
|
||||
startStatusCode: incidents.startStatusCode,
|
||||
})
|
||||
.from(incidents)
|
||||
.where(and(inArray(incidents.monitorId, monitorIds), isNull(incidents.resolvedAt))),
|
||||
loadActiveMaintenance(db, now),
|
||||
]);
|
||||
}
|
||||
|
||||
const bucketsByMonitor = new Map<number, DailyAggregate[]>();
|
||||
for (const row of [...historicalRows, ...todayRows]) {
|
||||
const buckets = bucketsByMonitor.get(row.monitorId);
|
||||
if (buckets) buckets.push(row);
|
||||
else bucketsByMonitor.set(row.monitorId, [row]);
|
||||
}
|
||||
const openIncidentsByMonitor = new Map(openIncidentRows.map((incident) => [incident.monitorId, incident]));
|
||||
|
||||
const services = monitorRows.map((monitor) => {
|
||||
const buckets = bucketsByMonitor.get(monitor.id) ?? [];
|
||||
let totalChecks = 0;
|
||||
let upChecks = 0;
|
||||
const history: HistoryEntry[] = buckets.map((bucket) => {
|
||||
totalChecks += bucket.totalChecks;
|
||||
upChecks += bucket.upChecks;
|
||||
return {
|
||||
day: bucket.day instanceof Date ? bucket.day.getTime() : Number(bucket.day),
|
||||
uptimePct: roundUptime(bucket.upChecks, bucket.totalChecks),
|
||||
};
|
||||
});
|
||||
|
||||
const openIncident = openIncidentsByMonitor.get(monitor.id);
|
||||
const maintenance = activeMaintenance.get(monitor.id);
|
||||
return {
|
||||
id: monitor.id,
|
||||
name: monitor.name,
|
||||
status: maintenance ? ('maintenance' as const) : serviceStatus(monitor.lastOk),
|
||||
message:
|
||||
!maintenance && monitor.lastOk === false
|
||||
? (openIncident?.aiMessage ?? deterministicIncidentMessage(openIncident?.startStatusCode ?? null))
|
||||
: null,
|
||||
maintenance: maintenance ? { name: maintenance.name, endsAt: maintenance.endsAt.toISOString() } : null,
|
||||
lastCheckedAt: monitor.lastCheckedAt?.toISOString() ?? null,
|
||||
uptime90d: roundUptime(upChecks, totalChecks),
|
||||
history,
|
||||
};
|
||||
});
|
||||
|
||||
return context.json({
|
||||
overall: overallStatus(services.map((service) => service.status)),
|
||||
updatedAt: Date.now(),
|
||||
services,
|
||||
});
|
||||
});
|
||||
|
||||
statusRoutes.get('/:id/favicon', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Service not found' }, 404);
|
||||
const [monitor] = await getDb(context.env)
|
||||
.select({ url: monitors.url })
|
||||
.from(monitors)
|
||||
.where(and(eq(monitors.id, id), eq(monitors.enabled, true)))
|
||||
.limit(1);
|
||||
if (!monitor) return context.json({ message: 'Service not found' }, 404);
|
||||
|
||||
const cacheKey = new Request(`${new URL(context.req.url).origin}/api/status/${id}/favicon`);
|
||||
let cache: EdgeCache | null = null;
|
||||
try {
|
||||
const defaultCache = (caches as CacheStorage & { readonly default: EdgeCache }).default;
|
||||
const cached = await defaultCache.match(cacheKey);
|
||||
if (cached) return cached;
|
||||
cache = defaultCache;
|
||||
} catch {
|
||||
// Cache API availability is best-effort, particularly in local and preview environments.
|
||||
}
|
||||
|
||||
const favicon = await resolveFavicon(monitor.url);
|
||||
if (!favicon) return context.json({ message: 'No favicon' }, 404);
|
||||
|
||||
const response = new Response(favicon.body, {
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${FAVICON_CACHE_SECONDS}`,
|
||||
'Content-Length': String(favicon.body.byteLength),
|
||||
'Content-Type': favicon.contentType,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
if (cache) {
|
||||
context.executionCtx.waitUntil(cache.put(cacheKey, response.clone()).catch(() => undefined));
|
||||
}
|
||||
return response;
|
||||
});
|
||||
|
||||
export default statusRoutes;
|
||||
import { and, desc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
import { deterministicIncidentMessage } from '../ai/fallback-message';
|
||||
import { getDb } from '../db/client';
|
||||
import { checks, incidentMonitors, incidents, incidentUpdates, monitorDailyStats, monitors } from '../db/schema';
|
||||
import { loadActiveMaintenance, type ActiveMaintenance } from '../maintenance/windows';
|
||||
import { resolveFavicon } from './monitors';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const FAVICON_CACHE_SECONDS = 86_400;
|
||||
type ServiceStatus = 'up' | 'down' | 'unknown' | 'maintenance';
|
||||
type OverallStatus = 'operational' | 'degraded' | 'down';
|
||||
type DailyAggregate = { monitorId: number; day: Date; totalChecks: number; upChecks: number };
|
||||
type EdgeCache = {
|
||||
match(request: RequestInfo | URL): Promise<Response | undefined>;
|
||||
put(request: RequestInfo | URL, response: Response): Promise<void>;
|
||||
};
|
||||
|
||||
type PublicUpdate = { body: string; status: string; createdAt: Date };
|
||||
type PublicIncident = {
|
||||
id: number;
|
||||
title: string | null;
|
||||
status: string;
|
||||
impact: string;
|
||||
source: string;
|
||||
startedAt: Date;
|
||||
resolvedAt: Date | null;
|
||||
durationMs: number | null;
|
||||
startStatusCode: number | null;
|
||||
};
|
||||
|
||||
function parseId(rawId: string) {
|
||||
const id = Number(rawId);
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
function parseLimit(raw: string | undefined, fallback: number, maximum: number) {
|
||||
if (!raw) return fallback;
|
||||
const value = Number(raw);
|
||||
return Number.isSafeInteger(value) && value > 0 ? Math.min(value, maximum) : fallback;
|
||||
}
|
||||
function roundUptime(upChecks: number, totalChecks: number) {
|
||||
return totalChecks > 0 ? Math.round((upChecks / totalChecks) * 1_000) / 10 : null;
|
||||
}
|
||||
function serviceStatus(lastOk: boolean | null): ServiceStatus {
|
||||
if (lastOk === true) return 'up';
|
||||
if (lastOk === false) return 'down';
|
||||
return 'unknown';
|
||||
}
|
||||
function overallStatus(statuses: ServiceStatus[], manualImpacts: string[]): OverallStatus {
|
||||
let checked = 0;
|
||||
let down = 0;
|
||||
for (const status of statuses) {
|
||||
if (status === 'unknown' || status === 'maintenance') continue;
|
||||
checked += 1;
|
||||
if (status === 'down') down += 1;
|
||||
}
|
||||
let severity = down === 0 ? 0 : down === checked ? 2 : 1;
|
||||
for (const impact of manualImpacts)
|
||||
severity = Math.max(severity, impact === 'critical' ? 2 : impact === 'minor' || impact === 'major' ? 1 : 0);
|
||||
return severity === 2 ? 'down' : severity === 1 ? 'degraded' : 'operational';
|
||||
}
|
||||
function publicIncidentTitle(incident: Pick<PublicIncident, 'title' | 'source'>) {
|
||||
return incident.title ?? (incident.source === 'auto' ? 'Service disruption' : 'Incident update');
|
||||
}
|
||||
|
||||
async function loadServices(db: ReturnType<typeof getDb>, incidentIds: number[]) {
|
||||
if (incidentIds.length === 0) return new Map<number, Array<{ id: number; name: string }>>();
|
||||
const rows = await db
|
||||
.select({ incidentId: incidentMonitors.incidentId, id: monitors.id, name: monitors.name })
|
||||
.from(incidentMonitors)
|
||||
.innerJoin(monitors, eq(monitors.id, incidentMonitors.monitorId))
|
||||
.where(inArray(incidentMonitors.incidentId, incidentIds));
|
||||
const grouped = new Map<number, Array<{ id: number; name: string }>>();
|
||||
for (const row of rows) {
|
||||
const services = grouped.get(row.incidentId);
|
||||
if (services) services.push({ id: row.id, name: row.name });
|
||||
else grouped.set(row.incidentId, [{ id: row.id, name: row.name }]);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
async function loadLatestUpdates(db: ReturnType<typeof getDb>, incidentIds: number[]) {
|
||||
if (incidentIds.length === 0) return new Map<number, PublicUpdate>();
|
||||
const rows = await db
|
||||
.select({
|
||||
incidentId: incidentUpdates.incidentId,
|
||||
body: incidentUpdates.body,
|
||||
status: incidentUpdates.status,
|
||||
createdAt: incidentUpdates.createdAt,
|
||||
})
|
||||
.from(incidentUpdates)
|
||||
.where(inArray(incidentUpdates.incidentId, incidentIds))
|
||||
.orderBy(desc(incidentUpdates.createdAt), desc(incidentUpdates.id));
|
||||
const latest = new Map<number, PublicUpdate>();
|
||||
for (const row of rows) if (!latest.has(row.incidentId)) latest.set(row.incidentId, row);
|
||||
return latest;
|
||||
}
|
||||
|
||||
const incidentSelection = {
|
||||
id: incidents.id,
|
||||
title: incidents.title,
|
||||
status: incidents.status,
|
||||
impact: incidents.impact,
|
||||
source: incidents.source,
|
||||
startedAt: incidents.startedAt,
|
||||
resolvedAt: incidents.resolvedAt,
|
||||
durationMs: incidents.durationMs,
|
||||
startStatusCode: incidents.startStatusCode,
|
||||
};
|
||||
|
||||
const statusRoutes = new Hono<{ Bindings: Env }>();
|
||||
|
||||
statusRoutes.get('/', async (context) => {
|
||||
const db = getDb(context.env);
|
||||
const monitorRows = await db
|
||||
.select({
|
||||
id: monitors.id,
|
||||
name: monitors.name,
|
||||
lastOk: monitors.lastOk,
|
||||
lastStatusCode: monitors.lastStatusCode,
|
||||
lastCheckedAt: monitors.lastCheckedAt,
|
||||
})
|
||||
.from(monitors)
|
||||
.where(eq(monitors.enabled, true))
|
||||
.orderBy(monitors.createdAt);
|
||||
const activeIncidentRows = await db
|
||||
.select(incidentSelection)
|
||||
.from(incidents)
|
||||
.where(isNull(incidents.resolvedAt))
|
||||
.orderBy(desc(incidents.startedAt));
|
||||
const incidentIds = activeIncidentRows.map((incident) => incident.id);
|
||||
const [incidentServices, latestUpdates] = await Promise.all([loadServices(db, incidentIds), loadLatestUpdates(db, incidentIds)]);
|
||||
|
||||
const now = new Date();
|
||||
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
const cutoff = today - 89 * DAY_MS;
|
||||
const monitorIds = monitorRows.map((monitor) => monitor.id);
|
||||
let historicalRows: DailyAggregate[] = [];
|
||||
let todayRows: DailyAggregate[] = [];
|
||||
let activeMaintenance = new Map<number, ActiveMaintenance>();
|
||||
if (monitorIds.length > 0) {
|
||||
[historicalRows, todayRows, activeMaintenance] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
monitorId: monitorDailyStats.monitorId,
|
||||
day: monitorDailyStats.day,
|
||||
totalChecks: monitorDailyStats.totalChecks,
|
||||
upChecks: monitorDailyStats.upChecks,
|
||||
})
|
||||
.from(monitorDailyStats)
|
||||
.where(
|
||||
and(
|
||||
inArray(monitorDailyStats.monitorId, monitorIds),
|
||||
gte(monitorDailyStats.day, new Date(cutoff)),
|
||||
lt(monitorDailyStats.day, new Date(today)),
|
||||
),
|
||||
)
|
||||
.orderBy(monitorDailyStats.day),
|
||||
db
|
||||
.select({
|
||||
monitorId: checks.monitorId,
|
||||
day: sql<Date>`cast(${today} as integer)`,
|
||||
totalChecks: sql<number>`count(*)`,
|
||||
upChecks: sql<number>`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
|
||||
})
|
||||
.from(checks)
|
||||
.where(and(inArray(checks.monitorId, monitorIds), eq(checks.maintenance, false), gte(checks.checkedAt, new Date(today))))
|
||||
.groupBy(checks.monitorId),
|
||||
loadActiveMaintenance(db, now),
|
||||
]);
|
||||
}
|
||||
const bucketsByMonitor = new Map<number, DailyAggregate[]>();
|
||||
for (const row of [...historicalRows, ...todayRows]) {
|
||||
const buckets = bucketsByMonitor.get(row.monitorId);
|
||||
if (buckets) buckets.push(row);
|
||||
else bucketsByMonitor.set(row.monitorId, [row]);
|
||||
}
|
||||
const activeIncidentByMonitor = new Map<number, PublicIncident>();
|
||||
for (const incident of activeIncidentRows) {
|
||||
for (const service of incidentServices.get(incident.id) ?? [])
|
||||
if (!activeIncidentByMonitor.has(service.id)) activeIncidentByMonitor.set(service.id, incident);
|
||||
}
|
||||
const services = monitorRows.map((monitor) => {
|
||||
const buckets = bucketsByMonitor.get(monitor.id) ?? [];
|
||||
let totalChecks = 0;
|
||||
let upChecks = 0;
|
||||
const history = buckets.map((bucket) => {
|
||||
totalChecks += bucket.totalChecks;
|
||||
upChecks += bucket.upChecks;
|
||||
return {
|
||||
day: bucket.day instanceof Date ? bucket.day.getTime() : Number(bucket.day),
|
||||
uptimePct: roundUptime(bucket.upChecks, bucket.totalChecks),
|
||||
};
|
||||
});
|
||||
const incident = activeIncidentByMonitor.get(monitor.id);
|
||||
const maintenance = activeMaintenance.get(monitor.id);
|
||||
return {
|
||||
id: monitor.id,
|
||||
name: monitor.name,
|
||||
status: maintenance ? ('maintenance' as const) : serviceStatus(monitor.lastOk),
|
||||
message:
|
||||
!maintenance && monitor.lastOk === false
|
||||
? incident
|
||||
? (latestUpdates.get(incident.id)?.body ?? deterministicIncidentMessage(incident.startStatusCode))
|
||||
: deterministicIncidentMessage(monitor.lastStatusCode)
|
||||
: null,
|
||||
maintenance: maintenance ? { name: maintenance.name, endsAt: maintenance.endsAt.toISOString() } : null,
|
||||
lastCheckedAt: monitor.lastCheckedAt?.toISOString() ?? null,
|
||||
uptime90d: roundUptime(upChecks, totalChecks),
|
||||
history,
|
||||
};
|
||||
});
|
||||
const activeIncidents = activeIncidentRows.map((incident) => ({
|
||||
id: incident.id,
|
||||
title: publicIncidentTitle(incident),
|
||||
status: incident.status,
|
||||
impact: incident.impact,
|
||||
source: incident.source,
|
||||
startedAt: incident.startedAt.toISOString(),
|
||||
latestUpdate: latestUpdates.get(incident.id) ?? null,
|
||||
services: incidentServices.get(incident.id) ?? [],
|
||||
}));
|
||||
return context.json({
|
||||
overall: overallStatus(
|
||||
services.map((service) => service.status),
|
||||
activeIncidentRows.filter((incident) => incident.source === 'manual').map((incident) => incident.impact),
|
||||
),
|
||||
updatedAt: Date.now(),
|
||||
services,
|
||||
activeIncidents,
|
||||
});
|
||||
});
|
||||
|
||||
statusRoutes.get('/incidents', async (context) => {
|
||||
const db = getDb(context.env);
|
||||
const limit = parseLimit(context.req.query('limit'), 20, 20);
|
||||
const rows = await db
|
||||
.select(incidentSelection)
|
||||
.from(incidents)
|
||||
.where(and(eq(incidents.status, 'resolved'), gte(incidents.resolvedAt, new Date(Date.now() - 30 * DAY_MS))))
|
||||
.orderBy(desc(incidents.resolvedAt))
|
||||
.limit(limit);
|
||||
const services = await loadServices(
|
||||
db,
|
||||
rows.map((row) => row.id),
|
||||
);
|
||||
return context.json({
|
||||
incidents: rows.map((incident) => ({
|
||||
id: incident.id,
|
||||
title: publicIncidentTitle(incident),
|
||||
status: incident.status,
|
||||
impact: incident.impact,
|
||||
source: incident.source,
|
||||
startedAt: incident.startedAt.toISOString(),
|
||||
resolvedAt: incident.resolvedAt?.toISOString() ?? null,
|
||||
durationMs: incident.durationMs,
|
||||
services: services.get(incident.id) ?? [],
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
statusRoutes.get('/incidents/:id', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Incident not found' }, 404);
|
||||
const db = getDb(context.env);
|
||||
const [incident] = await db.select(incidentSelection).from(incidents).where(eq(incidents.id, id)).limit(1);
|
||||
if (!incident) return context.json({ message: 'Incident not found' }, 404);
|
||||
const [services, updates] = await Promise.all([
|
||||
loadServices(db, [id]),
|
||||
db
|
||||
.select({ status: incidentUpdates.status, body: incidentUpdates.body, createdAt: incidentUpdates.createdAt })
|
||||
.from(incidentUpdates)
|
||||
.where(eq(incidentUpdates.incidentId, id))
|
||||
.orderBy(incidentUpdates.createdAt, incidentUpdates.id),
|
||||
]);
|
||||
const timeline =
|
||||
updates.length > 0
|
||||
? updates
|
||||
: [{ status: incident.status, body: deterministicIncidentMessage(incident.startStatusCode), createdAt: incident.startedAt }];
|
||||
return context.json({
|
||||
incident: {
|
||||
id: incident.id,
|
||||
title: publicIncidentTitle(incident),
|
||||
status: incident.status,
|
||||
impact: incident.impact,
|
||||
source: incident.source,
|
||||
startedAt: incident.startedAt.toISOString(),
|
||||
resolvedAt: incident.resolvedAt?.toISOString() ?? null,
|
||||
durationMs: incident.durationMs,
|
||||
services: services.get(id) ?? [],
|
||||
updates: timeline,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
statusRoutes.get('/:id/favicon', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Service not found' }, 404);
|
||||
const [monitor] = await getDb(context.env)
|
||||
.select({ url: monitors.url })
|
||||
.from(monitors)
|
||||
.where(and(eq(monitors.id, id), eq(monitors.enabled, true)))
|
||||
.limit(1);
|
||||
if (!monitor) return context.json({ message: 'Service not found' }, 404);
|
||||
const cacheKey = new Request(`${new URL(context.req.url).origin}/api/status/${id}/favicon`);
|
||||
let cache: EdgeCache | null = null;
|
||||
try {
|
||||
const defaultCache = (caches as CacheStorage & { readonly default: EdgeCache }).default;
|
||||
const cached = await defaultCache.match(cacheKey);
|
||||
if (cached) return cached;
|
||||
cache = defaultCache;
|
||||
} catch {
|
||||
// Cache API availability is best-effort.
|
||||
}
|
||||
const favicon = await resolveFavicon(monitor.url);
|
||||
if (!favicon) return context.json({ message: 'No favicon' }, 404);
|
||||
const response = new Response(favicon.body, {
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${FAVICON_CACHE_SECONDS}`,
|
||||
'Content-Length': String(favicon.body.byteLength),
|
||||
'Content-Type': favicon.contentType,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
if (cache) context.executionCtx.waitUntil(cache.put(cacheKey, response.clone()).catch(() => undefined));
|
||||
return response;
|
||||
});
|
||||
|
||||
export default statusRoutes;
|
||||
|
||||
+30
-13
@@ -1,4 +1,5 @@
|
||||
import { applyD1Migrations, env, SELF, type D1Migration } from 'cloudflare:test';
|
||||
import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
|
||||
import { env, exports as worker } from 'cloudflare:workers';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { deterministicIncidentMessage } from '../src/worker/ai/fallback-message';
|
||||
import { generateIncidentMessage } from '../src/worker/ai/incident-message';
|
||||
@@ -36,6 +37,8 @@ const failedResult = {
|
||||
async function resetDatabase() {
|
||||
await env.DB.batch([
|
||||
env.DB.prepare('DELETE FROM checks'),
|
||||
env.DB.prepare('DELETE FROM incident_updates'),
|
||||
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 ai_settings'),
|
||||
@@ -59,10 +62,18 @@ async function seedMonitorAndIncident(aiMessage: string | null = null) {
|
||||
.bind(monitor.name, monitor.url, monitor.lastError, now, now, now)
|
||||
.run();
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO incidents (monitor_id, started_at, resolved_at, start_status_code, start_error, ai_message, duration_ms, created_at, updated_at) VALUES (1, ?, NULL, 503, ?, ?, NULL, ?, ?)',
|
||||
"INSERT INTO incidents (id, status, impact, source, started_at, resolved_at, start_status_code, start_error, duration_ms, created_at, updated_at) VALUES (1, 'investigating', 'major', 'auto', ?, NULL, 503, ?, NULL, ?, ?)",
|
||||
)
|
||||
.bind(now, monitor.lastError, aiMessage, now, now)
|
||||
.bind(now, monitor.lastError, now, now)
|
||||
.run();
|
||||
await env.DB.prepare('INSERT INTO incident_monitors (incident_id, monitor_id) VALUES (1, 1)').run();
|
||||
if (aiMessage) {
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO incident_updates (incident_id, status, body, source, created_at) VALUES (1, 'investigating', ?, 'ai', ?)",
|
||||
)
|
||||
.bind(aiMessage, now)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
async function seedAiSettings(enabled = true) {
|
||||
@@ -79,7 +90,7 @@ async function authenticatedCookie() {
|
||||
await env.DB.prepare('INSERT INTO admin_credentials (id, password_hash, created_at, updated_at) VALUES (1, ?, ?, ?)')
|
||||
.bind(await hashPassword(ADMIN_PASSWORD), now, now)
|
||||
.run();
|
||||
const response = await SELF.fetch('https://example.com/api/auth/login', {
|
||||
const response = await worker.default.fetch('https://example.com/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: 'https://example.com' },
|
||||
body: JSON.stringify({ password: ADMIN_PASSWORD }),
|
||||
@@ -88,7 +99,7 @@ async function authenticatedCookie() {
|
||||
}
|
||||
|
||||
async function settingsRequest(path: string, cookie: string, init?: RequestInit) {
|
||||
return SELF.fetch(`https://example.com/api/settings${path}`, {
|
||||
return worker.default.fetch(`https://example.com/api/settings${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -185,7 +196,9 @@ describe('AI incident messages', () => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(generateIncidentMessage(env, { monitor, result: failedResult })).resolves.toBe(expected);
|
||||
const incident = await env.DB.prepare('SELECT ai_message AS aiMessage FROM incidents').first<{ aiMessage: string | null }>();
|
||||
const incident = await env.DB.prepare("SELECT body AS aiMessage FROM incident_updates WHERE source = 'ai'").first<{
|
||||
aiMessage: string | null;
|
||||
}>();
|
||||
expect(incident?.aiMessage).toBe(expected);
|
||||
|
||||
const promptText = String(JSON.parse(String(fetchMock.mock.calls[0][1]?.body)).messages[1].content);
|
||||
@@ -214,8 +227,10 @@ describe('AI incident messages', () => {
|
||||
vi.stubGlobal('fetch', vi.fn(implementation));
|
||||
|
||||
await expect(generateIncidentMessage(env, { monitor, result: failedResult })).resolves.toBeNull();
|
||||
const incident = await env.DB.prepare('SELECT ai_message AS aiMessage FROM incidents').first<{ aiMessage: string | null }>();
|
||||
expect(incident?.aiMessage).toBeNull();
|
||||
const incident = await env.DB.prepare("SELECT body AS aiMessage FROM incident_updates WHERE source = 'ai'").first<{
|
||||
aiMessage: string | null;
|
||||
}>();
|
||||
expect(incident).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects generated content containing a URL', async () => {
|
||||
@@ -227,22 +242,24 @@ describe('AI incident messages', () => {
|
||||
);
|
||||
|
||||
await expect(generateIncidentMessage(env, { monitor, result: failedResult })).resolves.toBeNull();
|
||||
const incident = await env.DB.prepare('SELECT ai_message AS aiMessage FROM incidents').first<{ aiMessage: string | null }>();
|
||||
expect(incident?.aiMessage).toBeNull();
|
||||
const incident = await env.DB.prepare("SELECT body AS aiMessage FROM incident_updates WHERE source = 'ai'").first<{
|
||||
aiMessage: string | null;
|
||||
}>();
|
||||
expect(incident).toBeNull();
|
||||
});
|
||||
|
||||
it('returns stored AI copy and deterministic fallback copy on public status', async () => {
|
||||
await seedMonitorAndIncident('Customers may see delayed API responses.');
|
||||
let body = await (
|
||||
await SELF.fetch('https://example.com/api/status')
|
||||
await worker.default.fetch('https://example.com/api/status')
|
||||
).json<{
|
||||
services: Array<{ message: string | null }>;
|
||||
}>();
|
||||
expect(body.services[0].message).toBe('Customers may see delayed API responses.');
|
||||
|
||||
await env.DB.prepare('UPDATE incidents SET ai_message = NULL').run();
|
||||
await env.DB.prepare('DELETE FROM incident_updates').run();
|
||||
body = await (
|
||||
await SELF.fetch('https://example.com/api/status')
|
||||
await worker.default.fetch('https://example.com/api/status')
|
||||
).json<{
|
||||
services: Array<{ message: string | null }>;
|
||||
}>();
|
||||
|
||||
+7
-6
@@ -1,4 +1,5 @@
|
||||
import { applyD1Migrations, env, SELF, type D1Migration } from 'cloudflare:test';
|
||||
import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
|
||||
import { env, exports as worker } from 'cloudflare:workers';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { hashPassword } from '../src/worker/lib/password';
|
||||
|
||||
@@ -18,7 +19,7 @@ async function seedAdmin() {
|
||||
}
|
||||
|
||||
function login(password = ADMIN_PASSWORD, ipAddress = '198.51.100.10') {
|
||||
return SELF.fetch('https://example.com/api/auth/login', {
|
||||
return worker.default.fetch('https://example.com/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -65,12 +66,12 @@ describe('authentication', () => {
|
||||
});
|
||||
|
||||
it('returns authentication state from the session endpoint', async () => {
|
||||
const anonymousResponse = await SELF.fetch('https://example.com/api/auth/me');
|
||||
const anonymousResponse = await worker.default.fetch('https://example.com/api/auth/me');
|
||||
expect(anonymousResponse.status).toBe(200);
|
||||
expect(await anonymousResponse.json()).toEqual({ authenticated: false });
|
||||
|
||||
const loginResponse = await login();
|
||||
const authenticatedResponse = await SELF.fetch('https://example.com/api/auth/me', {
|
||||
const authenticatedResponse = await worker.default.fetch('https://example.com/api/auth/me', {
|
||||
headers: { Cookie: cookieFrom(loginResponse) },
|
||||
});
|
||||
|
||||
@@ -81,7 +82,7 @@ describe('authentication', () => {
|
||||
it('revokes the persisted session on logout', async () => {
|
||||
const loginResponse = await login();
|
||||
const cookie = cookieFrom(loginResponse);
|
||||
const logoutResponse = await SELF.fetch('https://example.com/api/auth/logout', {
|
||||
const logoutResponse = await worker.default.fetch('https://example.com/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
@@ -94,7 +95,7 @@ describe('authentication', () => {
|
||||
const sessionCount = await env.DB.prepare('SELECT COUNT(*) AS count FROM sessions').first<{ count: number }>();
|
||||
expect(sessionCount?.count).toBe(0);
|
||||
|
||||
const sessionResponse = await SELF.fetch('https://example.com/api/auth/me', {
|
||||
const sessionResponse = await worker.default.fetch('https://example.com/api/auth/me', {
|
||||
headers: { Cookie: cookie },
|
||||
});
|
||||
expect(await sessionResponse.json()).toEqual({ authenticated: false });
|
||||
|
||||
+31
-8
@@ -1,4 +1,5 @@
|
||||
import { applyD1Migrations, env, type D1Migration } from 'cloudflare:test';
|
||||
import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { runDueChecks } from '../src/worker/checks/run-due-checks';
|
||||
|
||||
@@ -7,6 +8,8 @@ async function clearMonitoringTables() {
|
||||
env.DB.prepare('DELETE FROM maintenance_window_monitors'),
|
||||
env.DB.prepare('DELETE FROM maintenance_windows'),
|
||||
env.DB.prepare('DELETE FROM checks'),
|
||||
env.DB.prepare('DELETE FROM incident_updates'),
|
||||
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'),
|
||||
@@ -103,7 +106,7 @@ describe('scheduled monitor checks', () => {
|
||||
last_error: 'Expected HTTP 200, received 500',
|
||||
});
|
||||
const incident = await env.DB.prepare(
|
||||
'SELECT monitor_id, resolved_at, start_status_code, start_error FROM incidents WHERE monitor_id = ?',
|
||||
'SELECT im.monitor_id, i.resolved_at, i.start_status_code, i.start_error FROM incidents i JOIN incident_monitors im ON im.incident_id = i.id WHERE im.monitor_id = ?',
|
||||
)
|
||||
.bind(id)
|
||||
.first<{ monitor_id: number; resolved_at: number | null; start_status_code: number; start_error: string }>();
|
||||
@@ -122,26 +125,46 @@ describe('scheduled monitor checks', () => {
|
||||
vi.fn(async () => new Response(null, { status: 503 })),
|
||||
);
|
||||
await runDueChecks(env);
|
||||
const count = await env.DB.prepare('SELECT COUNT(*) AS count FROM incidents WHERE monitor_id = ?').bind(id).first<{ count: number }>();
|
||||
const count = await env.DB.prepare('SELECT COUNT(*) AS count FROM incident_monitors WHERE monitor_id = ?')
|
||||
.bind(id)
|
||||
.first<{ count: number }>();
|
||||
expect(count?.count).toBe(0);
|
||||
});
|
||||
|
||||
it('links each auto incident to the correct monitor in one scheduled batch', async () => {
|
||||
const firstId = await insertMonitor({ name: 'First', url: 'https://first.example.com' });
|
||||
const secondId = await insertMonitor({ name: 'Second', url: 'https://second.example.com' });
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(null, { status: 503 })),
|
||||
);
|
||||
|
||||
await runDueChecks(env);
|
||||
const assignments = await env.DB.prepare('SELECT incident_id, monitor_id FROM incident_monitors ORDER BY incident_id').all<{
|
||||
incident_id: number;
|
||||
monitor_id: number;
|
||||
}>();
|
||||
expect(assignments.results.map((row) => row.monitor_id)).toEqual([firstId, secondId]);
|
||||
expect(new Set(assignments.results.map((row) => row.incident_id)).size).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves the open incident on recovery', async () => {
|
||||
const id = await insertMonitor({ last_ok: 0 });
|
||||
const startedAt = Date.now() - 60_000;
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO incidents (monitor_id, started_at, start_status_code, start_error, created_at, updated_at) VALUES (?, ?, 500, 'Down', ?, ?)",
|
||||
const inserted = await env.DB.prepare(
|
||||
"INSERT INTO incidents (status, impact, source, started_at, start_status_code, start_error, created_at, updated_at) VALUES ('investigating', 'major', 'auto', ?, 500, 'Down', ?, ?)",
|
||||
)
|
||||
.bind(id, startedAt, startedAt, startedAt)
|
||||
.bind(startedAt, startedAt, startedAt)
|
||||
.run();
|
||||
await env.DB.prepare('INSERT INTO incident_monitors (incident_id, monitor_id) VALUES (?, ?)').bind(inserted.meta.last_row_id, id).run();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(null, { status: 200 })),
|
||||
);
|
||||
|
||||
await runDueChecks(env);
|
||||
const incident = await env.DB.prepare('SELECT resolved_at, duration_ms FROM incidents WHERE monitor_id = ?')
|
||||
.bind(id)
|
||||
const incident = await env.DB.prepare('SELECT resolved_at, duration_ms FROM incidents WHERE id = ?')
|
||||
.bind(inserted.meta.last_row_id)
|
||||
.first<{ resolved_at: number | null; duration_ms: number | null }>();
|
||||
expect(incident?.resolved_at).toEqual(expect.any(Number));
|
||||
expect(incident?.duration_ms).toBeGreaterThanOrEqual(60_000);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
|
||||
import { env, exports as worker } from 'cloudflare:workers';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
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 incident_updates'),
|
||||
env.DB.prepare('DELETE FROM incident_monitors'),
|
||||
env.DB.prepare('DELETE FROM incidents'),
|
||||
env.DB.prepare('DELETE FROM monitors'),
|
||||
env.DB.prepare('DELETE FROM ai_settings'),
|
||||
env.DB.prepare('DELETE FROM sessions'),
|
||||
env.DB.prepare('DELETE FROM admin_credentials'),
|
||||
]);
|
||||
const now = Date.now();
|
||||
await env.DB.prepare('INSERT INTO admin_credentials (id, password_hash, created_at, updated_at) VALUES (1, ?, ?, ?)')
|
||||
.bind(await hashPassword(PASSWORD), now, now)
|
||||
.run();
|
||||
}
|
||||
async function cookie() {
|
||||
const response = await worker.default.fetch('https://example.com/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: 'https://example.com' },
|
||||
body: JSON.stringify({ password: PASSWORD }),
|
||||
});
|
||||
return response.headers.get('Set-Cookie')?.split(';', 1)[0] ?? '';
|
||||
}
|
||||
function post(path: string, auth: string, body: unknown) {
|
||||
return worker.default.fetch(`https://example.com${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: 'https://example.com', ...(auth ? { Cookie: auth } : {}) },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
async function enableAi() {
|
||||
const now = Date.now();
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO ai_settings (id, enabled, base_url, api_key, model, created_at, updated_at) VALUES (1, 1, 'https://api.example.com/v1', 'secret', 'small', ?, ?)",
|
||||
)
|
||||
.bind(now, now)
|
||||
.run();
|
||||
}
|
||||
|
||||
describe('AI incident drafts', () => {
|
||||
beforeAll(async () => applyD1Migrations(env.DB, (env as Env & { TEST_MIGRATIONS: D1Migration[] }).TEST_MIGRATIONS));
|
||||
beforeEach(reset);
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
it('requires authentication and returns 409 without configured AI', async () => {
|
||||
expect((await post('/api/incidents/draft', '', { note: 'down', status: 'investigating' })).status).toBe(401);
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
expect((await post('/api/incidents/draft', await cookie(), { note: 'down', status: 'investigating' })).status).toBe(409);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it('returns a clean draft without writing database rows', async () => {
|
||||
await enableAi();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
Response.json({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content:
|
||||
'TITLE: Delayed customer requests\nBODY: Some requests are taking longer than expected. We are working to restore normal performance.',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
const response = await post('/api/incidents/draft', await cookie(), {
|
||||
note: 'redis full memory, scale RAM',
|
||||
status: 'identified',
|
||||
monitorIds: [],
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
title: 'Delayed customer requests',
|
||||
body: 'Some requests are taking longer than expected. We are working to restore normal performance.',
|
||||
});
|
||||
expect((await env.DB.prepare('SELECT count(*) AS count FROM incidents').first<{ count: number }>())?.count).toBe(0);
|
||||
expect((await env.DB.prepare('SELECT count(*) AS count FROM incident_updates').first<{ count: number }>())?.count).toBe(0);
|
||||
});
|
||||
it('rejects unsafe model output and varies guidance by lifecycle status', async () => {
|
||||
await enableAi();
|
||||
const calls: string[] = [];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (_input, init) => {
|
||||
calls.push(String(init?.body));
|
||||
return Response.json({
|
||||
choices: [{ message: { content: 'TITLE: Service issue\nBODY: See https://internal.example.com HTTP 500.' } }],
|
||||
});
|
||||
}),
|
||||
);
|
||||
const auth = await cookie();
|
||||
expect((await post('/api/incidents/draft', auth, { note: 'same', status: 'identified', monitorIds: [] })).status).toBe(422);
|
||||
expect((await post('/api/incidents/draft', auth, { note: 'same', status: 'resolved', monitorIds: [] })).status).toBe(422);
|
||||
expect(calls[0]).toContain('cause has been identified');
|
||||
expect(calls[1]).toContain('operating normally again');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
|
||||
import { env, exports as worker } from 'cloudflare:workers';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
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 incident_updates'),
|
||||
env.DB.prepare('DELETE FROM incident_monitors'),
|
||||
env.DB.prepare('DELETE FROM incidents'),
|
||||
env.DB.prepare('DELETE FROM checks'),
|
||||
env.DB.prepare('DELETE FROM monitors'),
|
||||
env.DB.prepare('DELETE FROM sessions'),
|
||||
env.DB.prepare('DELETE FROM admin_credentials'),
|
||||
env.DB.prepare('DELETE FROM ai_settings'),
|
||||
]);
|
||||
const now = Date.now();
|
||||
await env.DB.prepare('INSERT INTO admin_credentials (id, password_hash, created_at, updated_at) VALUES (1, ?, ?, ?)')
|
||||
.bind(await hashPassword(PASSWORD), now, now)
|
||||
.run();
|
||||
}
|
||||
async function cookie() {
|
||||
const response = await worker.default.fetch('https://example.com/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: 'https://example.com' },
|
||||
body: JSON.stringify({ password: PASSWORD }),
|
||||
});
|
||||
return response.headers.get('Set-Cookie')?.split(';', 1)[0] ?? '';
|
||||
}
|
||||
function request(path: string, method = 'GET', auth = '', body?: unknown) {
|
||||
return worker.default.fetch(`https://example.com${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(auth ? { Cookie: auth } : {}),
|
||||
...(method !== 'GET' ? { Origin: 'https://example.com' } : {}),
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
async function monitor(name: string) {
|
||||
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, last_ok, created_at, updated_at) VALUES (?, 'https://example.com', 'GET', 200, 300, 10000, 1, 1, 1, ?, ?)",
|
||||
)
|
||||
.bind(name, now, now)
|
||||
.run();
|
||||
return Number(result.meta.last_row_id);
|
||||
}
|
||||
|
||||
describe('incident lifecycle API', () => {
|
||||
beforeAll(async () => applyD1Migrations(env.DB, (env as Env & { TEST_MIGRATIONS: D1Migration[] }).TEST_MIGRATIONS));
|
||||
beforeEach(reset);
|
||||
it('protects admin incident routes', async () => {
|
||||
for (const [path, method] of [
|
||||
['/api/incidents', 'GET'],
|
||||
['/api/incidents', 'POST'],
|
||||
['/api/incidents/draft', 'POST'],
|
||||
['/api/incidents/1', 'GET'],
|
||||
['/api/incidents/1/updates', 'POST'],
|
||||
] as const)
|
||||
expect((await request(path, method)).status).toBe(401);
|
||||
});
|
||||
it('creates a multi-service manual incident and resolves it with an update', async () => {
|
||||
const ids = await Promise.all(['API', 'Web', 'Jobs'].map(monitor));
|
||||
const auth = await cookie();
|
||||
const created = await request('/api/incidents', 'POST', auth, {
|
||||
title: 'Delayed requests',
|
||||
impact: 'critical',
|
||||
status: 'investigating',
|
||||
body: 'Some requests are taking longer than expected. We are investigating.',
|
||||
note: 'redis memory',
|
||||
monitorIds: ids,
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
const body = await created.json<{ incident: { id: number; monitorIds: number[]; updates: unknown[] } }>();
|
||||
expect(body.incident.monitorIds).toHaveLength(3);
|
||||
expect(body.incident.updates).toHaveLength(1);
|
||||
const resolved = await request(`/api/incidents/${body.incident.id}/updates`, 'POST', auth, {
|
||||
status: 'resolved',
|
||||
body: 'Service is operating normally again.',
|
||||
note: 'scaled',
|
||||
});
|
||||
expect(resolved.status).toBe(200);
|
||||
const row = await env.DB.prepare('SELECT status, resolved_at, duration_ms FROM incidents WHERE id = ?').bind(body.incident.id).first();
|
||||
expect(row).toMatchObject({ status: 'resolved' });
|
||||
expect(row?.resolved_at).toEqual(expect.any(Number));
|
||||
});
|
||||
it('publishes a service-less critical incident without leaking internal notes', async () => {
|
||||
const auth = await cookie();
|
||||
const created = await request('/api/incidents', 'POST', auth, {
|
||||
title: 'Sign-in disruption',
|
||||
impact: 'critical',
|
||||
status: 'investigating',
|
||||
body: 'Some customers cannot sign in. We are investigating.',
|
||||
note: 'internal secret redis',
|
||||
monitorIds: [],
|
||||
});
|
||||
const incident = (await created.json<{ incident: { id: number } }>()).incident;
|
||||
const status = await (await request('/api/status')).json<{ overall: string; activeIncidents: Array<{ services: unknown[] }> }>();
|
||||
expect(status.overall).toBe('down');
|
||||
expect(status.activeIncidents[0].services).toEqual([]);
|
||||
const detailText = await (await request(`/api/status/incidents/${incident.id}`)).text();
|
||||
expect(detailText).not.toContain('internal secret');
|
||||
expect(detailText).not.toContain('note');
|
||||
});
|
||||
it('keeps an incident after its assigned monitor is deleted', async () => {
|
||||
const id = await monitor('API');
|
||||
const auth = await cookie();
|
||||
const created = await request('/api/incidents', 'POST', auth, {
|
||||
title: 'API issue',
|
||||
impact: 'major',
|
||||
status: 'investigating',
|
||||
body: 'Some requests are failing.',
|
||||
monitorIds: [id],
|
||||
});
|
||||
const incidentId = (await created.json<{ incident: { id: number } }>()).incident.id;
|
||||
expect((await request(`/api/monitors/${id}`, 'DELETE', auth)).status).toBe(200);
|
||||
expect(await env.DB.prepare('SELECT id FROM incidents WHERE id = ?').bind(incidentId).first()).toBeTruthy();
|
||||
expect(await env.DB.prepare('SELECT * FROM incident_monitors WHERE incident_id = ?').bind(incidentId).first()).toBeNull();
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { exports as worker } from 'cloudflare:workers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('uptime monitoring Worker', () => {
|
||||
it('returns a successful D1 health response', async () => {
|
||||
const response = await SELF.fetch('https://example.com/api/health');
|
||||
const response = await worker.default.fetch('https://example.com/api/health');
|
||||
const body = await response.json<{
|
||||
ok: boolean;
|
||||
db: { ok: number } | null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { applyD1Migrations, env, type D1Migration } from 'cloudflare:test';
|
||||
import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { runDueChecks } from '../src/worker/checks/run-due-checks';
|
||||
import type { MaintenanceWindowRow } from '../src/worker/maintenance/windows';
|
||||
@@ -23,6 +24,8 @@ async function resetDatabase() {
|
||||
env.DB.prepare('DELETE FROM maintenance_window_monitors'),
|
||||
env.DB.prepare('DELETE FROM maintenance_windows'),
|
||||
env.DB.prepare('DELETE FROM checks'),
|
||||
env.DB.prepare('DELETE FROM incident_updates'),
|
||||
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'),
|
||||
@@ -82,7 +85,7 @@ describe('maintenance windows', () => {
|
||||
await runDueChecks(env);
|
||||
const check = await env.DB.prepare('SELECT maintenance, ok FROM checks WHERE monitor_id = ?').bind(id).first();
|
||||
const monitor = await env.DB.prepare('SELECT last_ok, last_status_code FROM monitors WHERE id = ?').bind(id).first();
|
||||
const incident = await env.DB.prepare('SELECT COUNT(*) AS count FROM incidents WHERE monitor_id = ?')
|
||||
const incident = await env.DB.prepare('SELECT COUNT(*) AS count FROM incident_monitors WHERE monitor_id = ?')
|
||||
.bind(id)
|
||||
.first<{ count: number }>();
|
||||
expect(check).toMatchObject({ maintenance: 1, ok: 0 });
|
||||
@@ -101,7 +104,7 @@ describe('maintenance windows', () => {
|
||||
|
||||
await runDueChecks(env);
|
||||
const check = await env.DB.prepare('SELECT maintenance FROM checks WHERE monitor_id = ?').bind(id).first();
|
||||
const incident = await env.DB.prepare('SELECT COUNT(*) AS count FROM incidents WHERE monitor_id = ?')
|
||||
const incident = await env.DB.prepare('SELECT COUNT(*) AS count FROM incident_monitors WHERE monitor_id = ?')
|
||||
.bind(id)
|
||||
.first<{ count: number }>();
|
||||
expect(check).toMatchObject({ maintenance: 0 });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { applyD1Migrations, env, SELF, type D1Migration } from 'cloudflare:test';
|
||||
import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
|
||||
import { env, exports as worker } from 'cloudflare:workers';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { hashPassword } from '../src/worker/lib/password';
|
||||
import { resolveFavicon } from '../src/worker/routes/monitors';
|
||||
@@ -16,6 +17,8 @@ const VALID_MONITOR = {
|
||||
async function seedAdmin() {
|
||||
await env.DB.batch([
|
||||
env.DB.prepare('DELETE FROM checks'),
|
||||
env.DB.prepare('DELETE FROM incident_updates'),
|
||||
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'),
|
||||
@@ -31,7 +34,7 @@ async function seedAdmin() {
|
||||
}
|
||||
|
||||
async function authenticatedCookie() {
|
||||
const response = await SELF.fetch('https://example.com/api/auth/login', {
|
||||
const response = await worker.default.fetch('https://example.com/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -44,7 +47,7 @@ async function authenticatedCookie() {
|
||||
}
|
||||
|
||||
function apiFetch(path: string, method = 'GET', cookie = '', body?: unknown) {
|
||||
return SELF.fetch(`https://example.com${path}`, {
|
||||
return worker.default.fetch(`https://example.com${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
@@ -194,8 +197,9 @@ describe('monitor API', () => {
|
||||
now - 1000,
|
||||
),
|
||||
env.DB.prepare(
|
||||
"INSERT INTO incidents (monitor_id, started_at, start_status_code, start_error, created_at, updated_at) VALUES (?, ?, 500, 'Down', ?, ?)",
|
||||
).bind(id, now - 1000, now - 1000, now - 1000),
|
||||
"INSERT INTO incidents (id, status, impact, source, started_at, start_status_code, start_error, created_at, updated_at) VALUES (100, 'investigating', 'major', 'auto', ?, 500, 'Down', ?, ?)",
|
||||
).bind(now - 1000, now - 1000, now - 1000),
|
||||
env.DB.prepare('INSERT INTO incident_monitors (incident_id, monitor_id) VALUES (100, ?)').bind(id),
|
||||
]);
|
||||
|
||||
const [detail, checksResponse, incidentsResponse, statsResponse] = await Promise.all([
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
import { applyD1Migrations, env, type D1Migration } from 'cloudflare:test';
|
||||
import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { runDailyRollup } from '../src/worker/scheduled/rollup';
|
||||
|
||||
|
||||
+4
-3
@@ -1,4 +1,5 @@
|
||||
import { applyD1Migrations, env, SELF, type D1Migration } from 'cloudflare:test';
|
||||
import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
|
||||
import { env, exports as worker } from 'cloudflare:workers';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -69,7 +70,7 @@ async function insertMonitor(input: {
|
||||
}
|
||||
|
||||
function statusFetch(path = '/api/status') {
|
||||
return SELF.fetch(`https://example.com${path}`);
|
||||
return worker.default.fetch(`https://example.com${path}`);
|
||||
}
|
||||
|
||||
describe('public status API', () => {
|
||||
@@ -182,7 +183,7 @@ describe('public status API', () => {
|
||||
});
|
||||
|
||||
it('keeps the administrative monitor collection protected', async () => {
|
||||
const response = await SELF.fetch('https://example.com/api/monitors');
|
||||
const response = await worker.default.fetch('https://example.com/api/monitors');
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toEqual({ message: 'Authentication required' });
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { applyD1Migrations, env, type D1Migration } from 'cloudflare:test';
|
||||
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';
|
||||
|
||||
Reference in New Issue
Block a user