feat: add incident and post history for services

This commit is contained in:
2026-08-30 09:07:50 +07:00
parent d63a46cac6
commit 7a96644c25
44 changed files with 3409 additions and 322 deletions
+51
View File
@@ -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`);
+969
View File
@@ -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": {}
}
}
+7
View File
@@ -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
View File
@@ -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>;
+68
View File
@@ -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);
}
+5 -2
View File
@@ -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;
+24
View File
@@ -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>
);
}
+2
View File
@@ -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 />
+59
View File
@@ -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>
);
}
+6 -3
View File
@@ -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>
+39 -10
View File
@@ -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>
+57
View File
@@ -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) });
}
+15 -1
View File
@@ -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) });
}
+424
View File
@@ -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;
+7 -2
View File
@@ -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
View File
@@ -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.';
+3 -2
View File
@@ -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(
+82
View File
@@ -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 };
}
+16 -20
View File
@@ -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(
+15
View File
@@ -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;
}
+28 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+348
View File
@@ -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;
+4 -4
View File
@@ -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() })
+33 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+105
View File
@@ -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');
});
});
+123
View File
@@ -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
View File
@@ -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;
+6 -3
View File
@@ -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 });
+9 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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' });
});
+2 -1
View File
@@ -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';