mirror of
https://github.com/Nezumi-2711/uptime-monitoring.git
synced 2026-09-22 13:48:31 +00:00
feat: add AI config for detect error automatically
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE `ai_settings` (
|
||||||
|
`id` integer PRIMARY KEY NOT NULL,
|
||||||
|
`enabled` integer DEFAULT false NOT NULL,
|
||||||
|
`base_url` text,
|
||||||
|
`api_key` text,
|
||||||
|
`model` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `incidents` ADD `ai_message` text;
|
||||||
@@ -0,0 +1,659 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "5c9f981a-cccd-4832-806c-4b156078d34d",
|
||||||
|
"prevId": "c137c06e-d876-42ae-a0a9-39115b3f0ebf",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"name": "incidents",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"ai_message": {
|
||||||
|
"name": "ai_message",
|
||||||
|
"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_monitor_id_started_at_idx": {
|
||||||
|
"name": "incidents_monitor_id_started_at_idx",
|
||||||
|
"columns": [
|
||||||
|
"monitor_id",
|
||||||
|
"started_at"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"incidents_monitor_id_monitors_id_fk": {
|
||||||
|
"name": "incidents_monitor_id_monitors_id_fk",
|
||||||
|
"tableFrom": "incidents",
|
||||||
|
"tableTo": "monitors",
|
||||||
|
"columnsFrom": [
|
||||||
|
"monitor_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,13 @@
|
|||||||
"when": 1787887541718,
|
"when": 1787887541718,
|
||||||
"tag": "0003_colorful_kingpin",
|
"tag": "0003_colorful_kingpin",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1787997596170,
|
||||||
|
"tag": "0004_smiling_jack_flag",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+9
-3
@@ -1,3 +1,4 @@
|
|||||||
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
import { RequireAuth } from './components/RequireAuth';
|
import { RequireAuth } from './components/RequireAuth';
|
||||||
import { usePathname } from './lib/router';
|
import { usePathname } from './lib/router';
|
||||||
import { DashboardPage } from './pages/DashboardPage';
|
import { DashboardPage } from './pages/DashboardPage';
|
||||||
@@ -8,8 +9,10 @@ import { StatusPage } from './pages/StatusPage';
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
if (pathname === '/') return <StatusPage />;
|
let content;
|
||||||
if (pathname === '/login') return <LoginPage />;
|
if (pathname === '/') content = <StatusPage />;
|
||||||
|
else if (pathname === '/login') content = <LoginPage />;
|
||||||
|
else {
|
||||||
const monitorMatch = pathname.match(/^\/monitors\/(\d+)\/?$/);
|
const monitorMatch = pathname.match(/^\/monitors\/(\d+)\/?$/);
|
||||||
const page = monitorMatch ? (
|
const page = monitorMatch ? (
|
||||||
<MonitorDetailPage id={Number(monitorMatch[1])} />
|
<MonitorDetailPage id={Number(monitorMatch[1])} />
|
||||||
@@ -18,7 +21,10 @@ function App() {
|
|||||||
) : (
|
) : (
|
||||||
<DashboardPage />
|
<DashboardPage />
|
||||||
);
|
);
|
||||||
return <RequireAuth>{page}</RequireAuth>;
|
content = <RequireAuth>{page}</RequireAuth>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <TooltipProvider>{content}</TooltipProvider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export type Incident = {
|
|||||||
resolvedAt: string | null;
|
resolvedAt: string | null;
|
||||||
startStatusCode: number | null;
|
startStatusCode: number | null;
|
||||||
startError: string | null;
|
startError: string | null;
|
||||||
|
aiMessage: string | null;
|
||||||
durationMs: number | null;
|
durationMs: number | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
@@ -10,6 +10,24 @@ export type NotificationSettings = {
|
|||||||
|
|
||||||
export type NotificationSettingsInput = Pick<NotificationSettings, 'webhookUrl' | 'webhookEnabled'>;
|
export type NotificationSettingsInput = Pick<NotificationSettings, 'webhookUrl' | 'webhookEnabled'>;
|
||||||
|
|
||||||
|
export type AiSettings = {
|
||||||
|
id: number;
|
||||||
|
enabled: boolean;
|
||||||
|
baseUrl: string | null;
|
||||||
|
model: string | null;
|
||||||
|
apiKeySet: boolean;
|
||||||
|
apiKeyPreview: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiSettingsInput = {
|
||||||
|
enabled: boolean;
|
||||||
|
baseUrl: string | null;
|
||||||
|
model: string | null;
|
||||||
|
apiKey?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export function getNotificationSettings(signal?: AbortSignal) {
|
export function getNotificationSettings(signal?: AbortSignal) {
|
||||||
return getJson<{ settings: NotificationSettings }>('/api/settings/notifications', {
|
return getJson<{ settings: NotificationSettings }>('/api/settings/notifications', {
|
||||||
signal,
|
signal,
|
||||||
@@ -24,3 +42,18 @@ export function updateNotificationSettings(input: NotificationSettingsInput) {
|
|||||||
export function testNotificationWebhook() {
|
export function testNotificationWebhook() {
|
||||||
return postJson<{ ok: true }>('/api/settings/notifications/test');
|
return postJson<{ ok: true }>('/api/settings/notifications/test');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAiSettings(signal?: AbortSignal) {
|
||||||
|
return getJson<{ settings: AiSettings }>('/api/settings/ai', {
|
||||||
|
signal,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAiSettings(input: AiSettingsInput) {
|
||||||
|
return putJson<{ settings: AiSettings }>('/api/settings/ai', input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testAiSettings() {
|
||||||
|
return postJson<{ ok: true; message: string }>('/api/settings/ai/test');
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type PublicService = {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
status: PublicServiceStatus;
|
status: PublicServiceStatus;
|
||||||
|
message: string | null;
|
||||||
lastCheckedAt: string | null;
|
lastCheckedAt: string | null;
|
||||||
uptime90d: number | null;
|
uptime90d: number | null;
|
||||||
history: Array<{
|
history: Array<{
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { Activity, LogOut, Menu, Settings, Zap, type LucideIcon } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { navigate, usePathname } from '../lib/router';
|
||||||
|
import { useLogoutMutation } from '../queries/auth';
|
||||||
|
|
||||||
|
type NavigationItem = {
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
href?: string;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AppHeader({ context }: { context?: string }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const logoutMutation = useLogoutMutation();
|
||||||
|
const items: NavigationItem[] = [
|
||||||
|
{
|
||||||
|
label: 'View status page',
|
||||||
|
icon: Activity,
|
||||||
|
href: '/',
|
||||||
|
onClick: () => navigate('/'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
icon: Settings,
|
||||||
|
href: '/settings',
|
||||||
|
onClick: () => navigate('/settings'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: logoutMutation.isPending ? 'Signing out…' : 'Sign out',
|
||||||
|
icon: LogOut,
|
||||||
|
onClick: () => logoutMutation.mutate(),
|
||||||
|
disabled: logoutMutation.isPending,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="dashboard-header">
|
||||||
|
<div className="dashboard-header-inner">
|
||||||
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
|
className="brand brand-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
aria-label="Upwatch dashboard"
|
||||||
|
>
|
||||||
|
<Zap className="brand-mark" fill="currentColor" aria-hidden="true" />
|
||||||
|
<span>upwatch</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<nav className="nav-actions" aria-label="Primary navigation">
|
||||||
|
{context && <span className="header-context">{context}</span>}
|
||||||
|
{items.map((item) => {
|
||||||
|
const ItemIcon = item.icon;
|
||||||
|
const isCurrent = item.href === pathname;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={item.label}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="app-nav-icon"
|
||||||
|
type="button"
|
||||||
|
aria-label={item.label}
|
||||||
|
aria-current={isCurrent ? 'page' : undefined}
|
||||||
|
disabled={item.disabled}
|
||||||
|
onClick={item.onClick}
|
||||||
|
>
|
||||||
|
<ItemIcon aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent sideOffset={6}>{item.label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="app-nav-mobile-trigger">
|
||||||
|
<Sheet>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" type="button" aria-label="Open menu">
|
||||||
|
<Menu aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="right" className="app-nav-sheet">
|
||||||
|
<SheetHeader className="app-nav-sheet-header">
|
||||||
|
<SheetTitle className="app-nav-sheet-title">
|
||||||
|
<Zap fill="currentColor" aria-hidden="true" />
|
||||||
|
<span>upwatch</span>
|
||||||
|
</SheetTitle>
|
||||||
|
{context && <SheetDescription>{context}</SheetDescription>}
|
||||||
|
</SheetHeader>
|
||||||
|
<nav className="app-nav-mobile" aria-label="Primary navigation">
|
||||||
|
{items.map((item) => {
|
||||||
|
const ItemIcon = item.icon;
|
||||||
|
const isCurrent = item.href === pathname;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SheetClose asChild key={item.label}>
|
||||||
|
<button
|
||||||
|
className="app-nav-mobile-item"
|
||||||
|
type="button"
|
||||||
|
aria-current={isCurrent ? 'page' : undefined}
|
||||||
|
disabled={item.disabled}
|
||||||
|
onClick={item.onClick}
|
||||||
|
>
|
||||||
|
<ItemIcon aria-hidden="true" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
</SheetClose>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, type TooltipContentProps } from 'recharts';
|
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
|
||||||
|
import { Empty, EmptyDescription, EmptyTitle } from '@/components/ui/empty';
|
||||||
import type { Check } from '../../api/monitors';
|
import type { Check } from '../../api/monitors';
|
||||||
|
|
||||||
type LatencyDatum = {
|
type LatencyDatum = {
|
||||||
@@ -8,18 +10,9 @@ type LatencyDatum = {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function LatencyTooltip({ active, payload }: TooltipContentProps) {
|
const latencyConfig = {
|
||||||
if (!active || !payload.length) return null;
|
latency: { label: 'Latency', color: 'var(--primary-deep)' },
|
||||||
|
} satisfies ChartConfig;
|
||||||
const point = payload[0].payload as LatencyDatum;
|
|
||||||
return (
|
|
||||||
<div className="chart-tooltip">
|
|
||||||
<time>{new Date(point.t).toLocaleString()}</time>
|
|
||||||
<strong>{point.latency} ms</strong>
|
|
||||||
<span className={point.ok ? 'is-up' : 'is-down'}>{point.ok ? 'Up' : 'Down'}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LatencySparkline({ checks }: { checks: Check[] }) {
|
export function LatencySparkline({ checks }: { checks: Check[] }) {
|
||||||
const [hasAnimated, setHasAnimated] = useState(false);
|
const [hasAnimated, setHasAnimated] = useState(false);
|
||||||
@@ -30,39 +23,54 @@ export function LatencySparkline({ checks }: { checks: Check[] }) {
|
|||||||
}));
|
}));
|
||||||
const values = data.map((point) => point.latency);
|
const values = data.map((point) => point.latency);
|
||||||
|
|
||||||
if (data.length < 2) return <div className="chart-empty">More checks are needed to draw latency.</div>;
|
if (data.length < 2)
|
||||||
|
return (
|
||||||
|
<Empty className="min-h-37.5 p-6">
|
||||||
|
<EmptyTitle className="text-[13px]">Not enough latency data</EmptyTitle>
|
||||||
|
<EmptyDescription>More checks are needed to draw latency.</EmptyDescription>
|
||||||
|
</Empty>
|
||||||
|
);
|
||||||
|
|
||||||
const minimum = Math.min(...values);
|
const minimum = Math.min(...values);
|
||||||
const maximum = Math.max(...values);
|
const maximum = Math.max(...values);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sparkline-wrap" role="img" aria-label={`Latency from ${minimum} to ${maximum} milliseconds`}>
|
<div className="sparkline-wrap" role="img" aria-label={`Latency from ${minimum} to ${maximum} milliseconds`}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ChartContainer config={latencyConfig} className="h-full w-full">
|
||||||
<AreaChart data={data} margin={{ top: 10, right: 4, bottom: 4, left: 4 }}>
|
<AreaChart data={data} margin={{ top: 10, right: 4, bottom: 4, left: 4 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="latency-fill" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="latency-fill" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0" stopColor="#3ecf8e" stopOpacity="0.22" />
|
<stop offset="0" stopColor="var(--color-latency)" stopOpacity="0.22" />
|
||||||
<stop offset="1" stopColor="#3ecf8e" stopOpacity="0" />
|
<stop offset="1" stopColor="var(--color-latency)" stopOpacity="0" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid vertical={false} stroke="#eeeeee" strokeDasharray="3 5" />
|
<CartesianGrid vertical={false} strokeDasharray="3 5" />
|
||||||
<XAxis dataKey="t" hide />
|
<XAxis dataKey="t" hide />
|
||||||
<YAxis hide domain={['dataMin', 'dataMax']} />
|
<YAxis hide domain={['dataMin', 'dataMax']} />
|
||||||
<Tooltip content={LatencyTooltip} cursor={{ stroke: '#9adfc1', strokeDasharray: '3 4' }} isAnimationActive={false} />
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
className="min-w-36 gap-1.5 rounded-[6px] border-(--hairline) bg-white/97 px-3 py-2.5 shadow-[0_8px_24px_rgb(24_74_52/0.1),0_2px_6px_rgb(0_0_0/0.04)] backdrop-blur-sm"
|
||||||
|
labelFormatter={(_, payload) => new Date((payload[0].payload as LatencyDatum).t).toLocaleString()}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
cursor={{ stroke: 'var(--primary)', strokeDasharray: '3 4' }}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="latency"
|
dataKey="latency"
|
||||||
stroke="#24b47e"
|
stroke="var(--color-latency)"
|
||||||
strokeWidth={2.5}
|
strokeWidth={2.5}
|
||||||
fill="url(#latency-fill)"
|
fill="url(#latency-fill)"
|
||||||
activeDot={{ r: 4, fill: '#24b47e', stroke: '#fff', strokeWidth: 2 }}
|
activeDot={{ r: 4, fill: 'var(--color-latency)', stroke: 'var(--background)', strokeWidth: 2 }}
|
||||||
isAnimationActive={!hasAnimated}
|
isAnimationActive={!hasAnimated}
|
||||||
animationDuration={700}
|
animationDuration={700}
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
onAnimationEnd={() => setHasAnimated(true)}
|
onAnimationEnd={() => setHasAnimated(true)}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ChartContainer>
|
||||||
<div className="chart-scale">
|
<div className="chart-scale">
|
||||||
<span>{maximum} ms</span>
|
<span>{maximum} ms</span>
|
||||||
<span>{minimum} ms</span>
|
<span>{minimum} ms</span>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis, type TooltipContentProps } from 'recharts';
|
import { Bar, BarChart, Cell, XAxis, YAxis } from 'recharts';
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
|
||||||
|
import { Empty, EmptyDescription, EmptyTitle } from '@/components/ui/empty';
|
||||||
import type { Check } from '../../api/monitors';
|
import type { Check } from '../../api/monitors';
|
||||||
|
|
||||||
type UptimeDatum = {
|
type UptimeDatum = {
|
||||||
@@ -7,19 +9,14 @@ type UptimeDatum = {
|
|||||||
v: number;
|
v: number;
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
id: number;
|
id: number;
|
||||||
|
status: 'up' | 'down';
|
||||||
|
fill: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function UptimeTooltip({ active, payload }: TooltipContentProps) {
|
const uptimeConfig = {
|
||||||
if (!active || !payload.length) return null;
|
up: { label: 'Up', color: 'var(--primary)' },
|
||||||
|
down: { label: 'Down', color: 'var(--chart-down)' },
|
||||||
const point = payload[0].payload as UptimeDatum;
|
} satisfies ChartConfig;
|
||||||
return (
|
|
||||||
<div className="chart-tooltip chart-tooltip-compact">
|
|
||||||
<time>{new Date(point.t).toLocaleString()}</time>
|
|
||||||
<span className={point.ok ? 'is-up' : 'is-down'}>{point.ok ? 'Up' : 'Down'}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UptimeBar({ checks }: { checks: Check[] }) {
|
export function UptimeBar({ checks }: { checks: Check[] }) {
|
||||||
const [hasAnimated, setHasAnimated] = useState(false);
|
const [hasAnimated, setHasAnimated] = useState(false);
|
||||||
@@ -28,19 +25,37 @@ export function UptimeBar({ checks }: { checks: Check[] }) {
|
|||||||
v: 1,
|
v: 1,
|
||||||
ok: check.ok,
|
ok: check.ok,
|
||||||
id: check.id,
|
id: check.id,
|
||||||
|
status: check.ok ? 'up' : 'down',
|
||||||
|
fill: check.ok ? 'var(--color-up)' : 'var(--color-down)',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (data.length === 0) return <div className="chart-empty">No availability checks recorded yet.</div>;
|
if (data.length === 0)
|
||||||
|
return (
|
||||||
|
<Empty className="min-h-37.5 p-6">
|
||||||
|
<EmptyTitle className="text-[13px]">No availability data yet</EmptyTitle>
|
||||||
|
<EmptyDescription>No availability checks recorded yet.</EmptyDescription>
|
||||||
|
</Empty>
|
||||||
|
);
|
||||||
|
|
||||||
const successfulChecks = data.filter((check) => check.ok).length;
|
const successfulChecks = data.filter((check) => check.ok).length;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="uptime-bar" role="img" aria-label={`${successfulChecks} of ${data.length} recent checks succeeded`}>
|
<div className="uptime-bar" role="img" aria-label={`${successfulChecks} of ${data.length} recent checks succeeded`}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ChartContainer config={uptimeConfig} className="h-full w-full">
|
||||||
<BarChart data={data} barCategoryGap={2} margin={{ top: 10, right: 0, bottom: 0, left: 0 }}>
|
<BarChart data={data} barCategoryGap={2} margin={{ top: 10, right: 0, bottom: 0, left: 0 }}>
|
||||||
<XAxis dataKey="id" hide />
|
<XAxis dataKey="id" hide />
|
||||||
<YAxis hide domain={[0, 1]} />
|
<YAxis hide domain={[0, 1]} />
|
||||||
<Tooltip content={UptimeTooltip} cursor={{ fill: 'rgb(23 23 23 / 0.04)' }} isAnimationActive={false} />
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
className="min-w-36 gap-1.5 rounded-[6px] border-(--hairline) bg-white/97 px-3 py-2.5 shadow-[0_8px_24px_rgb(24_74_52/0.1),0_2px_6px_rgb(0_0_0/0.04)] backdrop-blur-sm"
|
||||||
|
hideIndicator={false}
|
||||||
|
nameKey="status"
|
||||||
|
labelFormatter={(_, payload) => new Date((payload[0].payload as UptimeDatum).t).toLocaleString()}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="v"
|
dataKey="v"
|
||||||
radius={[2, 2, 0, 0]}
|
radius={[2, 2, 0, 0]}
|
||||||
@@ -50,11 +65,11 @@ export function UptimeBar({ checks }: { checks: Check[] }) {
|
|||||||
onAnimationEnd={() => setHasAnimated(true)}
|
onAnimationEnd={() => setHasAnimated(true)}
|
||||||
>
|
>
|
||||||
{data.map((point) => (
|
{data.map((point) => (
|
||||||
<Cell key={point.id} fill={point.ok ? '#3ecf8e' : '#d95c5c'} />
|
<Cell key={point.id} fill={point.ok ? 'var(--color-up)' : 'var(--color-down)'} />
|
||||||
))}
|
))}
|
||||||
</Bar>
|
</Bar>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
<div className="uptime-legend">
|
<div className="uptime-legend">
|
||||||
<span>Oldest</span>
|
<span>Oldest</span>
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { Zap } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { navigate } from '../../lib/router';
|
|
||||||
import { useLogoutMutation } from '../../queries/auth';
|
|
||||||
|
|
||||||
export function DashboardHeader() {
|
|
||||||
const logoutMutation = useLogoutMutation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="dashboard-header">
|
|
||||||
<div className="dashboard-header-inner">
|
|
||||||
<a className="brand" href="/dashboard" aria-label="Upwatch dashboard">
|
|
||||||
<Zap className="brand-mark" fill="currentColor" />
|
|
||||||
<span>upwatch</span>
|
|
||||||
</a>
|
|
||||||
<div className="nav-actions">
|
|
||||||
<span className="header-context">Production monitors</span>
|
|
||||||
<a className="nav-auth" href="/">
|
|
||||||
View status page
|
|
||||||
</a>
|
|
||||||
<Button variant="unstyled" className="nav-auth" type="button" onClick={() => navigate('/settings')}>
|
|
||||||
Settings
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="unstyled"
|
|
||||||
className="nav-auth"
|
|
||||||
type="button"
|
|
||||||
onClick={() => logoutMutation.mutate()}
|
|
||||||
disabled={logoutMutation.isPending}
|
|
||||||
>
|
|
||||||
{logoutMutation.isPending ? 'Signing out…' : 'Sign out'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
import { useMonitorsQuery } from '../../queries/monitors';
|
import { useMonitorsQuery } from '../../queries/monitors';
|
||||||
|
|
||||||
type DashboardOverviewProps = {
|
type DashboardOverviewProps = {
|
||||||
@@ -28,21 +29,21 @@ export function DashboardOverview({ onAddMonitor }: DashboardOverviewProps) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="metric-grid" aria-label="Monitor summary">
|
<section className="metric-grid" aria-label="Monitor summary">
|
||||||
<div className="metric-card">
|
<Card className="metric-card">
|
||||||
<p>Total monitors</p>
|
<p>Total monitors</p>
|
||||||
<strong>{monitors.length}</strong>
|
<strong>{monitors.length}</strong>
|
||||||
<span>{monitors.filter((monitor) => monitor.enabled).length} enabled</span>
|
<span>{monitors.filter((monitor) => monitor.enabled).length} enabled</span>
|
||||||
</div>
|
</Card>
|
||||||
<div className="metric-card">
|
<Card className="metric-card">
|
||||||
<p>Currently up</p>
|
<p>Currently up</p>
|
||||||
<strong>{up}</strong>
|
<strong>{up}</strong>
|
||||||
<span>Latest checks succeeded</span>
|
<span>Latest checks succeeded</span>
|
||||||
</div>
|
</Card>
|
||||||
<div className="metric-card">
|
<Card className="metric-card">
|
||||||
<p>Currently down</p>
|
<p>Currently down</p>
|
||||||
<strong>{down}</strong>
|
<strong>{down}</strong>
|
||||||
<span>Needs attention</span>
|
<span>Needs attention</span>
|
||||||
</div>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const DEFAULT_MONITOR_INPUT: MonitorInput = {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const INTERVAL_OPTIONS = [
|
export const INTERVAL_OPTIONS = [
|
||||||
{ value: '300', label: '5 minutes' },
|
{ value: '300', label: '5 minutes' },
|
||||||
{ value: '900', label: '15 minutes' },
|
{ value: '900', label: '15 minutes' },
|
||||||
{ value: '1800', label: '30 minutes' },
|
{ value: '1800', label: '30 minutes' },
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ArrowRight, Database, History, Pencil, Power, PowerOff, RefreshCw, Trash2 } from 'lucide-react';
|
import { AlertTriangle, ArrowRight, Database, History, Pencil, Power, PowerOff, RefreshCw, Trash2 } from 'lucide-react';
|
||||||
import { Badge, type BadgeVariant } from '@/components/ui/badge';
|
import { Badge, type BadgeVariant } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Empty, EmptyContent, EmptyDescription, EmptyMedia, EmptyTitle } from '@/components/ui/empty';
|
||||||
import type { Monitor } from '../../api/monitors';
|
import type { Monitor } from '../../api/monitors';
|
||||||
import { navigate } from '../../lib/router';
|
import { navigate } from '../../lib/router';
|
||||||
import { useDeleteMonitorMutation, useMonitorsQuery, useRunCheckMutation, useUpdateMonitorMutation } from '../../queries/monitors';
|
import { useDeleteMonitorMutation, useMonitorsQuery, useRunCheckMutation, useUpdateMonitorMutation } from '../../queries/monitors';
|
||||||
@@ -83,26 +84,33 @@ export function MonitorListPanel({ formOpen, onAddMonitor, onEdit }: MonitorList
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : monitorsQuery.isError ? (
|
) : monitorsQuery.isError ? (
|
||||||
<div className="panel-state error-state">
|
<Empty variant="error">
|
||||||
<strong>Monitors could not be loaded</strong>
|
<EmptyMedia variant="icon">
|
||||||
<p>{errorMessage(monitorsQuery.error, 'Unknown request error')}</p>
|
<AlertTriangle />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>Monitors could not be loaded</EmptyTitle>
|
||||||
|
<EmptyDescription>{errorMessage(monitorsQuery.error, 'Unknown request error')}</EmptyDescription>
|
||||||
|
<EmptyContent>
|
||||||
<Button variant="unstyled" className="secondary-button" type="button" onClick={() => void monitorsQuery.refetch()}>
|
<Button variant="unstyled" className="secondary-button" type="button" onClick={() => void monitorsQuery.refetch()}>
|
||||||
Try again
|
Try again
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</EmptyContent>
|
||||||
|
</Empty>
|
||||||
) : monitors.length === 0 ? (
|
) : monitors.length === 0 ? (
|
||||||
<div className="panel-state empty-state">
|
<Empty>
|
||||||
<span className="empty-icon">
|
<EmptyMedia variant="icon">
|
||||||
<Database />
|
<Database />
|
||||||
</span>
|
</EmptyMedia>
|
||||||
<strong>No monitors yet</strong>
|
<EmptyTitle>No monitors yet</EmptyTitle>
|
||||||
<p>Add the first endpoint to start collecting availability checks.</p>
|
<EmptyDescription>Add the first endpoint to start collecting availability checks.</EmptyDescription>
|
||||||
{!formOpen && (
|
{!formOpen && (
|
||||||
|
<EmptyContent>
|
||||||
<Button variant="unstyled" className="primary-button" type="button" onClick={onAddMonitor}>
|
<Button variant="unstyled" className="primary-button" type="button" onClick={onAddMonitor}>
|
||||||
Add first site <ArrowRight />
|
Add first site <ArrowRight />
|
||||||
</Button>
|
</Button>
|
||||||
|
</EmptyContent>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Empty>
|
||||||
) : (
|
) : (
|
||||||
<div className="monitor-list">
|
<div className="monitor-list">
|
||||||
<div className="services-title">
|
<div className="services-title">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Monitor } from '../api/monitors';
|
import type { Monitor } from '../api/monitors';
|
||||||
|
import { AppHeader } from '../components/AppHeader';
|
||||||
import { DashboardFooter } from '../components/dashboard/DashboardFooter';
|
import { DashboardFooter } from '../components/dashboard/DashboardFooter';
|
||||||
import { DashboardHeader } from '../components/dashboard/DashboardHeader';
|
|
||||||
import { DashboardOverview } from '../components/dashboard/DashboardOverview';
|
import { DashboardOverview } from '../components/dashboard/DashboardOverview';
|
||||||
import { MonitorFormDialog } from '../components/dashboard/MonitorFormDialog';
|
import { MonitorFormDialog } from '../components/dashboard/MonitorFormDialog';
|
||||||
import { MonitorListPanel } from '../components/dashboard/MonitorListPanel';
|
import { MonitorListPanel } from '../components/dashboard/MonitorListPanel';
|
||||||
@@ -27,7 +27,7 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard-shell">
|
<div className="dashboard-shell">
|
||||||
<DashboardHeader />
|
<AppHeader context="Production monitors" />
|
||||||
|
|
||||||
<main className="dashboard-main">
|
<main className="dashboard-main">
|
||||||
<DashboardOverview onAddMonitor={openCreateForm} />
|
<DashboardOverview onAddMonitor={openCreateForm} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type FormEvent, useEffect, useState } from 'react';
|
import { type FormEvent, useEffect, useState } from 'react';
|
||||||
import { Zap } from 'lucide-react';
|
import { Zap } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { navigate } from '../lib/router';
|
import { navigate } from '../lib/router';
|
||||||
import { useLoginMutation, useSessionQuery } from '../queries/auth';
|
import { useLoginMutation, useSessionQuery } from '../queries/auth';
|
||||||
@@ -28,6 +29,7 @@ export function LoginPage() {
|
|||||||
<span>upwatch</span>
|
<span>upwatch</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card asChild>
|
||||||
<section className="auth-card" aria-labelledby="login-title">
|
<section className="auth-card" aria-labelledby="login-title">
|
||||||
<div className="auth-heading">
|
<div className="auth-heading">
|
||||||
<p>Admin access</p>
|
<p>Admin access</p>
|
||||||
@@ -60,6 +62,7 @@ export function LoginPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<p className="auth-footnote">Protected by an encrypted, seven-day session.</p>
|
<p className="auth-footnote">Protected by an encrypted, seven-day session.</p>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import { ArrowLeft, BellOff, CheckCircle2, Clock3, ExternalLink, RefreshCw, Zap } from 'lucide-react';
|
import { useState } from 'react';
|
||||||
|
import { ArrowLeft, BellOff, CheckCircle2, Clock3, ExternalLink, Pencil, Power, PowerOff, RefreshCw, Trash2 } from 'lucide-react';
|
||||||
import { Badge, type BadgeVariant } from '@/components/ui/badge';
|
import { Badge, type BadgeVariant } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Empty, EmptyContent, EmptyDescription, EmptyTitle } from '@/components/ui/empty';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { AppHeader } from '../components/AppHeader';
|
||||||
import { LatencySparkline } from '../components/charts/LatencySparkline';
|
import { LatencySparkline } from '../components/charts/LatencySparkline';
|
||||||
import { UptimeBar } from '../components/charts/UptimeBar';
|
import { UptimeBar } from '../components/charts/UptimeBar';
|
||||||
|
import { DeleteMonitorDialog } from '../components/dashboard/DeleteMonitorDialog';
|
||||||
|
import { INTERVAL_OPTIONS, MonitorFormDialog } from '../components/dashboard/MonitorFormDialog';
|
||||||
import { navigate } from '../lib/router';
|
import { navigate } from '../lib/router';
|
||||||
import { useLogoutMutation } from '../queries/auth';
|
|
||||||
import {
|
import {
|
||||||
|
useDeleteMonitorMutation,
|
||||||
useMonitorChecksQuery,
|
useMonitorChecksQuery,
|
||||||
useMonitorIncidentsQuery,
|
useMonitorIncidentsQuery,
|
||||||
useMonitorQuery,
|
useMonitorQuery,
|
||||||
useMonitorStatsQuery,
|
useMonitorStatsQuery,
|
||||||
useRunCheckMutation,
|
useRunCheckMutation,
|
||||||
|
useUpdateMonitorMutation,
|
||||||
} from '../queries/monitors';
|
} from '../queries/monitors';
|
||||||
|
|
||||||
function formatDate(value: string) {
|
function formatDate(value: string) {
|
||||||
@@ -31,13 +39,20 @@ function statusDetails(lastOk: boolean | null): { label: string; className: Badg
|
|||||||
return { label: 'Awaiting first check', className: 'checking' };
|
return { label: 'Awaiting first check', className: 'checking' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatInterval(seconds: number) {
|
||||||
|
return INTERVAL_OPTIONS.find((option) => Number(option.value) === seconds)?.label ?? `${Math.round(seconds / 60)} min`;
|
||||||
|
}
|
||||||
|
|
||||||
export function MonitorDetailPage({ id }: { id: number }) {
|
export function MonitorDetailPage({ id }: { id: number }) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
|
||||||
const monitorQuery = useMonitorQuery(id);
|
const monitorQuery = useMonitorQuery(id);
|
||||||
const checksQuery = useMonitorChecksQuery(id);
|
const checksQuery = useMonitorChecksQuery(id);
|
||||||
const statsQuery = useMonitorStatsQuery(id);
|
const statsQuery = useMonitorStatsQuery(id);
|
||||||
const incidentsQuery = useMonitorIncidentsQuery(id);
|
const incidentsQuery = useMonitorIncidentsQuery(id);
|
||||||
const checkMutation = useRunCheckMutation();
|
const checkMutation = useRunCheckMutation();
|
||||||
const logoutMutation = useLogoutMutation();
|
const updateMutation = useUpdateMonitorMutation();
|
||||||
|
const deleteMutation = useDeleteMonitorMutation();
|
||||||
const monitor = monitorQuery.data?.monitor;
|
const monitor = monitorQuery.data?.monitor;
|
||||||
const checks = checksQuery.data?.checks ?? [];
|
const checks = checksQuery.data?.checks ?? [];
|
||||||
const incidents = incidentsQuery.data?.incidents ?? [];
|
const incidents = incidentsQuery.data?.incidents ?? [];
|
||||||
@@ -53,33 +68,29 @@ export function MonitorDetailPage({ id }: { id: number }) {
|
|||||||
);
|
);
|
||||||
if (monitorQuery.isError || !monitor)
|
if (monitorQuery.isError || !monitor)
|
||||||
return (
|
return (
|
||||||
<div className="detail-error">
|
<div className="grid min-h-dvh place-content-center">
|
||||||
<strong>Monitor not found</strong>
|
<Empty>
|
||||||
<p>The requested monitor could not be loaded.</p>
|
<EmptyTitle>Monitor not found</EmptyTitle>
|
||||||
|
<EmptyDescription>The requested monitor could not be loaded.</EmptyDescription>
|
||||||
|
<EmptyContent>
|
||||||
<Button variant="unstyled" className="secondary-button" onClick={() => navigate('/dashboard')}>
|
<Button variant="unstyled" className="secondary-button" onClick={() => navigate('/dashboard')}>
|
||||||
Return to dashboard
|
Return to dashboard
|
||||||
</Button>
|
</Button>
|
||||||
|
</EmptyContent>
|
||||||
|
</Empty>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard-shell">
|
<div className="dashboard-shell">
|
||||||
<header className="dashboard-header">
|
<AppHeader />
|
||||||
<div className="dashboard-header-inner">
|
{isEditing && <MonitorFormDialog key={monitor.id} editing={monitor} onClose={() => setIsEditing(false)} />}
|
||||||
<Button variant="unstyled" className="brand brand-button" type="button" onClick={() => navigate('/dashboard')}>
|
<DeleteMonitorDialog
|
||||||
<Zap className="brand-mark" fill="currentColor" />
|
monitor={isConfirmingDelete ? monitor : null}
|
||||||
<span>upwatch</span>
|
isPending={deleteMutation.isPending}
|
||||||
</Button>
|
onCancel={() => setIsConfirmingDelete(false)}
|
||||||
<div className="nav-actions">
|
onConfirm={() => deleteMutation.mutate(monitor.id, { onSuccess: () => navigate('/dashboard') })}
|
||||||
<Button variant="unstyled" className="nav-auth" onClick={() => navigate('/settings')}>
|
/>
|
||||||
Settings
|
|
||||||
</Button>
|
|
||||||
<Button variant="unstyled" className="nav-auth" onClick={() => logoutMutation.mutate()} disabled={logoutMutation.isPending}>
|
|
||||||
Sign out
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main className="dashboard-main detail-main">
|
<main className="dashboard-main detail-main">
|
||||||
<Button variant="unstyled" className="back-link" type="button" onClick={() => navigate('/dashboard')}>
|
<Button variant="unstyled" className="back-link" type="button" onClick={() => navigate('/dashboard')}>
|
||||||
<ArrowLeft /> All monitors
|
<ArrowLeft /> All monitors
|
||||||
@@ -119,20 +130,24 @@ export function MonitorDetailPage({ id }: { id: number }) {
|
|||||||
{(['24h', '7d', '30d', '90d'] as const).map((key) => {
|
{(['24h', '7d', '30d', '90d'] as const).map((key) => {
|
||||||
const window = statsQuery.data?.windows[key];
|
const window = statsQuery.data?.windows[key];
|
||||||
return (
|
return (
|
||||||
<article className="sla-card" key={key}>
|
<Card asChild key={key}>
|
||||||
|
<article className="sla-card">
|
||||||
<p>{key} uptime</p>
|
<p>{key} uptime</p>
|
||||||
<strong>{window?.uptimePct == null ? '—' : `${window.uptimePct.toFixed(3)}%`}</strong>
|
<strong>{window?.uptimePct == null ? '—' : `${window.uptimePct.toFixed(3)}%`}</strong>
|
||||||
<span>
|
<span>
|
||||||
{window?.totalChecks ?? 0} checks · {window?.avgLatencyMs ?? '—'} ms avg
|
{window?.totalChecks ?? 0} checks · {window?.avgLatencyMs ?? '—'} ms avg
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<Card asChild>
|
||||||
<article className={`sla-card incident-summary ${openIncident ? 'has-incident' : ''}`}>
|
<article className={`sla-card incident-summary ${openIncident ? 'has-incident' : ''}`}>
|
||||||
<p>Current incident</p>
|
<p>Current incident</p>
|
||||||
<strong>{openIncident ? formatDuration(null, openIncident.startedAt) : 'None'}</strong>
|
<strong>{openIncident ? formatDuration(null, openIncident.startedAt) : 'None'}</strong>
|
||||||
<span>{openIncident ? `Open since ${formatDate(openIncident.startedAt)}` : 'Everything is operational'}</span>
|
<span>{openIncident ? `Open since ${formatDate(openIncident.startedAt)}` : 'Everything is operational'}</span>
|
||||||
</article>
|
</article>
|
||||||
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="detail-grid">
|
<div className="detail-grid">
|
||||||
@@ -172,34 +187,41 @@ export function MonitorDetailPage({ id }: { id: number }) {
|
|||||||
{/* Keyboard focus makes this horizontally scrollable region accessible without a pointer. */}
|
{/* Keyboard focus makes this horizontally scrollable region accessible without a pointer. */}
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
|
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
|
||||||
<section className="data-table-wrap recent-checks-scroll" aria-label="Recent checks" tabIndex={0}>
|
<section className="data-table-wrap recent-checks-scroll" aria-label="Recent checks" tabIndex={0}>
|
||||||
<table className="data-table">
|
<Table className="min-w-150">
|
||||||
<thead>
|
<TableHeader>
|
||||||
<tr>
|
<TableRow>
|
||||||
<th>Status</th>
|
<TableHead>Status</TableHead>
|
||||||
<th>Response</th>
|
<TableHead>Response</TableHead>
|
||||||
<th>Latency</th>
|
<TableHead>Latency</TableHead>
|
||||||
<th>Checked</th>
|
<TableHead>Checked</TableHead>
|
||||||
</tr>
|
</TableRow>
|
||||||
</thead>
|
</TableHeader>
|
||||||
<tbody>
|
<TableBody>
|
||||||
{checks.slice(0, 20).map((check) => (
|
{checks.slice(0, 20).map((check) => (
|
||||||
<tr key={check.id}>
|
<TableRow key={check.id}>
|
||||||
<td>
|
<TableCell>
|
||||||
<Badge variant={check.ok ? 'online' : 'offline'}>{check.ok ? 'Up' : 'Down'}</Badge>
|
<Badge variant={check.ok ? 'online' : 'offline'}>{check.ok ? 'Up' : 'Down'}</Badge>
|
||||||
</td>
|
</TableCell>
|
||||||
<td>
|
<TableCell>
|
||||||
<code>{check.statusCode ? `HTTP ${check.statusCode}` : (check.error ?? 'Failed')}</code>
|
<code className="max-w-65 truncate font-mono text-[11px]/[1.4] font-normal">
|
||||||
</td>
|
{check.statusCode ? `HTTP ${check.statusCode}` : (check.error ?? 'Failed')}
|
||||||
<td>{check.latencyMs} ms</td>
|
</code>
|
||||||
<td>{formatDate(check.checkedAt)}</td>
|
</TableCell>
|
||||||
</tr>
|
<TableCell>{check.latencyMs} ms</TableCell>
|
||||||
|
<TableCell>{formatDate(check.checkedAt)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
{checks.length === 0 && <div className="table-empty">No checks recorded.</div>}
|
{checks.length === 0 && (
|
||||||
|
<Empty className="min-h-37.5 p-6">
|
||||||
|
<EmptyTitle className="text-[13px]">No checks yet</EmptyTitle>
|
||||||
|
<EmptyDescription>No checks recorded.</EmptyDescription>
|
||||||
|
</Empty>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
<section className="data-panel">
|
<section className="data-panel incidents-panel">
|
||||||
<div className="data-panel-heading">
|
<div className="data-panel-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="overline">Downtime</p>
|
<p className="overline">Downtime</p>
|
||||||
@@ -207,26 +229,91 @@ export function MonitorDetailPage({ id }: { id: number }) {
|
|||||||
</div>
|
</div>
|
||||||
<span>{incidents.length} recorded</span>
|
<span>{incidents.length} recorded</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="incident-list">
|
{/* Keyboard focus makes this scrollable region accessible without a pointer. */}
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
|
||||||
|
<div className="incident-list" aria-label="Incident history" tabIndex={0}>
|
||||||
{incidents.map((incident) => (
|
{incidents.map((incident) => (
|
||||||
<article className={incident.resolvedAt ? 'resolved' : 'open'} key={incident.id}>
|
<article className={incident.resolvedAt ? 'resolved' : 'open'} key={incident.id}>
|
||||||
<span>{incident.resolvedAt ? <CheckCircle2 /> : <Clock3 />}</span>
|
<span>{incident.resolvedAt ? <CheckCircle2 /> : <Clock3 />}</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>{incident.resolvedAt ? 'Resolved incident' : 'Incident in progress'}</strong>
|
<strong>{incident.resolvedAt ? 'Resolved incident' : 'Incident in progress'}</strong>
|
||||||
<p>
|
<p>
|
||||||
{incident.startError ??
|
{incident.aiMessage ??
|
||||||
|
incident.startError ??
|
||||||
(incident.startStatusCode ? `HTTP ${incident.startStatusCode}` : 'Endpoint became unavailable')}
|
(incident.startStatusCode ? `HTTP ${incident.startStatusCode}` : 'Endpoint became unavailable')}
|
||||||
</p>
|
</p>
|
||||||
|
{incident.aiMessage && incident.startError && <small className="incident-raw">{incident.startError}</small>}
|
||||||
<small>
|
<small>
|
||||||
{formatDate(incident.startedAt)} · {formatDuration(incident.durationMs, incident.startedAt)}
|
{formatDate(incident.startedAt)} · {formatDuration(incident.durationMs, incident.startedAt)}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
{incidents.length === 0 && <div className="table-empty">No downtime incidents recorded.</div>}
|
{incidents.length === 0 && (
|
||||||
|
<Empty className="min-h-37.5 p-6">
|
||||||
|
<EmptyTitle className="text-[13px]">No incidents yet</EmptyTitle>
|
||||||
|
<EmptyDescription>No downtime incidents recorded.</EmptyDescription>
|
||||||
|
</Empty>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section className="data-panel configuration-panel">
|
||||||
|
<div className="data-panel-heading">
|
||||||
|
<div>
|
||||||
|
<p className="overline">Setup</p>
|
||||||
|
<h2>Configuration</h2>
|
||||||
|
</div>
|
||||||
|
<Button variant="unstyled" className="secondary-button" type="button" onClick={() => setIsEditing(true)}>
|
||||||
|
<Pencil /> Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<dl className="config-list">
|
||||||
|
<div className="config-row">
|
||||||
|
<dt>Method</dt>
|
||||||
|
<dd>{monitor.method}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="config-row">
|
||||||
|
<dt>Expected status</dt>
|
||||||
|
<dd>{monitor.expectedStatus}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="config-row">
|
||||||
|
<dt>Check interval</dt>
|
||||||
|
<dd>{formatInterval(monitor.intervalSeconds)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="config-row">
|
||||||
|
<dt>Timeout</dt>
|
||||||
|
<dd>{monitor.timeoutMs.toLocaleString()} ms</dd>
|
||||||
|
</div>
|
||||||
|
<div className="config-row">
|
||||||
|
<dt>Scheduled checks</dt>
|
||||||
|
<dd className="config-row-actions">
|
||||||
|
<span>{monitor.enabled ? 'Running' : 'Paused'}</span>
|
||||||
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
|
className="row-action"
|
||||||
|
type="button"
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
onClick={() => updateMutation.mutate({ id: monitor.id, input: { enabled: !monitor.enabled } })}
|
||||||
|
>
|
||||||
|
{monitor.enabled ? <PowerOff /> : <Power />}
|
||||||
|
{monitor.enabled ? 'Pause' : 'Resume'}
|
||||||
|
</Button>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="config-row">
|
||||||
|
<dt>Incident alerts</dt>
|
||||||
|
<dd>{monitor.alertsEnabled ? 'Enabled' : 'Muted'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div className="config-danger">
|
||||||
|
<p>This permanently deletes the monitor and its check history.</p>
|
||||||
|
<Button variant="unstyled" className="danger-button" type="button" onClick={() => setIsConfirmingDelete(true)}>
|
||||||
|
<Trash2 /> Delete monitor
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { type FormEvent, useState } from 'react';
|
import { type FormEvent, useState } from 'react';
|
||||||
import { ArrowLeft, BellRing, Send, Zap } from 'lucide-react';
|
import { ArrowLeft, BellRing, Send, Sparkles } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Empty, EmptyTitle } from '@/components/ui/empty';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import type { NotificationSettings } from '../api/settings';
|
import type { AiSettings, NotificationSettings } from '../api/settings';
|
||||||
|
import { AppHeader } from '../components/AppHeader';
|
||||||
import { navigate } from '../lib/router';
|
import { navigate } from '../lib/router';
|
||||||
import { useLogoutMutation } from '../queries/auth';
|
|
||||||
import {
|
import {
|
||||||
|
useAiSettingsQuery,
|
||||||
useNotificationSettingsQuery,
|
useNotificationSettingsQuery,
|
||||||
|
useTestAiSettingsMutation,
|
||||||
useTestNotificationWebhookMutation,
|
useTestNotificationWebhookMutation,
|
||||||
|
useUpdateAiSettingsMutation,
|
||||||
useUpdateNotificationSettingsMutation,
|
useUpdateNotificationSettingsMutation,
|
||||||
} from '../queries/settings';
|
} from '../queries/settings';
|
||||||
|
|
||||||
@@ -65,34 +70,98 @@ function SettingsForm({ settings }: { settings: NotificationSettings }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AiSettingsForm({ settings }: { settings: AiSettings }) {
|
||||||
|
const [baseUrl, setBaseUrl] = useState(settings.baseUrl ?? 'https://api.openai.com/v1');
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [model, setModel] = useState(settings.model ?? 'gpt-4o-mini');
|
||||||
|
const [enabled, setEnabled] = useState(settings.enabled);
|
||||||
|
const updateMutation = useUpdateAiSettingsMutation();
|
||||||
|
const testMutation = useTestAiSettingsMutation();
|
||||||
|
|
||||||
|
function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
updateMutation.mutate({
|
||||||
|
enabled,
|
||||||
|
baseUrl: baseUrl.trim() || null,
|
||||||
|
model: model.trim() || null,
|
||||||
|
apiKey: apiKey.trim() || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="settings-form" onSubmit={submit}>
|
||||||
|
<label className="field" htmlFor="ai-base-url">
|
||||||
|
<span>Base URL</span>
|
||||||
|
<Input
|
||||||
|
id="ai-base-url"
|
||||||
|
type="url"
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(event) => setBaseUrl(event.target.value)}
|
||||||
|
placeholder="https://api.openai.com/v1"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field" htmlFor="ai-api-key">
|
||||||
|
<span>API key</span>
|
||||||
|
<Input
|
||||||
|
id="ai-api-key"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(event) => setApiKey(event.target.value)}
|
||||||
|
placeholder={settings.apiKeyPreview ?? 'Enter API key'}
|
||||||
|
/>
|
||||||
|
{settings.apiKeySet && <small className="field-helper">Leave blank to keep the current key.</small>}
|
||||||
|
</label>
|
||||||
|
<label className="field" htmlFor="ai-model">
|
||||||
|
<span>Model</span>
|
||||||
|
<Input id="ai-model" type="text" value={model} onChange={(event) => setModel(event.target.value)} placeholder="gpt-4o-mini" />
|
||||||
|
</label>
|
||||||
|
<div className="settings-toggle">
|
||||||
|
<Switch id="ai-enabled" checked={enabled} onCheckedChange={setEnabled} />
|
||||||
|
<label htmlFor="ai-enabled">
|
||||||
|
<strong>Enable AI incident messages</strong>
|
||||||
|
<small>Generate one sanitized public update when an incident opens.</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="settings-actions">
|
||||||
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
|
className="secondary-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => testMutation.mutate()}
|
||||||
|
disabled={!settings.apiKeySet || !settings.baseUrl || !settings.model || testMutation.isPending}
|
||||||
|
>
|
||||||
|
<Sparkles /> {testMutation.isPending ? 'Generating…' : 'Test generation'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="unstyled" className="primary-button" type="submit" disabled={updateMutation.isPending}>
|
||||||
|
{updateMutation.isPending ? 'Saving…' : 'Save settings'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{updateMutation.isSuccess && <p className="settings-success">AI settings saved.</p>}
|
||||||
|
{testMutation.isSuccess && <p className="settings-success">{testMutation.data.message}</p>}
|
||||||
|
{(updateMutation.isError || testMutation.isError) && (
|
||||||
|
<p className="form-error">{(updateMutation.error ?? testMutation.error)?.message ?? 'Request failed'}</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const settingsQuery = useNotificationSettingsQuery();
|
const settingsQuery = useNotificationSettingsQuery();
|
||||||
const logoutMutation = useLogoutMutation();
|
const aiSettingsQuery = useAiSettingsQuery();
|
||||||
return (
|
return (
|
||||||
<div className="dashboard-shell">
|
<div className="dashboard-shell">
|
||||||
<header className="dashboard-header">
|
<AppHeader context="Settings" />
|
||||||
<div className="dashboard-header-inner">
|
|
||||||
<Button variant="unstyled" className="brand brand-button" onClick={() => navigate('/dashboard')}>
|
|
||||||
<Zap className="brand-mark" fill="currentColor" />
|
|
||||||
<span>upwatch</span>
|
|
||||||
</Button>
|
|
||||||
<div className="nav-actions">
|
|
||||||
<span className="header-context">Settings</span>
|
|
||||||
<Button variant="unstyled" className="nav-auth" onClick={() => logoutMutation.mutate()}>
|
|
||||||
Sign out
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main className="settings-main">
|
<main className="settings-main">
|
||||||
<Button variant="unstyled" className="back-link" type="button" onClick={() => navigate('/dashboard')}>
|
<Button variant="unstyled" className="back-link" type="button" onClick={() => navigate('/dashboard')}>
|
||||||
<ArrowLeft /> Dashboard
|
<ArrowLeft /> Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
<section className="settings-heading">
|
<section className="settings-heading">
|
||||||
<p className="overline">Integrations</p>
|
<p className="overline">Integrations</p>
|
||||||
<h1>Notifications</h1>
|
<h1>Notifications & AI</h1>
|
||||||
<p>Route monitor transitions to Slack, Discord, or any service that accepts JSON webhooks.</p>
|
<p>Configure incident alerts and visitor-friendly status updates from one place.</p>
|
||||||
</section>
|
</section>
|
||||||
|
<Card asChild>
|
||||||
<section className="settings-card">
|
<section className="settings-card">
|
||||||
<div className="settings-card-intro">
|
<div className="settings-card-intro">
|
||||||
<span>
|
<span>
|
||||||
@@ -106,11 +175,38 @@ export function SettingsPage() {
|
|||||||
{settingsQuery.isPending ? (
|
{settingsQuery.isPending ? (
|
||||||
<div className="table-empty">Loading settings…</div>
|
<div className="table-empty">Loading settings…</div>
|
||||||
) : settingsQuery.isError ? (
|
) : settingsQuery.isError ? (
|
||||||
<p className="form-error">Unable to load notification settings.</p>
|
<Empty variant="error" className="m-6">
|
||||||
|
<EmptyTitle>Unable to load notification settings</EmptyTitle>
|
||||||
|
</Empty>
|
||||||
) : (
|
) : (
|
||||||
<SettingsForm key={settingsQuery.data.settings.updatedAt ?? 'new'} settings={settingsQuery.data.settings} />
|
<SettingsForm key={settingsQuery.data.settings.updatedAt ?? 'new'} settings={settingsQuery.data.settings} />
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</Card>
|
||||||
|
<Card asChild>
|
||||||
|
<section className="settings-card">
|
||||||
|
<div className="settings-card-intro">
|
||||||
|
<span>
|
||||||
|
<Sparkles />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2>AI incident messages</h2>
|
||||||
|
<p>
|
||||||
|
Turn technical check failures into short, sanitized updates for visitors. Generation runs only when an incident opens.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{aiSettingsQuery.isPending ? (
|
||||||
|
<div className="table-empty">Loading settings…</div>
|
||||||
|
) : aiSettingsQuery.isError ? (
|
||||||
|
<Empty variant="error" className="m-6">
|
||||||
|
<EmptyTitle>Unable to load AI settings</EmptyTitle>
|
||||||
|
</Empty>
|
||||||
|
) : (
|
||||||
|
<AiSettingsForm key={aiSettingsQuery.data.settings.updatedAt ?? 'new'} settings={aiSettingsQuery.data.settings} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</Card>
|
||||||
<section className="payload-preview">
|
<section className="payload-preview">
|
||||||
<p className="overline">Payload preview</p>
|
<p className="overline">Payload preview</p>
|
||||||
<pre>{`{
|
<pre>{`{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Activity, CircleCheck, Database, RefreshCw, TriangleAlert, Zap } from 'lucide-react';
|
import { Activity, CircleCheck, Database, RefreshCw, TriangleAlert, Zap } from 'lucide-react';
|
||||||
import { Badge, type BadgeVariant } from '@/components/ui/badge';
|
import { Badge, type BadgeVariant } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Empty, EmptyContent, EmptyDescription, EmptyMedia, EmptyTitle } from '@/components/ui/empty';
|
||||||
import type { PublicOverallStatus, PublicServiceStatus } from '../api/status';
|
import type { PublicOverallStatus, PublicServiceStatus } from '../api/status';
|
||||||
import { SiteIcon } from '../components/SiteIcon';
|
import { SiteIcon } from '../components/SiteIcon';
|
||||||
import { StatusHistoryBar } from '../components/StatusHistoryBar';
|
import { StatusHistoryBar } from '../components/StatusHistoryBar';
|
||||||
@@ -53,6 +54,7 @@ export function StatusPage() {
|
|||||||
const sessionQuery = useSessionQuery();
|
const sessionQuery = useSessionQuery();
|
||||||
const [now, setNow] = useState(Date.now);
|
const [now, setNow] = useState(Date.now);
|
||||||
const status = statusQuery.data;
|
const status = statusQuery.data;
|
||||||
|
const activeIncidents = status?.services.filter((service) => service.message) ?? [];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setInterval(() => setNow(Date.now()), 5_000);
|
const timer = window.setInterval(() => setNow(Date.now()), 5_000);
|
||||||
@@ -99,16 +101,18 @@ export function StatusPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : statusQuery.isError || !status ? (
|
) : statusQuery.isError || !status ? (
|
||||||
<section className="status-panel-state status-error-state">
|
<Empty variant="error" className="mt-[42px] min-h-[280px] place-content-center p-12">
|
||||||
<span>
|
<EmptyMedia variant="icon">
|
||||||
<TriangleAlert />
|
<TriangleAlert />
|
||||||
</span>
|
</EmptyMedia>
|
||||||
<strong>Status could not be loaded</strong>
|
<EmptyTitle>Status could not be loaded</EmptyTitle>
|
||||||
<p>{errorMessage(statusQuery.error)}</p>
|
<EmptyDescription>{errorMessage(statusQuery.error)}</EmptyDescription>
|
||||||
|
<EmptyContent>
|
||||||
<Button variant="unstyled" className="secondary-button" type="button" onClick={() => void statusQuery.refetch()}>
|
<Button variant="unstyled" className="secondary-button" type="button" onClick={() => void statusQuery.refetch()}>
|
||||||
Try again
|
Try again
|
||||||
</Button>
|
</Button>
|
||||||
</section>
|
</EmptyContent>
|
||||||
|
</Empty>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<section className={`status-banner ${status.overall}`} aria-live="polite">
|
<section className={`status-banner ${status.overall}`} aria-live="polite">
|
||||||
@@ -122,6 +126,45 @@ export function StatusPage() {
|
|||||||
<time dateTime={new Date(status.updatedAt).toISOString()}>{relativeUpdate(status.updatedAt, now)}</time>
|
<time dateTime={new Date(status.updatedAt).toISOString()}>{relativeUpdate(status.updatedAt, now)}</time>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{activeIncidents.length > 0 && (
|
||||||
|
<section className="active-incidents" aria-labelledby="active-incidents-title" aria-live="polite">
|
||||||
|
<header className="active-incidents-header">
|
||||||
|
<div className="active-incidents-heading">
|
||||||
|
<span className="active-incidents-icon">
|
||||||
|
<TriangleAlert aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p>Active incident</p>
|
||||||
|
<h2 id="active-incidents-title">We’re working to restore service</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="active-incidents-count">
|
||||||
|
{activeIncidents.length} {activeIncidents.length === 1 ? 'service' : 'services'} affected
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="active-incident-list">
|
||||||
|
{activeIncidents.map((service) => (
|
||||||
|
<article className="active-incident-row" key={service.id}>
|
||||||
|
<div className="active-incident-service">
|
||||||
|
<span className="active-incident-service-icon">
|
||||||
|
<SiteIcon monitorId={service.id} favicon="public" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<strong>{service.name}</strong>
|
||||||
|
<span>Service disruption</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>{service.message}</p>
|
||||||
|
<span className="active-incident-state">
|
||||||
|
<i /> Investigating
|
||||||
|
</span>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="public-services-panel" aria-labelledby="public-services-title">
|
<section className="public-services-panel" aria-labelledby="public-services-title">
|
||||||
<div className="public-services-heading">
|
<div className="public-services-heading">
|
||||||
<div>
|
<div>
|
||||||
@@ -141,13 +184,13 @@ export function StatusPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status.services.length === 0 ? (
|
{status.services.length === 0 ? (
|
||||||
<div className="status-panel-state">
|
<Empty className="min-h-[280px] place-content-center p-12">
|
||||||
<span>
|
<EmptyMedia variant="icon">
|
||||||
<Database />
|
<Database />
|
||||||
</span>
|
</EmptyMedia>
|
||||||
<strong>No public services yet</strong>
|
<EmptyTitle>No public services yet</EmptyTitle>
|
||||||
<p>Service health will appear here after monitoring is enabled.</p>
|
<EmptyDescription>Service health will appear here after monitoring is enabled.</EmptyDescription>
|
||||||
</div>
|
</Empty>
|
||||||
) : (
|
) : (
|
||||||
<div className="public-service-list">
|
<div className="public-service-list">
|
||||||
{status.services.map((service) => {
|
{status.services.map((service) => {
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
|
getAiSettings,
|
||||||
getNotificationSettings,
|
getNotificationSettings,
|
||||||
|
testAiSettings,
|
||||||
testNotificationWebhook,
|
testNotificationWebhook,
|
||||||
|
updateAiSettings,
|
||||||
updateNotificationSettings,
|
updateNotificationSettings,
|
||||||
|
type AiSettingsInput,
|
||||||
type NotificationSettingsInput,
|
type NotificationSettingsInput,
|
||||||
} from '../api/settings';
|
} from '../api/settings';
|
||||||
import { queryClient } from '../lib/query-client';
|
import { queryClient } from '../lib/query-client';
|
||||||
@@ -10,6 +14,7 @@ import { queryClient } from '../lib/query-client';
|
|||||||
export const settingsKeys = {
|
export const settingsKeys = {
|
||||||
all: ['settings'] as const,
|
all: ['settings'] as const,
|
||||||
notifications: () => [...settingsKeys.all, 'notifications'] as const,
|
notifications: () => [...settingsKeys.all, 'notifications'] as const,
|
||||||
|
ai: () => [...settingsKeys.all, 'ai'] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useNotificationSettingsQuery() {
|
export function useNotificationSettingsQuery() {
|
||||||
@@ -29,3 +34,21 @@ export function useUpdateNotificationSettingsMutation() {
|
|||||||
export function useTestNotificationWebhookMutation() {
|
export function useTestNotificationWebhookMutation() {
|
||||||
return useMutation({ mutationFn: testNotificationWebhook });
|
return useMutation({ mutationFn: testNotificationWebhook });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useAiSettingsQuery() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: settingsKeys.ai(),
|
||||||
|
queryFn: ({ signal }) => getAiSettings(signal),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateAiSettingsMutation() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: AiSettingsInput) => updateAiSettings(input),
|
||||||
|
onSuccess: (data) => queryClient.setQueryData(settingsKeys.ai(), data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTestAiSettingsMutation() {
|
||||||
|
return useMutation({ mutationFn: testAiSettings });
|
||||||
|
}
|
||||||
|
|||||||
+367
-224
@@ -14,6 +14,7 @@
|
|||||||
--primary: #3ecf8e;
|
--primary: #3ecf8e;
|
||||||
--shadcn-primary: oklch(0.205 0 0);
|
--shadcn-primary: oklch(0.205 0 0);
|
||||||
--primary-deep: #24b47e;
|
--primary-deep: #24b47e;
|
||||||
|
--chart-down: #d95c5c;
|
||||||
--ink: #171717;
|
--ink: #171717;
|
||||||
--muted: #707070;
|
--muted: #707070;
|
||||||
--shadcn-muted: oklch(0.97 0 0);
|
--shadcn-muted: oklch(0.97 0 0);
|
||||||
@@ -57,6 +58,7 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
|
font-size: 19px;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@@ -144,34 +146,99 @@ button {
|
|||||||
color: var(--primary-deep);
|
color: var(--primary-deep);
|
||||||
}
|
}
|
||||||
.nav-actions {
|
.nav-actions {
|
||||||
display: flex;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.app-nav-mobile-trigger {
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
.header-context {
|
.header-context {
|
||||||
|
margin-right: 12px;
|
||||||
padding-right: 20px;
|
padding-right: 20px;
|
||||||
border-right: 1px solid #e5e5e5;
|
border-right: 1px solid #e5e5e5;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.nav-auth {
|
.app-nav-icon {
|
||||||
padding: 0;
|
color: #5f5f5f;
|
||||||
border: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #525252;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 160ms ease;
|
|
||||||
}
|
}
|
||||||
.nav-auth:hover:not(:disabled) {
|
.app-nav-icon:hover:not(:disabled),
|
||||||
|
.app-nav-icon[aria-current='page'] {
|
||||||
|
background: rgb(62 207 142 / 0.1);
|
||||||
color: var(--primary-deep);
|
color: var(--primary-deep);
|
||||||
}
|
}
|
||||||
.nav-auth:disabled {
|
.app-nav-sheet {
|
||||||
|
width: min(88vw, 340px);
|
||||||
|
border-color: var(--hairline);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.app-nav-sheet-header {
|
||||||
|
gap: 5px;
|
||||||
|
padding: 24px 20px 18px;
|
||||||
|
border-bottom: 1px solid #ededed;
|
||||||
|
}
|
||||||
|
.app-nav-sheet-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
.app-nav-sheet-title svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
color: var(--primary-deep);
|
||||||
|
}
|
||||||
|
.app-nav-mobile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
}
|
||||||
|
.app-nav-mobile-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: left;
|
||||||
|
color: #4f4f4f;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
color 160ms ease;
|
||||||
|
}
|
||||||
|
.app-nav-mobile-item svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
.app-nav-mobile-item:hover:not(:disabled),
|
||||||
|
.app-nav-mobile-item[aria-current='page'] {
|
||||||
|
color: var(--primary-deep);
|
||||||
|
background: rgb(62 207 142 / 0.1);
|
||||||
|
}
|
||||||
|
.app-nav-mobile-item:disabled {
|
||||||
cursor: wait;
|
cursor: wait;
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.nav-actions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.app-nav-mobile-trigger {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-main {
|
.dashboard-main {
|
||||||
width: min(1280px, calc(100% - 48px));
|
width: min(1280px, calc(100% - 48px));
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -201,7 +268,8 @@ button {
|
|||||||
}
|
}
|
||||||
.dashboard-intro > div > p:last-child {
|
.dashboard-intro > div > p:last-child {
|
||||||
margin: 12px 0 0;
|
margin: 12px 0 0;
|
||||||
font-size: 16px;
|
font-size: 19px;
|
||||||
|
line-height: 1.55;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.primary-button,
|
.primary-button,
|
||||||
@@ -264,7 +332,9 @@ button {
|
|||||||
cursor: wait;
|
cursor: wait;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
.primary-button svg {
|
.primary-button svg,
|
||||||
|
.secondary-button svg,
|
||||||
|
.danger-button svg {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
@@ -639,44 +709,6 @@ button {
|
|||||||
background: #fff2f2;
|
background: #fff2f2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-state {
|
|
||||||
display: grid;
|
|
||||||
justify-items: center;
|
|
||||||
padding: 72px 24px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.panel-state strong {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.panel-state p {
|
|
||||||
max-width: 420px;
|
|
||||||
margin: 8px 0 20px;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.55;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.empty-icon {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
border: 1px solid #dcdcdc;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #666;
|
|
||||||
background: #fafafa;
|
|
||||||
}
|
|
||||||
.empty-icon svg {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
}
|
|
||||||
.error-state {
|
|
||||||
background: #fffafa;
|
|
||||||
}
|
|
||||||
.error-state strong {
|
|
||||||
color: #8b3434;
|
|
||||||
}
|
|
||||||
.monitor-skeleton > div {
|
.monitor-skeleton > div {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 40px 1fr 180px;
|
grid-template-columns: 40px 1fr 180px;
|
||||||
@@ -786,7 +818,7 @@ button {
|
|||||||
.auth-heading > span {
|
.auth-heading > span {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
font-size: 14px;
|
font-size: 19px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
color: #626262;
|
color: #626262;
|
||||||
}
|
}
|
||||||
@@ -1038,6 +1070,56 @@ button {
|
|||||||
monospace;
|
monospace;
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
}
|
}
|
||||||
|
.configuration-panel {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.config-list {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.config-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 14px 22px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.config-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.config-row dt {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.config-row dd {
|
||||||
|
margin: 0;
|
||||||
|
font:
|
||||||
|
400 13px/1.4 'IBM Plex Mono',
|
||||||
|
monospace;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.config-row .config-row-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.config-row-actions .row-action {
|
||||||
|
font-family: 'DM Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
}
|
||||||
|
.config-danger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 22px;
|
||||||
|
border-top: 1px solid #ededed;
|
||||||
|
background: #fdfafa;
|
||||||
|
}
|
||||||
|
.config-danger p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
.chart-panel {
|
.chart-panel {
|
||||||
min-height: 330px;
|
min-height: 330px;
|
||||||
}
|
}
|
||||||
@@ -1047,16 +1129,6 @@ button {
|
|||||||
padding: 26px 24px 24px;
|
padding: 26px 24px 24px;
|
||||||
background: linear-gradient(180deg, #fdfefe, #fff);
|
background: linear-gradient(180deg, #fdfefe, #fff);
|
||||||
}
|
}
|
||||||
.sparkline-wrap .recharts-wrapper:focus,
|
|
||||||
.sparkline-wrap .recharts-wrapper:focus-visible,
|
|
||||||
.sparkline-wrap .recharts-surface:focus,
|
|
||||||
.sparkline-wrap .recharts-surface:focus-visible,
|
|
||||||
.uptime-bar .recharts-wrapper:focus,
|
|
||||||
.uptime-bar .recharts-wrapper:focus-visible,
|
|
||||||
.uptime-bar .recharts-surface:focus,
|
|
||||||
.uptime-bar .recharts-surface:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.chart-scale {
|
.chart-scale {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 19px 22px 20px auto;
|
inset: 19px 22px 20px auto;
|
||||||
@@ -1069,57 +1141,6 @@ button {
|
|||||||
monospace;
|
monospace;
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
}
|
}
|
||||||
.chart-tooltip {
|
|
||||||
display: grid;
|
|
||||||
min-width: 144px;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid var(--hairline);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--ink);
|
|
||||||
background: rgb(255 255 255 / 0.97);
|
|
||||||
box-shadow:
|
|
||||||
0 8px 24px rgb(24 74 52 / 0.1),
|
|
||||||
0 2px 6px rgb(0 0 0 / 0.04);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
}
|
|
||||||
.chart-tooltip time {
|
|
||||||
font-size: 10px;
|
|
||||||
line-height: 1.35;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.chart-tooltip strong {
|
|
||||||
font:
|
|
||||||
500 15px/1.3 'IBM Plex Mono',
|
|
||||||
monospace;
|
|
||||||
letter-spacing: -0.3px;
|
|
||||||
}
|
|
||||||
.chart-tooltip > span {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
width: max-content;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #16885b;
|
|
||||||
}
|
|
||||||
.chart-tooltip > span::before {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--primary-deep);
|
|
||||||
content: '';
|
|
||||||
}
|
|
||||||
.chart-tooltip > span.is-down {
|
|
||||||
color: #ae3d3d;
|
|
||||||
}
|
|
||||||
.chart-tooltip > span.is-down::before {
|
|
||||||
background: #d95c5c;
|
|
||||||
}
|
|
||||||
.chart-tooltip-compact {
|
|
||||||
min-width: 134px;
|
|
||||||
}
|
|
||||||
.chart-empty,
|
|
||||||
.table-empty {
|
.table-empty {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 150px;
|
min-height: 150px;
|
||||||
@@ -1157,7 +1178,7 @@ button {
|
|||||||
}
|
}
|
||||||
.uptime-legend i.legend-down {
|
.uptime-legend i.legend-down {
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
background: #d95c5c;
|
background: var(--chart-down);
|
||||||
}
|
}
|
||||||
.data-table-wrap {
|
.data-table-wrap {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -1167,7 +1188,13 @@ button {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.recent-checks-scroll {
|
.incidents-panel {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.recent-checks-scroll,
|
||||||
|
.incident-list {
|
||||||
max-height: clamp(320px, 52vh, 556px);
|
max-height: clamp(320px, 52vh, 556px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
@@ -1175,69 +1202,41 @@ button {
|
|||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
.recent-checks-scroll:focus-visible {
|
.recent-checks-scroll:focus-visible,
|
||||||
|
.incident-list:focus-visible {
|
||||||
outline: 2px solid rgb(36 180 126 / 0.45);
|
outline: 2px solid rgb(36 180 126 / 0.45);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
.recent-checks-scroll::-webkit-scrollbar {
|
.recent-checks-scroll::-webkit-scrollbar,
|
||||||
|
.incident-list::-webkit-scrollbar {
|
||||||
width: 9px;
|
width: 9px;
|
||||||
height: 9px;
|
height: 9px;
|
||||||
}
|
}
|
||||||
.recent-checks-scroll::-webkit-scrollbar-track {
|
.recent-checks-scroll::-webkit-scrollbar-track,
|
||||||
|
.incident-list::-webkit-scrollbar-track {
|
||||||
background: #f4f6f5;
|
background: #f4f6f5;
|
||||||
}
|
}
|
||||||
.recent-checks-scroll::-webkit-scrollbar-thumb {
|
.recent-checks-scroll::-webkit-scrollbar-thumb,
|
||||||
|
.incident-list::-webkit-scrollbar-thumb {
|
||||||
border: 2px solid #f4f6f5;
|
border: 2px solid #f4f6f5;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #b8c7c0;
|
background: #b8c7c0;
|
||||||
}
|
}
|
||||||
.recent-checks-scroll::-webkit-scrollbar-thumb:hover {
|
.recent-checks-scroll::-webkit-scrollbar-thumb:hover,
|
||||||
|
.incident-list::-webkit-scrollbar-thumb:hover {
|
||||||
background: #91a69d;
|
background: #91a69d;
|
||||||
}
|
}
|
||||||
.recent-checks-scroll .data-table {
|
|
||||||
min-width: 600px;
|
|
||||||
}
|
|
||||||
.recent-checks-scroll .data-table th {
|
|
||||||
position: sticky;
|
|
||||||
z-index: 1;
|
|
||||||
top: 0;
|
|
||||||
box-shadow: 0 1px #e5e5e5;
|
|
||||||
}
|
|
||||||
.data-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.data-table th {
|
|
||||||
padding: 11px 16px;
|
|
||||||
font-weight: 400;
|
|
||||||
text-align: left;
|
|
||||||
color: #888;
|
|
||||||
background: #fafafa;
|
|
||||||
}
|
|
||||||
.data-table td {
|
|
||||||
padding: 14px 16px;
|
|
||||||
border-top: 1px solid #ededed;
|
|
||||||
color: #555;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.data-table code {
|
|
||||||
max-width: 260px;
|
|
||||||
overflow: hidden;
|
|
||||||
font:
|
|
||||||
400 11px/1.4 'IBM Plex Mono',
|
|
||||||
monospace;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.incident-list {
|
.incident-list {
|
||||||
max-height: 556px;
|
min-height: 0;
|
||||||
overflow: auto;
|
|
||||||
}
|
}
|
||||||
.incident-list article {
|
.incident-list article {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 13px;
|
gap: 13px;
|
||||||
padding: 18px 20px;
|
padding: 18px 20px;
|
||||||
}
|
}
|
||||||
|
.incident-list article > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.incident-list article + article {
|
.incident-list article + article {
|
||||||
border-top: 1px solid #ededed;
|
border-top: 1px solid #ededed;
|
||||||
}
|
}
|
||||||
@@ -1265,6 +1264,7 @@ button {
|
|||||||
}
|
}
|
||||||
.incident-list p {
|
.incident-list p {
|
||||||
margin: 4px 0 6px;
|
margin: 4px 0 6px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -1275,18 +1275,11 @@ button {
|
|||||||
monospace;
|
monospace;
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
}
|
}
|
||||||
.detail-error {
|
.incident-list .incident-raw {
|
||||||
display: grid;
|
display: block;
|
||||||
min-height: 100dvh;
|
margin: -1px 0 5px;
|
||||||
place-content: center;
|
overflow-wrap: anywhere;
|
||||||
justify-items: center;
|
|
||||||
padding: 24px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
.detail-error p {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-main {
|
.settings-main {
|
||||||
width: min(760px, calc(100% - 48px));
|
width: min(760px, calc(100% - 48px));
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -1303,6 +1296,8 @@ button {
|
|||||||
}
|
}
|
||||||
.settings-heading > p:last-child {
|
.settings-heading > p:last-child {
|
||||||
margin: 12px 0 0;
|
margin: 12px 0 0;
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 1.55;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.settings-card {
|
.settings-card {
|
||||||
@@ -1349,6 +1344,7 @@ button {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
padding: 26px;
|
padding: 26px;
|
||||||
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
.settings-toggle {
|
.settings-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1370,6 +1366,12 @@ button {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
.field-helper {
|
||||||
|
display: block;
|
||||||
|
margin-top: 7px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
.settings-actions {
|
.settings-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -1458,7 +1460,7 @@ button {
|
|||||||
.status-intro > p:last-child {
|
.status-intro > p:last-child {
|
||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
margin: 18px 0 0;
|
margin: 18px 0 0;
|
||||||
font-size: 16px;
|
font-size: 19px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@@ -1528,6 +1530,158 @@ button {
|
|||||||
opacity: 0.66;
|
opacity: 0.66;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.active-incidents {
|
||||||
|
margin-top: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e9c5c5;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 14px 40px rgb(117 38 38 / 0.06);
|
||||||
|
animation: enter 500ms 45ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
.active-incidents-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
min-height: 92px;
|
||||||
|
padding: 18px 22px;
|
||||||
|
border-bottom: 1px solid #f0dede;
|
||||||
|
background: linear-gradient(110deg, #fff4f4 0%, #fffafa 72%, #fff 100%);
|
||||||
|
}
|
||||||
|
.active-incidents-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.active-incidents-icon {
|
||||||
|
display: grid;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid rgb(174 61 61 / 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #a93e3e;
|
||||||
|
background: rgb(255 255 255 / 0.82);
|
||||||
|
box-shadow: 0 4px 14px rgb(130 34 34 / 0.06);
|
||||||
|
}
|
||||||
|
.active-incidents-icon svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
.active-incidents-heading p {
|
||||||
|
margin: 0 0 3px;
|
||||||
|
font:
|
||||||
|
500 9px/1.3 'IBM Plex Mono',
|
||||||
|
monospace;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #a24a4a;
|
||||||
|
}
|
||||||
|
.active-incidents-heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.25px;
|
||||||
|
color: #5d2929;
|
||||||
|
}
|
||||||
|
.active-incidents-count {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid #e8caca;
|
||||||
|
border-radius: 5px;
|
||||||
|
font:
|
||||||
|
500 9px/1.3 'IBM Plex Mono',
|
||||||
|
monospace;
|
||||||
|
color: #8e3f3f;
|
||||||
|
background: rgb(255 255 255 / 0.72);
|
||||||
|
}
|
||||||
|
.active-incident-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(190px, 0.55fr) minmax(320px, 1.25fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
min-height: 104px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
}
|
||||||
|
.active-incident-row + .active-incident-row {
|
||||||
|
border-top: 1px solid #f1e5e5;
|
||||||
|
}
|
||||||
|
.active-incident-service {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.active-incident-service-icon {
|
||||||
|
display: grid;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid #e5d6d6;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: #765454;
|
||||||
|
background: #fcf8f8;
|
||||||
|
}
|
||||||
|
.active-incident-service-icon svg {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
}
|
||||||
|
.active-incident-service-icon img {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.active-incident-service > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.active-incident-service > div strong,
|
||||||
|
.active-incident-service > div span {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.active-incident-service > div strong {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #292323;
|
||||||
|
}
|
||||||
|
.active-incident-service > div span {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #9a7777;
|
||||||
|
}
|
||||||
|
.active-incident-row > p {
|
||||||
|
max-width: 66ch;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
text-wrap: pretty;
|
||||||
|
color: #6f5a5a;
|
||||||
|
}
|
||||||
|
.active-incident-state {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
justify-self: end;
|
||||||
|
font:
|
||||||
|
500 9px/1.3 'IBM Plex Mono',
|
||||||
|
monospace;
|
||||||
|
color: #8e4b4b;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.active-incident-state i {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d95c5c;
|
||||||
|
box-shadow: 0 0 0 4px rgb(217 92 92 / 0.1);
|
||||||
|
animation: blink 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
.public-services-panel {
|
.public-services-panel {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1657,51 +1811,6 @@ button {
|
|||||||
height: 1px;
|
height: 1px;
|
||||||
background: #ededed;
|
background: #ededed;
|
||||||
}
|
}
|
||||||
.status-panel-state {
|
|
||||||
display: grid;
|
|
||||||
justify-items: center;
|
|
||||||
min-height: 280px;
|
|
||||||
place-content: center;
|
|
||||||
padding: 48px 24px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.status-panel-state > span {
|
|
||||||
display: grid;
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
place-items: center;
|
|
||||||
border: 1px solid #dcdcdc;
|
|
||||||
border-radius: 50%;
|
|
||||||
color: #666;
|
|
||||||
background: #fafafa;
|
|
||||||
}
|
|
||||||
.status-panel-state svg {
|
|
||||||
width: 19px;
|
|
||||||
height: 19px;
|
|
||||||
}
|
|
||||||
.status-panel-state strong {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.status-panel-state p {
|
|
||||||
max-width: 400px;
|
|
||||||
margin: 8px 0 18px;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.status-error-state {
|
|
||||||
margin-top: 42px;
|
|
||||||
border: 1px solid #ebd1d1;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #fffafa;
|
|
||||||
}
|
|
||||||
.status-error-state > span {
|
|
||||||
border-color: #ebcaca;
|
|
||||||
color: #a34242;
|
|
||||||
background: #fff3f3;
|
|
||||||
}
|
|
||||||
.status-loading {
|
.status-loading {
|
||||||
margin-top: 42px;
|
margin-top: 42px;
|
||||||
}
|
}
|
||||||
@@ -1866,9 +1975,6 @@ button {
|
|||||||
.status-footer {
|
.status-footer {
|
||||||
width: min(100% - 32px, 960px);
|
width: min(100% - 32px, 960px);
|
||||||
}
|
}
|
||||||
.header-context {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.dashboard-main {
|
.dashboard-main {
|
||||||
padding: 40px 0 56px;
|
padding: 40px 0 56px;
|
||||||
}
|
}
|
||||||
@@ -1926,6 +2032,13 @@ button {
|
|||||||
.detail-actions {
|
.detail-actions {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
.config-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.config-danger {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
.detail-title h1 {
|
.detail-title h1 {
|
||||||
font-size: 34px;
|
font-size: 34px;
|
||||||
}
|
}
|
||||||
@@ -1948,6 +2061,14 @@ button {
|
|||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
}
|
}
|
||||||
|
.active-incident-row {
|
||||||
|
grid-template-columns: minmax(180px, 0.55fr) minmax(0, 1fr);
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.active-incident-state {
|
||||||
|
grid-column: 2;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
.public-service-row {
|
.public-service-row {
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
@@ -1983,7 +2104,7 @@ button {
|
|||||||
padding-top: 32px;
|
padding-top: 32px;
|
||||||
}
|
}
|
||||||
.dashboard-intro > div > p:last-child {
|
.dashboard-intro > div > p:last-child {
|
||||||
font-size: 14px;
|
font-size: 19px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.panel-heading {
|
.panel-heading {
|
||||||
@@ -2016,9 +2137,6 @@ button {
|
|||||||
.row-action-icon {
|
.row-action-icon {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
}
|
}
|
||||||
.nav-actions {
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.detail-title {
|
.detail-title {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
@@ -2049,7 +2167,7 @@ button {
|
|||||||
letter-spacing: -1.8px;
|
letter-spacing: -1.8px;
|
||||||
}
|
}
|
||||||
.status-intro > p:last-child {
|
.status-intro > p:last-child {
|
||||||
font-size: 14px;
|
font-size: 19px;
|
||||||
}
|
}
|
||||||
.status-banner {
|
.status-banner {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -2063,6 +2181,28 @@ button {
|
|||||||
.status-banner time {
|
.status-banner time {
|
||||||
grid-column: auto;
|
grid-column: auto;
|
||||||
}
|
}
|
||||||
|
.active-incidents-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px 16px;
|
||||||
|
}
|
||||||
|
.active-incidents-count {
|
||||||
|
margin-left: 54px;
|
||||||
|
}
|
||||||
|
.active-incident-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px 16px;
|
||||||
|
}
|
||||||
|
.active-incident-row > p {
|
||||||
|
padding-left: 48px;
|
||||||
|
}
|
||||||
|
.active-incident-state {
|
||||||
|
grid-column: auto;
|
||||||
|
justify-self: start;
|
||||||
|
margin-left: 48px;
|
||||||
|
}
|
||||||
.public-services-heading {
|
.public-services-heading {
|
||||||
padding: 17px 16px;
|
padding: 17px 16px;
|
||||||
}
|
}
|
||||||
@@ -2104,6 +2244,8 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
--font-sans: 'DM Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
--font-mono: 'IBM Plex Mono', monospace;
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
@@ -2117,6 +2259,7 @@ button {
|
|||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-2: var(--chart-2);
|
--color-chart-2: var(--chart-2);
|
||||||
--color-chart-1: var(--chart-1);
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-down: var(--chart-down);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Customized from radix-nova: layout and typography defaults are intentionally left to consumer styles.
|
||||||
|
function Card({ className, asChild = false, ...props }: React.ComponentProps<'div'> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : 'div';
|
||||||
|
|
||||||
|
return <Comp data-slot="card" className={cn('flex flex-col rounded-[8px] border border-[#e3e3e3] bg-white', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="card-header" className={cn(className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="card-title" className={cn(className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="card-description" className={cn(className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="card-action" className={cn(className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="card-content" className={cn(className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="card-footer" className={cn(className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter };
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as RechartsPrimitive from 'recharts';
|
||||||
|
import type { TooltipValueType } from 'recharts';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: '', dark: '.dark' } as const;
|
||||||
|
|
||||||
|
const INITIAL_DIMENSION = { width: 320, height: 200 } as const;
|
||||||
|
type TooltipNameType = number | string;
|
||||||
|
|
||||||
|
export type ChartConfig = Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
label?: React.ReactNode;
|
||||||
|
icon?: React.ComponentType;
|
||||||
|
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> })
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useChart must be used within a <ChartContainer />');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartContainer({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
initialDimension = INITIAL_DIMENSION,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
config: ChartConfig;
|
||||||
|
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
|
||||||
|
initialDimension?: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const uniqueId = React.useId();
|
||||||
|
const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-slot="chart"
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer initialDimension={initialDimension}>{children}</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(([, itemConfig]) => itemConfig.theme ?? itemConfig.color);
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? itemConfig.color;
|
||||||
|
return color ? ` --color-${key}: ${color};` : null;
|
||||||
|
})
|
||||||
|
.join('')}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join(''),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = 'dot',
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<'div'> & {
|
||||||
|
hideLabel?: boolean;
|
||||||
|
hideIndicator?: boolean;
|
||||||
|
indicator?: 'line' | 'dot' | 'dashed';
|
||||||
|
nameKey?: string;
|
||||||
|
labelKey?: string;
|
||||||
|
} & Omit<RechartsPrimitive.DefaultTooltipContentProps<TooltipValueType, TooltipNameType>, 'accessibilityLayer'>) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload;
|
||||||
|
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? 'value'}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const value = !labelKey && typeof label === 'string' ? (config[label]?.label ?? label) : itemConfig?.label;
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return <div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
|
||||||
|
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== 'dot';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid min-w-32 items-start gap-1.5 rounded-lg border border-oklch(0.922 0 0) border-oklch(0.922 0 0)/50 bg-oklch(1 0 0) px-2.5 py-1.5 text-xs shadow-xl dark:border-oklch(1 0 0 / 10%) dark:border-oklch(1 0 0 / 10%)/50 dark:bg-oklch(0.145 0 0)',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== 'none')
|
||||||
|
.map((item, index) => {
|
||||||
|
const key = `${nameKey ?? item.name ?? item.dataKey ?? 'value'}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const indicatorColor = color ?? item.payload?.fill ?? item.color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-oklch(0.556 0 0) dark:[&>svg]:text-oklch(0.708 0 0)',
|
||||||
|
indicator === 'dot' && 'items-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn('shrink-0 rounded-xs border-(--color-border) bg-(--color-bg)', {
|
||||||
|
'h-2.5 w-2.5': indicator === 'dot',
|
||||||
|
'w-1': indicator === 'line',
|
||||||
|
'w-0 border-[1.5px] border-dashed bg-transparent': indicator === 'dashed',
|
||||||
|
'my-0.5': nestLabel && indicator === 'dashed',
|
||||||
|
})}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--color-bg': indicatorColor,
|
||||||
|
'--color-border': indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div className={cn('flex flex-1 justify-between leading-none', nestLabel ? 'items-end' : 'items-center')}>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-oklch(0.556 0 0) dark:text-oklch(0.708 0 0)">{itemConfig?.label ?? item.name}</span>
|
||||||
|
</div>
|
||||||
|
{item.value != null && (
|
||||||
|
<span className="font-mono font-medium text-oklch(0.145 0 0) tabular-nums dark:text-oklch(0.985 0 0)">
|
||||||
|
{typeof item.value === 'number' ? item.value.toLocaleString() : String(item.value)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend;
|
||||||
|
|
||||||
|
function ChartLegendContent({
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = 'bottom',
|
||||||
|
nameKey,
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
hideIcon?: boolean;
|
||||||
|
nameKey?: string;
|
||||||
|
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center justify-center gap-4', verticalAlign === 'top' ? 'pb-3' : 'pt-3', className)}>
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== 'none')
|
||||||
|
.map((item, index) => {
|
||||||
|
const key = `${nameKey ?? item.dataKey ?? 'value'}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-oklch(0.556 0 0) dark:[&>svg]:text-oklch(0.708 0 0)',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||||
|
if (typeof payload !== 'object' || payload === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null ? payload.payload : undefined;
|
||||||
|
|
||||||
|
let configLabelKey: string = key;
|
||||||
|
|
||||||
|
if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||||
|
} else if (payloadPayload && key in payloadPayload && typeof payloadPayload[key as keyof typeof payloadPayload] === 'string') {
|
||||||
|
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config ? config[configLabelKey] : config[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const emptyVariants = cva('grid justify-items-center bg-transparent p-[56px_24px] text-center', {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: '',
|
||||||
|
error: 'rounded-[8px] border border-[#ebd1d1] bg-[#fffafa]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyMediaVariants = cva('', {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: '',
|
||||||
|
icon: 'mb-4.5 grid size-12 place-items-center rounded-[8px] border border-[#dcdcdc] bg-[#fafafa] text-[#666] [&_svg]:size-5.5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type EmptyVariant = NonNullable<VariantProps<typeof emptyVariants>['variant']>;
|
||||||
|
|
||||||
|
const EmptyVariantContext = React.createContext<EmptyVariant>('default');
|
||||||
|
|
||||||
|
function Empty({ className, variant = 'default', ...props }: React.ComponentProps<'div'> & VariantProps<typeof emptyVariants>) {
|
||||||
|
const resolvedVariant = variant ?? 'default';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmptyVariantContext.Provider value={resolvedVariant}>
|
||||||
|
<div
|
||||||
|
data-slot="empty"
|
||||||
|
data-variant={resolvedVariant}
|
||||||
|
className={cn(emptyVariants({ variant: resolvedVariant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</EmptyVariantContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="empty-header" className={cn('grid justify-items-center', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyMedia({ className, variant, ...props }: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) {
|
||||||
|
const emptyVariant = React.useContext(EmptyVariantContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-media"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
emptyMediaVariants({ variant }),
|
||||||
|
emptyVariant === 'error' && variant === 'icon' && 'border-[#ebcaca] bg-[#fff3f3] text-[#a34242]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
const variant = React.useContext(EmptyVariantContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-title"
|
||||||
|
className={cn('text-[16px] font-medium text-[#171717]', variant === 'error' && 'text-[#8b3434]', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||||
|
return (
|
||||||
|
<p data-slot="empty-description" className={cn('mt-2 max-w-105 text-[13px] leading-[1.55] text-(--muted)', className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="empty-content" className={cn('mt-5 flex flex-col items-center', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle };
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Dialog as SheetPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { XIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = 'right',
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
data-side={side}
|
||||||
|
className={cn(
|
||||||
|
'fixed z-50 flex flex-col gap-4 bg-oklch(1 0 0) bg-clip-padding text-sm text-oklch(0.145 0 0) shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10 dark:bg-oklch(0.205 0 0) dark:text-oklch(0.985 0 0)',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close data-slot="sheet-close" asChild>
|
||||||
|
<Button variant="ghost" className="absolute top-3 right-3" size="icon-sm">
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="sheet-header" className={cn('flex flex-col gap-0.5 p-4', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="sheet-footer" className={cn('mt-auto flex flex-col gap-2 p-4', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn('text-base font-medium text-oklch(0.145 0 0) dark:text-oklch(0.985 0 0)', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn('text-sm text-oklch(0.556 0 0) dark:text-oklch(0.708 0 0)', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription };
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
||||||
|
return <table data-slot="table" className={cn('w-full border-collapse text-[12px]', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
|
||||||
|
return <thead data-slot="table-header" className={cn(className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
||||||
|
return <tbody data-slot="table-body" className={cn(className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
|
||||||
|
return <tfoot data-slot="table-footer" className={cn(className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
||||||
|
return <tr data-slot="table-row" className={cn(className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn('sticky top-0 z-1 bg-[#fafafa] px-4 py-2.75 text-left font-normal text-[#888] shadow-[0_1px_#e5e5e5]', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn('whitespace-nowrap border-t border-[#ededed] px-4 py-3.5 text-[#555]', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
|
||||||
|
return <caption data-slot="table-caption" className={cn('mt-4 text-[12px] text-[#888]', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Tooltip as TooltipPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({ className, sideOffset = 8, children, ...props }: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center rounded-md border border-neutral-800 bg-neutral-950 px-2.5 py-1.5 text-xs leading-none font-medium whitespace-nowrap text-white shadow-lg shadow-black/15 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 dark:border-neutral-200 dark:bg-white dark:text-neutral-950',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="z-50 size-2 fill-neutral-950 dark:fill-white" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
export type CompletionSettings = {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CompletionBody = {
|
||||||
|
choices?: Array<{ message?: { content?: unknown } }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function requestCompletion(settings: CompletionSettings, system: string, user: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${settings.baseUrl}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${settings.apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: settings.model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: system },
|
||||||
|
{ role: 'user', content: user },
|
||||||
|
],
|
||||||
|
max_tokens: 160,
|
||||||
|
temperature: 0.2,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
await response.body?.cancel();
|
||||||
|
console.warn(JSON.stringify({ message: 'AI completion returned an error', status: response.status }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await response.json()) as CompletionBody;
|
||||||
|
const content = body.choices?.[0]?.message?.content;
|
||||||
|
if (typeof content !== 'string') {
|
||||||
|
console.warn(JSON.stringify({ message: 'AI completion returned malformed content' }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
JSON.stringify({
|
||||||
|
message: 'AI completion failed',
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/** Appended to every visitor-facing incident message so the update always closes with reassurance. */
|
||||||
|
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.';
|
||||||
|
|
||||||
|
/** 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.';
|
||||||
|
if (statusCode >= 500 && statusCode <= 599) return 'This service is having problems and some requests may fail or load incorrectly.';
|
||||||
|
if (statusCode === 429) return 'This service is under heavy load and is temporarily turning away some requests.';
|
||||||
|
if (statusCode >= 400 && statusCode <= 499) return 'This service is not responding correctly and some features may not work right now.';
|
||||||
|
if (statusCode >= 300 && statusCode <= 399) return 'This service is not loading as expected for visitors.';
|
||||||
|
return 'This service is not responding as expected and may be unavailable.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The deterministic public message shown when AI copy is disabled or generation fails. */
|
||||||
|
export function deterministicIncidentMessage(statusCode: number | null): string {
|
||||||
|
return `${describeFailure(statusCode)} ${INCIDENT_REASSURANCE}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const HOUR_MS = 60 * 60 * 1000;
|
||||||
|
const DAY_MS = 24 * HOUR_MS;
|
||||||
|
const RECENT_WINDOW_MS = 24 * HOUR_MS;
|
||||||
|
const RECENT_LIMIT = 30;
|
||||||
|
const FLAP_WINDOW = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A one-line, plain description of what actually failed. This is internal context
|
||||||
|
* for the model only - it may name HTTP codes and transport errors here because
|
||||||
|
* the prompt is responsible for translating them into visitor-facing language.
|
||||||
|
*/
|
||||||
|
function classifyFailure(monitor: Monitor, result: CheckResult): string {
|
||||||
|
if (result.statusCode === null) {
|
||||||
|
const error = (result.error ?? '').toLowerCase();
|
||||||
|
if (/tim(?:e|ed)\s?out|timeout|deadline|aborted/.test(error)) {
|
||||||
|
return 'Request timed out - no response came back before the timeout limit';
|
||||||
|
}
|
||||||
|
if (/getaddrinfo|enotfound|dns|name not resolved|could not resolve|eai_again/.test(error)) {
|
||||||
|
return 'DNS lookup failed - the hostname could not be resolved to an address';
|
||||||
|
}
|
||||||
|
if (/certificate|cert(?:\s|_)|self[- ]signed|tls|ssl|handshake|err_cert/.test(error)) {
|
||||||
|
return 'TLS/SSL failure - the certificate is invalid, expired, or untrusted';
|
||||||
|
}
|
||||||
|
if (/econnrefused|connection refused|refused to connect/.test(error)) {
|
||||||
|
return 'Connection refused - nothing is accepting connections at that address';
|
||||||
|
}
|
||||||
|
if (/econnreset|connection reset|socket hang up|premature close/.test(error)) {
|
||||||
|
return 'Connection dropped before any response was returned';
|
||||||
|
}
|
||||||
|
if (/ehostunreach|enetunreach|network is unreachable|no route to host/.test(error)) {
|
||||||
|
return 'Network unreachable - the host could not be contacted at all';
|
||||||
|
}
|
||||||
|
return 'No HTTP response was received from the endpoint';
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = result.statusCode;
|
||||||
|
if (code >= 500) return `Server error - the endpoint answered with HTTP ${code}`;
|
||||||
|
if (code === 429) return 'The endpoint is rate limiting - HTTP 429 Too Many Requests';
|
||||||
|
if (code === 401 || code === 403) return `The endpoint rejected the health check as unauthorized - HTTP ${code}`;
|
||||||
|
if (code === 404) return 'The health-check path returned HTTP 404 Not Found';
|
||||||
|
if (code >= 400) return `Client error - the endpoint answered with HTTP ${code}`;
|
||||||
|
if (code >= 300) return `Unexpected redirect - the endpoint answered with HTTP ${code}`;
|
||||||
|
return `The endpoint answered with HTTP ${code}, but HTTP ${monitor.expectedStatus} was expected`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanizeInterval(seconds: number): string {
|
||||||
|
if (seconds >= 3600 && seconds % 3600 === 0) return `${seconds / 3600} h`;
|
||||||
|
if (seconds >= 60 && seconds % 60 === 0) return `${seconds / 60} min`;
|
||||||
|
return `${seconds} s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanizeDuration(ms: number): string {
|
||||||
|
const minutes = Math.round(ms / 60_000);
|
||||||
|
if (minutes < 1) return 'under a minute';
|
||||||
|
if (minutes < 60) return `${minutes} min`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const rest = minutes % 60;
|
||||||
|
return rest ? `${hours} h ${rest} min` : `${hours} h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function median(values: number[]): number {
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const mid = Math.floor(sorted.length / 2);
|
||||||
|
return sorted.length % 2 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentResponseLine(result: CheckResult): string {
|
||||||
|
return result.statusCode === null
|
||||||
|
? `Current probe: no response after ${result.latencyMs} ms`
|
||||||
|
: `Current probe: HTTP ${result.statusCode} after ${result.latencyMs} ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Representative context used by the "Test generation" button in settings. */
|
||||||
|
export const SAMPLE_INCIDENT_CONTEXT = [
|
||||||
|
'Service name: Upwatch test',
|
||||||
|
'What is checked: GET request, healthy when it returns HTTP 200',
|
||||||
|
'Check frequency: every 5 min',
|
||||||
|
'Detected problem: Server error - the endpoint answered with HTTP 503',
|
||||||
|
'Current probe: HTTP 503 after 250 ms',
|
||||||
|
'Failed checks in a row: 3 (down for about 15 min)',
|
||||||
|
'Recent pattern: 3 of the last 10 checks failed',
|
||||||
|
'Typical response time when healthy: about 180 ms',
|
||||||
|
'Last healthy check: about 15 min ago',
|
||||||
|
'Availability: 96% over the last 24 h (48 checks)',
|
||||||
|
'Recurrence: 2 other incidents for this service in the last 30 days',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a compact, plain-text briefing about an incident for the language model.
|
||||||
|
* Never includes the monitor URL, hostname, or any IP - only the service name the
|
||||||
|
* operator chose and derived signal about behaviour over time.
|
||||||
|
*/
|
||||||
|
export async function buildIncidentContext(db: Database, monitor: Monitor, result: CheckResult): Promise<string> {
|
||||||
|
const now = Date.now();
|
||||||
|
const lines: string[] = [
|
||||||
|
`Service name: ${monitor.name}`,
|
||||||
|
`What is checked: ${monitor.method} request, healthy when it returns HTTP ${monitor.expectedStatus}`,
|
||||||
|
`Check frequency: every ${humanizeInterval(monitor.intervalSeconds)}`,
|
||||||
|
`Detected problem: ${classifyFailure(monitor, result)}`,
|
||||||
|
currentResponseLine(result),
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const recent = await db
|
||||||
|
.select({ ok: checks.ok, latencyMs: checks.latencyMs, checkedAt: checks.checkedAt })
|
||||||
|
.from(checks)
|
||||||
|
.where(and(eq(checks.monitorId, monitor.id), gte(checks.checkedAt, new Date(now - RECENT_WINDOW_MS))))
|
||||||
|
.orderBy(desc(checks.checkedAt))
|
||||||
|
.limit(RECENT_LIMIT);
|
||||||
|
|
||||||
|
if (recent.length > 0) {
|
||||||
|
let consecutive = 0;
|
||||||
|
for (const row of recent) {
|
||||||
|
if (row.ok) break;
|
||||||
|
consecutive += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (consecutive > 0) {
|
||||||
|
const outageStart = recent[consecutive - 1].checkedAt.getTime();
|
||||||
|
const cappedByWindow = consecutive === recent.length && recent.length === RECENT_LIMIT;
|
||||||
|
const outage = cappedByWindow ? '24 h or more' : humanizeDuration(now - outageStart);
|
||||||
|
lines.push(`Failed checks in a row: ${consecutive} (down for ${outage})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const flapWindow = recent.slice(0, FLAP_WINDOW);
|
||||||
|
const failed = flapWindow.filter((row) => !row.ok).length;
|
||||||
|
if (consecutive === 0 || failed < flapWindow.length) {
|
||||||
|
lines.push(`Recent pattern: ${failed} of the last ${flapWindow.length} checks failed (intermittent)`);
|
||||||
|
} else {
|
||||||
|
lines.push(`Recent pattern: every one of the last ${flapWindow.length} checks failed (hard down)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const healthyLatencies = recent.filter((row) => row.ok && typeof row.latencyMs === 'number').map((row) => row.latencyMs as number);
|
||||||
|
if (healthyLatencies.length > 0) {
|
||||||
|
lines.push(`Typical response time when healthy: about ${median(healthyLatencies)} ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastHealthy = recent.find((row) => row.ok);
|
||||||
|
lines.push(
|
||||||
|
lastHealthy
|
||||||
|
? `Last healthy check: about ${humanizeDuration(now - lastHealthy.checkedAt.getTime())} ago`
|
||||||
|
: 'Last healthy check: none in the last 24 h',
|
||||||
|
);
|
||||||
|
|
||||||
|
const up = recent.filter((row) => row.ok).length;
|
||||||
|
lines.push(`Availability: ${Math.round((up / recent.length) * 100)}% over the last 24 h (${recent.length} checks)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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))));
|
||||||
|
// The incident that triggered this run is already persisted, so discount it.
|
||||||
|
const priorCount = Math.max(0, Number(priorIncidents?.count ?? 0) - 1);
|
||||||
|
lines.push(
|
||||||
|
priorCount > 0
|
||||||
|
? `Recurrence: ${priorCount} other incident${priorCount === 1 ? '' : 's'} for this service in the last 30 days`
|
||||||
|
: 'Recurrence: first incident for this service in the last 30 days',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
JSON.stringify({
|
||||||
|
message: 'incident context enrichment failed',
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
monitorId: monitor.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { and, eq, isNull } from 'drizzle-orm';
|
||||||
|
import type { CheckResult, Monitor } from '../checks/run-check';
|
||||||
|
import { getDb } from '../db/client';
|
||||||
|
import { aiSettings, incidents } from '../db/schema';
|
||||||
|
import { requestCompletion } from './client';
|
||||||
|
import { buildIncidentContext } from './incident-context';
|
||||||
|
|
||||||
|
export const INCIDENT_MESSAGE_SYSTEM_PROMPT = [
|
||||||
|
"You write short public status updates for a website's visitors.",
|
||||||
|
'Your reader is a non-technical customer, not an engineer, and should not need any background to understand you.',
|
||||||
|
'',
|
||||||
|
'Write exactly two plain-English sentences:',
|
||||||
|
'1. What visitors may notice right now, described purely by its effect on them (pages not loading, sign-in failing, checkout not going through, slow responses). Never state the technical cause.',
|
||||||
|
'2. A calm reassurance that the problem has been detected automatically, the team has been alerted, and work to restore the service is already underway and will be completed as soon as possible.',
|
||||||
|
'',
|
||||||
|
'Rules:',
|
||||||
|
'- Never include URLs, hostnames, domain names, IP addresses, port numbers, file paths, HTTP status codes, error codes, or stack traces.',
|
||||||
|
'- Never name the technical fault (timeout, DNS, TLS, certificate, server, database, rate limit, etc.).',
|
||||||
|
'- No blame, no alarm, no speculation, and do not promise a specific fix time.',
|
||||||
|
'- Keep the whole update under 240 characters. Output only the two sentences and nothing else.',
|
||||||
|
].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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateIncidentMessage(env: Env, input: { monitor: Monitor; result: CheckResult }): Promise<string | null> {
|
||||||
|
if (!input.monitor.alertsEnabled) return null;
|
||||||
|
try {
|
||||||
|
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) return null;
|
||||||
|
|
||||||
|
const content = await requestCompletion(
|
||||||
|
{ baseUrl: settings.baseUrl, apiKey: settings.apiKey, model: settings.model },
|
||||||
|
INCIDENT_MESSAGE_SYSTEM_PROMPT,
|
||||||
|
await buildIncidentContext(db, input.monitor, input.result),
|
||||||
|
);
|
||||||
|
if (!content) return null;
|
||||||
|
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)));
|
||||||
|
return message;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
JSON.stringify({
|
||||||
|
message: 'incident message generation failed',
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
monitorId: input.monitor.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { and, eq, sql } from 'drizzle-orm';
|
import { and, eq, sql } from 'drizzle-orm';
|
||||||
|
import { generateIncidentMessage } from '../ai/incident-message';
|
||||||
import { getDb } from '../db/client';
|
import { getDb } from '../db/client';
|
||||||
import { monitors } from '../db/schema';
|
import { monitors } from '../db/schema';
|
||||||
import { sendIncidentAlert } from '../notifications/webhook';
|
import { sendIncidentAlert } from '../notifications/webhook';
|
||||||
@@ -6,6 +7,7 @@ import { buildResultStatements } from './persist-result';
|
|||||||
import { runCheck } from './run-check';
|
import { runCheck } from './run-check';
|
||||||
|
|
||||||
const MAX_MONITORS_PER_RUN = 40;
|
const MAX_MONITORS_PER_RUN = 40;
|
||||||
|
const MAX_AI_MESSAGES_PER_RUN = 10;
|
||||||
const CONCURRENCY = 10;
|
const CONCURRENCY = 10;
|
||||||
|
|
||||||
export type DueCheckSummary = {
|
export type DueCheckSummary = {
|
||||||
@@ -59,18 +61,23 @@ export async function runDueChecks(env: Env, ctx?: Pick<ExecutionContext, 'waitU
|
|||||||
|
|
||||||
await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
|
await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
|
||||||
|
|
||||||
const notifications = persisted.flatMap((item) =>
|
let aiMessagesQueued = 0;
|
||||||
item.transition === null
|
const notifications = persisted.flatMap((item) => {
|
||||||
? []
|
if (item.transition === null) return [];
|
||||||
: [
|
const work: Promise<unknown>[] = [
|
||||||
sendIncidentAlert(env, {
|
sendIncidentAlert(env, {
|
||||||
monitor: item.monitor,
|
monitor: item.monitor,
|
||||||
kind: item.transition,
|
kind: item.transition,
|
||||||
result: item.result,
|
result: item.result,
|
||||||
at: item.checkedAt,
|
at: item.checkedAt,
|
||||||
}),
|
}),
|
||||||
],
|
];
|
||||||
);
|
if (item.transition === 'opened' && item.monitor.alertsEnabled && aiMessagesQueued < MAX_AI_MESSAGES_PER_RUN) {
|
||||||
|
aiMessagesQueued += 1;
|
||||||
|
work.push(generateIncidentMessage(env, { monitor: item.monitor, result: item.result }));
|
||||||
|
}
|
||||||
|
return work;
|
||||||
|
});
|
||||||
if (notifications.length > 0) {
|
if (notifications.length > 0) {
|
||||||
const notificationWork = Promise.all(notifications).then(() => undefined);
|
const notificationWork = Promise.all(notifications).then(() => undefined);
|
||||||
if (ctx) ctx.waitUntil(notificationWork);
|
if (ctx) ctx.waitUntil(notificationWork);
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ export const notificationSettings = sqliteTable('notification_settings', {
|
|||||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const aiSettings = sqliteTable('ai_settings', {
|
||||||
|
id: integer('id').primaryKey(),
|
||||||
|
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
baseUrl: text('base_url'),
|
||||||
|
apiKey: text('api_key'),
|
||||||
|
model: text('model'),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
export const sessions = sqliteTable(
|
export const sessions = sqliteTable(
|
||||||
'sessions',
|
'sessions',
|
||||||
{
|
{
|
||||||
@@ -86,6 +96,7 @@ export const incidents = sqliteTable(
|
|||||||
resolvedAt: integer('resolved_at', { mode: 'timestamp_ms' }),
|
resolvedAt: integer('resolved_at', { mode: 'timestamp_ms' }),
|
||||||
startStatusCode: integer('start_status_code'),
|
startStatusCode: integer('start_status_code'),
|
||||||
startError: text('start_error'),
|
startError: text('start_error'),
|
||||||
|
aiMessage: text('ai_message'),
|
||||||
durationMs: integer('duration_ms'),
|
durationMs: integer('duration_ms'),
|
||||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export function isPrivateHostname(rawHostname: string): boolean {
|
||||||
|
const hostname = rawHostname
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^\[|\]$/g, '')
|
||||||
|
.replace(/\.$/, '');
|
||||||
|
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;
|
||||||
|
|
||||||
|
const ipv4 = hostname.split('.').map(Number);
|
||||||
|
if (ipv4.length === 4 && ipv4.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
|
||||||
|
const [first, second] = ipv4;
|
||||||
|
return (
|
||||||
|
first === 0 ||
|
||||||
|
first === 10 ||
|
||||||
|
first === 127 ||
|
||||||
|
(first === 169 && second === 254) ||
|
||||||
|
(first === 172 && second >= 16 && second <= 31) ||
|
||||||
|
(first === 192 && second === 168)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostname === '::' || hostname === '::1') return true;
|
||||||
|
if (/^f[cd][0-9a-f]{2}(?::|$)/i.test(hostname) || /^fe[89ab][0-9a-f](?::|$)/i.test(hostname)) return true;
|
||||||
|
const mappedIpv4 = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
||||||
|
return mappedIpv4 ? isPrivateHostname(mappedIpv4[1]) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSafeRemoteUrl(url: URL) {
|
||||||
|
return (url.protocol === 'http:' || url.protocol === 'https:') && !url.username && !url.password && !isPrivateHostname(url.hostname);
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { and, desc, eq, gte, isNull, or, sql } from 'drizzle-orm';
|
import { and, desc, eq, gte, isNull, or, sql } from 'drizzle-orm';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import { generateIncidentMessage } from '../ai/incident-message';
|
||||||
import { buildResultStatements } from '../checks/persist-result';
|
import { buildResultStatements } from '../checks/persist-result';
|
||||||
import { runCheck } from '../checks/run-check';
|
import { runCheck } from '../checks/run-check';
|
||||||
import { getDb } from '../db/client';
|
import { getDb } from '../db/client';
|
||||||
import { checks, incidents, monitors } from '../db/schema';
|
import { checks, incidents, monitors } from '../db/schema';
|
||||||
import { requireAuth, type AuthVariables } from '../lib/require-auth';
|
import { requireAuth, type AuthVariables } from '../lib/require-auth';
|
||||||
|
import { isSafeRemoteUrl } from '../lib/safe-url';
|
||||||
import { sendIncidentAlert } from '../notifications/webhook';
|
import { sendIncidentAlert } from '../notifications/webhook';
|
||||||
|
|
||||||
type MonitorMethod = 'GET' | 'HEAD' | 'POST';
|
type MonitorMethod = 'GET' | 'HEAD' | 'POST';
|
||||||
@@ -39,36 +41,6 @@ type EdgeCache = {
|
|||||||
put(request: RequestInfo | URL, response: Response): Promise<void>;
|
put(request: RequestInfo | URL, response: Response): Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isPrivateHostname(rawHostname: string): boolean {
|
|
||||||
const hostname = rawHostname
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/^\[|\]$/g, '')
|
|
||||||
.replace(/\.$/, '');
|
|
||||||
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;
|
|
||||||
|
|
||||||
const ipv4 = hostname.split('.').map(Number);
|
|
||||||
if (ipv4.length === 4 && ipv4.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
|
|
||||||
const [first, second] = ipv4;
|
|
||||||
return (
|
|
||||||
first === 0 ||
|
|
||||||
first === 10 ||
|
|
||||||
first === 127 ||
|
|
||||||
(first === 169 && second === 254) ||
|
|
||||||
(first === 172 && second >= 16 && second <= 31) ||
|
|
||||||
(first === 192 && second === 168)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hostname === '::' || hostname === '::1') return true;
|
|
||||||
if (/^f[cd][0-9a-f]{2}(?::|$)/i.test(hostname) || /^fe[89ab][0-9a-f](?::|$)/i.test(hostname)) return true;
|
|
||||||
const mappedIpv4 = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
|
||||||
return mappedIpv4 ? isPrivateHostname(mappedIpv4[1]) : false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSafeRemoteUrl(url: URL) {
|
|
||||||
return (url.protocol === 'http:' || url.protocol === 'https:') && !url.username && !url.password && !isPrivateHostname(url.hostname);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readBodyLimited(response: Response, maximum: number, truncate: boolean) {
|
async function readBodyLimited(response: Response, maximum: number, truncate: boolean) {
|
||||||
if (!response.body) return null;
|
if (!response.body) return null;
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
@@ -558,6 +530,9 @@ monitorRoutes.post('/:id/check', async (context) => {
|
|||||||
await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
|
await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
|
||||||
if (transition) {
|
if (transition) {
|
||||||
await sendIncidentAlert(context.env, { monitor, kind: transition, result, at: checkedAt });
|
await sendIncidentAlert(context.env, { monitor, kind: transition, result, at: checkedAt });
|
||||||
|
if (transition === 'opened') {
|
||||||
|
await generateIncidentMessage(context.env, { monitor, result });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const [updated] = await db.select().from(monitors).where(eq(monitors.id, monitor.id)).limit(1);
|
const [updated] = await db.select().from(monitors).where(eq(monitors.id, monitor.id)).limit(1);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import { requestCompletion } from '../ai/client';
|
||||||
|
import { SAMPLE_INCIDENT_CONTEXT } from '../ai/incident-context';
|
||||||
|
import { INCIDENT_MESSAGE_SYSTEM_PROMPT, sanitizeIncidentMessage } from '../ai/incident-message';
|
||||||
import { getDb } from '../db/client';
|
import { getDb } from '../db/client';
|
||||||
import { notificationSettings } from '../db/schema';
|
import { aiSettings, notificationSettings } from '../db/schema';
|
||||||
import { requireAuth, type AuthVariables } from '../lib/require-auth';
|
import { requireAuth, type AuthVariables } from '../lib/require-auth';
|
||||||
|
import { isSafeRemoteUrl } from '../lib/safe-url';
|
||||||
import { sendTestWebhook } from '../notifications/webhook';
|
import { sendTestWebhook } from '../notifications/webhook';
|
||||||
|
|
||||||
type NotificationInput = {
|
type NotificationInput = {
|
||||||
@@ -10,6 +14,13 @@ type NotificationInput = {
|
|||||||
webhookEnabled: boolean;
|
webhookEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AiInput = {
|
||||||
|
enabled: boolean;
|
||||||
|
baseUrl: string | null;
|
||||||
|
model: string | null;
|
||||||
|
apiKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function parseNotificationInput(value: unknown): NotificationInput | string {
|
function parseNotificationInput(value: unknown): NotificationInput | string {
|
||||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||||
return 'Invalid request body';
|
return 'Invalid request body';
|
||||||
@@ -34,6 +45,53 @@ function parseNotificationInput(value: unknown): NotificationInput | string {
|
|||||||
return { webhookUrl, webhookEnabled: body.webhookEnabled };
|
return { webhookUrl, webhookEnabled: body.webhookEnabled };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseAiInput(value: unknown): AiInput | string {
|
||||||
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||||
|
return 'Invalid request body';
|
||||||
|
}
|
||||||
|
const body = value as Record<string, unknown>;
|
||||||
|
if (typeof body.enabled !== 'boolean') return 'enabled must be a boolean';
|
||||||
|
|
||||||
|
const rawBaseUrl = typeof body.baseUrl === 'string' ? body.baseUrl.trim() : body.baseUrl;
|
||||||
|
if (rawBaseUrl !== null && typeof rawBaseUrl !== 'string') return 'baseUrl must be a URL or null';
|
||||||
|
let baseUrl = rawBaseUrl || null;
|
||||||
|
if (baseUrl) {
|
||||||
|
try {
|
||||||
|
const url = new URL(baseUrl);
|
||||||
|
if (url.protocol !== 'https:' || !isSafeRemoteUrl(url)) throw new Error('unsafe URL');
|
||||||
|
baseUrl = url.toString().replace(/\/+$/, '');
|
||||||
|
} catch {
|
||||||
|
return 'Enter a valid public https base URL';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawModel = typeof body.model === 'string' ? body.model.trim() : body.model;
|
||||||
|
if (rawModel !== null && typeof rawModel !== 'string') return 'model must be a string or null';
|
||||||
|
const model = rawModel || null;
|
||||||
|
if (model && model.length > 100) return 'model must be between 1 and 100 characters';
|
||||||
|
|
||||||
|
let apiKey: string | undefined;
|
||||||
|
if (body.apiKey !== undefined && body.apiKey !== null) {
|
||||||
|
if (typeof body.apiKey !== 'string') return 'apiKey must be a string, null, or omitted';
|
||||||
|
apiKey = body.apiKey.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { enabled: body.enabled, baseUrl, model, apiKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicAiSettings(settings: typeof aiSettings.$inferSelect | undefined) {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
enabled: settings?.enabled ?? false,
|
||||||
|
baseUrl: settings?.baseUrl ?? null,
|
||||||
|
model: settings?.model ?? null,
|
||||||
|
apiKeySet: Boolean(settings?.apiKey),
|
||||||
|
apiKeyPreview: settings?.apiKey ? `••••••${settings.apiKey.slice(-4)}` : null,
|
||||||
|
createdAt: settings?.createdAt ?? null,
|
||||||
|
updatedAt: settings?.updatedAt ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const settingsRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>();
|
const settingsRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>();
|
||||||
settingsRoutes.use('*', requireAuth);
|
settingsRoutes.use('*', requireAuth);
|
||||||
|
|
||||||
@@ -75,4 +133,53 @@ settingsRoutes.post('/notifications/test', async (context) => {
|
|||||||
return context.json({ ok: true });
|
return context.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
settingsRoutes.get('/ai', async (context) => {
|
||||||
|
const [settings] = await getDb(context.env).select().from(aiSettings).where(eq(aiSettings.id, 1)).limit(1);
|
||||||
|
return context.json({ settings: publicAiSettings(settings) });
|
||||||
|
});
|
||||||
|
|
||||||
|
settingsRoutes.put('/ai', async (context) => {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await context.req.json();
|
||||||
|
} catch {
|
||||||
|
return context.json({ message: 'Invalid request body' }, 400);
|
||||||
|
}
|
||||||
|
const input = parseAiInput(body);
|
||||||
|
if (typeof input === 'string') return context.json({ message: input }, 400);
|
||||||
|
|
||||||
|
const db = getDb(context.env);
|
||||||
|
const [existing] = await db.select().from(aiSettings).where(eq(aiSettings.id, 1)).limit(1);
|
||||||
|
const apiKey = input.apiKey === undefined ? (existing?.apiKey ?? null) : input.apiKey || null;
|
||||||
|
if (input.enabled && !input.baseUrl) return context.json({ message: 'A base URL is required when AI messages are enabled' }, 400);
|
||||||
|
if (input.enabled && !input.model) return context.json({ message: 'A model is required when AI messages are enabled' }, 400);
|
||||||
|
if (input.enabled && !apiKey) return context.json({ message: 'An API key is required when AI messages are enabled' }, 400);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const [settings] = await db
|
||||||
|
.insert(aiSettings)
|
||||||
|
.values({ id: 1, enabled: input.enabled, baseUrl: input.baseUrl, apiKey, model: input.model, createdAt: now, updatedAt: now })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: aiSettings.id,
|
||||||
|
set: { enabled: input.enabled, baseUrl: input.baseUrl, apiKey, model: input.model, updatedAt: now },
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return context.json({ settings: publicAiSettings(settings) });
|
||||||
|
});
|
||||||
|
|
||||||
|
settingsRoutes.post('/ai/test', async (context) => {
|
||||||
|
const [settings] = await getDb(context.env).select().from(aiSettings).where(eq(aiSettings.id, 1)).limit(1);
|
||||||
|
if (!settings?.baseUrl || !settings.apiKey || !settings.model) {
|
||||||
|
return context.json({ message: 'Save a base URL, API key, and model first' }, 400);
|
||||||
|
}
|
||||||
|
const content = await requestCompletion(
|
||||||
|
{ baseUrl: settings.baseUrl, apiKey: settings.apiKey, model: settings.model },
|
||||||
|
INCIDENT_MESSAGE_SYSTEM_PROMPT,
|
||||||
|
SAMPLE_INCIDENT_CONTEXT,
|
||||||
|
);
|
||||||
|
const message = content ? sanitizeIncidentMessage(content) : null;
|
||||||
|
if (!message) return context.json({ message: 'AI message generation failed' }, 502);
|
||||||
|
return context.json({ ok: true, message });
|
||||||
|
});
|
||||||
|
|
||||||
export default settingsRoutes;
|
export default settingsRoutes;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { and, eq, gte, inArray, lt, sql } from 'drizzle-orm';
|
import { and, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import { deterministicIncidentMessage } from '../ai/fallback-message';
|
||||||
import { getDb } from '../db/client';
|
import { getDb } from '../db/client';
|
||||||
import { checks, monitorDailyStats, monitors } from '../db/schema';
|
import { checks, incidents, monitorDailyStats, monitors } from '../db/schema';
|
||||||
import { resolveFavicon } from './monitors';
|
import { resolveFavicon } from './monitors';
|
||||||
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
@@ -22,6 +23,12 @@ type DailyAggregate = {
|
|||||||
upChecks: number;
|
upChecks: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type OpenIncident = {
|
||||||
|
monitorId: number;
|
||||||
|
aiMessage: string | null;
|
||||||
|
startStatusCode: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
type EdgeCache = {
|
type EdgeCache = {
|
||||||
match(request: RequestInfo | URL): Promise<Response | undefined>;
|
match(request: RequestInfo | URL): Promise<Response | undefined>;
|
||||||
put(request: RequestInfo | URL, response: Response): Promise<void>;
|
put(request: RequestInfo | URL, response: Response): Promise<void>;
|
||||||
@@ -76,9 +83,10 @@ statusRoutes.get('/', async (context) => {
|
|||||||
const monitorIds = monitorRows.map((monitor) => monitor.id);
|
const monitorIds = monitorRows.map((monitor) => monitor.id);
|
||||||
let historicalRows: DailyAggregate[] = [];
|
let historicalRows: DailyAggregate[] = [];
|
||||||
let todayRows: DailyAggregate[] = [];
|
let todayRows: DailyAggregate[] = [];
|
||||||
|
let openIncidentRows: OpenIncident[] = [];
|
||||||
|
|
||||||
if (monitorIds.length > 0) {
|
if (monitorIds.length > 0) {
|
||||||
[historicalRows, todayRows] = await Promise.all([
|
[historicalRows, todayRows, openIncidentRows] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
monitorId: monitorDailyStats.monitorId,
|
monitorId: monitorDailyStats.monitorId,
|
||||||
@@ -105,6 +113,14 @@ statusRoutes.get('/', async (context) => {
|
|||||||
.from(checks)
|
.from(checks)
|
||||||
.where(and(inArray(checks.monitorId, monitorIds), gte(checks.checkedAt, new Date(today))))
|
.where(and(inArray(checks.monitorId, monitorIds), gte(checks.checkedAt, new Date(today))))
|
||||||
.groupBy(checks.monitorId),
|
.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))),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +130,7 @@ statusRoutes.get('/', async (context) => {
|
|||||||
if (buckets) buckets.push(row);
|
if (buckets) buckets.push(row);
|
||||||
else bucketsByMonitor.set(row.monitorId, [row]);
|
else bucketsByMonitor.set(row.monitorId, [row]);
|
||||||
}
|
}
|
||||||
|
const openIncidentsByMonitor = new Map(openIncidentRows.map((incident) => [incident.monitorId, incident]));
|
||||||
|
|
||||||
const services = monitorRows.map((monitor) => {
|
const services = monitorRows.map((monitor) => {
|
||||||
const buckets = bucketsByMonitor.get(monitor.id) ?? [];
|
const buckets = bucketsByMonitor.get(monitor.id) ?? [];
|
||||||
@@ -128,10 +145,13 @@ statusRoutes.get('/', async (context) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const openIncident = openIncidentsByMonitor.get(monitor.id);
|
||||||
return {
|
return {
|
||||||
id: monitor.id,
|
id: monitor.id,
|
||||||
name: monitor.name,
|
name: monitor.name,
|
||||||
status: serviceStatus(monitor.lastOk),
|
status: serviceStatus(monitor.lastOk),
|
||||||
|
message:
|
||||||
|
monitor.lastOk === false ? (openIncident?.aiMessage ?? deterministicIncidentMessage(openIncident?.startStatusCode ?? null)) : null,
|
||||||
lastCheckedAt: monitor.lastCheckedAt?.toISOString() ?? null,
|
lastCheckedAt: monitor.lastCheckedAt?.toISOString() ?? null,
|
||||||
uptime90d: roundUptime(upChecks, totalChecks),
|
uptime90d: roundUptime(upChecks, totalChecks),
|
||||||
history,
|
history,
|
||||||
|
|||||||
+251
@@ -0,0 +1,251 @@
|
|||||||
|
import { applyD1Migrations, env, SELF, type D1Migration } from 'cloudflare:test';
|
||||||
|
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';
|
||||||
|
import type { Monitor } from '../src/worker/checks/run-check';
|
||||||
|
import { hashPassword } from '../src/worker/lib/password';
|
||||||
|
|
||||||
|
const ADMIN_PASSWORD = 'correct-horse-battery-staple';
|
||||||
|
|
||||||
|
const monitor = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Public API',
|
||||||
|
url: 'https://api.example.com/health',
|
||||||
|
method: 'GET',
|
||||||
|
expectedStatus: 200,
|
||||||
|
intervalSeconds: 300,
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
enabled: true,
|
||||||
|
alertsEnabled: true,
|
||||||
|
lastOk: false,
|
||||||
|
lastStatusCode: 503,
|
||||||
|
lastLatencyMs: 250,
|
||||||
|
lastError: 'Expected HTTP 200, received 503',
|
||||||
|
lastCheckedAt: new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} satisfies Monitor;
|
||||||
|
|
||||||
|
const failedResult = {
|
||||||
|
ok: false,
|
||||||
|
statusCode: 503,
|
||||||
|
latencyMs: 250,
|
||||||
|
error: 'Expected HTTP 200, received 503',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function resetDatabase() {
|
||||||
|
await env.DB.batch([
|
||||||
|
env.DB.prepare('DELETE FROM checks'),
|
||||||
|
env.DB.prepare('DELETE FROM incidents'),
|
||||||
|
env.DB.prepare('DELETE FROM monitor_daily_stats'),
|
||||||
|
env.DB.prepare('DELETE FROM ai_settings'),
|
||||||
|
env.DB.prepare('DELETE FROM notification_settings'),
|
||||||
|
env.DB.prepare('DELETE FROM monitors'),
|
||||||
|
env.DB.prepare('DELETE FROM login_attempts'),
|
||||||
|
env.DB.prepare('DELETE FROM sessions'),
|
||||||
|
env.DB.prepare('DELETE FROM admin_credentials'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedMonitorAndIncident(aiMessage: string | null = null) {
|
||||||
|
const now = Date.now();
|
||||||
|
await env.DB.prepare(
|
||||||
|
`INSERT INTO monitors (
|
||||||
|
id, name, url, method, expected_status, interval_seconds, timeout_ms,
|
||||||
|
enabled, alerts_enabled, last_ok, last_status_code, last_latency_ms,
|
||||||
|
last_error, last_checked_at, created_at, updated_at
|
||||||
|
) VALUES (1, ?, ?, 'GET', 200, 300, 10000, 1, 1, 0, 503, 250, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.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, ?, ?)',
|
||||||
|
)
|
||||||
|
.bind(now, monitor.lastError, aiMessage, now, now)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedAiSettings(enabled = true) {
|
||||||
|
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, ?, 'https://api.openai.com/v1', 'sk-secret-4f2a', 'gpt-4o-mini', ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(enabled ? 1 : 0, now, now)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticatedCookie() {
|
||||||
|
const now = Date.now();
|
||||||
|
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', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Origin: 'https://example.com' },
|
||||||
|
body: JSON.stringify({ password: ADMIN_PASSWORD }),
|
||||||
|
});
|
||||||
|
return response.headers.get('Set-Cookie')?.split(';', 1)[0] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settingsRequest(path: string, cookie: string, init?: RequestInit) {
|
||||||
|
return SELF.fetch(`https://example.com/api/settings${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Origin: 'https://example.com',
|
||||||
|
Cookie: cookie,
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AI incident messages', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
|
||||||
|
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
|
||||||
|
});
|
||||||
|
beforeEach(resetDatabase);
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('stores an API key but only returns its masked state', async () => {
|
||||||
|
const cookie = await authenticatedCookie();
|
||||||
|
const response = await settingsRequest('/ai', cookie, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
baseUrl: 'https://api.openai.com/v1/',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
apiKey: 'sk-secret-4f2a',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const body = await response.json<{
|
||||||
|
settings: { baseUrl: string; apiKeySet: boolean; apiKeyPreview: string };
|
||||||
|
}>();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body.settings).toMatchObject({
|
||||||
|
baseUrl: 'https://api.openai.com/v1',
|
||||||
|
apiKeySet: true,
|
||||||
|
apiKeyPreview: '••••••4f2a',
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(body)).not.toContain('sk-secret');
|
||||||
|
|
||||||
|
const getResponse = await settingsRequest('/ai', cookie);
|
||||||
|
const getText = await getResponse.text();
|
||||||
|
expect(getText).not.toContain('sk-secret');
|
||||||
|
expect(JSON.parse(getText).settings.apiKeySet).toBe(true);
|
||||||
|
const stored = await env.DB.prepare('SELECT api_key AS apiKey FROM ai_settings WHERE id = 1').first<{ apiKey: string }>();
|
||||||
|
expect(stored?.apiKey).toBe('sk-secret-4f2a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves an omitted key and clears an empty key', async () => {
|
||||||
|
await seedAiSettings(false);
|
||||||
|
const cookie = await authenticatedCookie();
|
||||||
|
const input = { enabled: false, baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini' };
|
||||||
|
|
||||||
|
expect((await settingsRequest('/ai', cookie, { method: 'PUT', body: JSON.stringify(input) })).status).toBe(200);
|
||||||
|
let stored = await env.DB.prepare('SELECT api_key AS apiKey FROM ai_settings').first<{ apiKey: string | null }>();
|
||||||
|
expect(stored?.apiKey).toBe('sk-secret-4f2a');
|
||||||
|
|
||||||
|
expect((await settingsRequest('/ai', cookie, { method: 'PUT', body: JSON.stringify({ ...input, apiKey: '' }) })).status).toBe(200);
|
||||||
|
stored = await env.DB.prepare('SELECT api_key AS apiKey FROM ai_settings').first<{ apiKey: string | null }>();
|
||||||
|
expect(stored?.apiKey).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['non-https URL', { enabled: false, baseUrl: 'http://api.example.com/v1', model: 'model' }],
|
||||||
|
['private URL', { enabled: false, baseUrl: 'https://127.0.0.1/v1', model: 'model' }],
|
||||||
|
['missing enabled key', { enabled: true, baseUrl: 'https://api.example.com/v1', model: 'model' }],
|
||||||
|
])('rejects %s', async (_label, input) => {
|
||||||
|
const cookie = await authenticatedCookie();
|
||||||
|
const response = await settingsRequest('/ai', cookie, { method: 'PUT', body: JSON.stringify(input) });
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses a two-sentence completion and writes it to the open incident', async () => {
|
||||||
|
await seedMonitorAndIncident();
|
||||||
|
await seedAiSettings();
|
||||||
|
const now = Date.now();
|
||||||
|
await env.DB.batch([
|
||||||
|
env.DB.prepare('INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (1, 1, 200, 180, ?)').bind(
|
||||||
|
now - 20 * 60_000,
|
||||||
|
),
|
||||||
|
env.DB.prepare('INSERT INTO checks (monitor_id, ok, status_code, latency_ms, error, checked_at) VALUES (1, 0, 503, 250, ?, ?)').bind(
|
||||||
|
'Expected HTTP 200, received 503',
|
||||||
|
now - 60_000,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const completion =
|
||||||
|
'Some visitors may see errors or slow responses when using the service.\n' +
|
||||||
|
'We detected this automatically and are already working to restore it as soon as possible.';
|
||||||
|
const expected =
|
||||||
|
'Some visitors may see errors or slow responses when using the service. ' +
|
||||||
|
'We detected this automatically and are already working to restore it as soon as possible.';
|
||||||
|
const fetchMock = vi.fn(async () => Response.json({ choices: [{ message: { content: completion } }] }));
|
||||||
|
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 }>();
|
||||||
|
expect(incident?.aiMessage).toBe(expected);
|
||||||
|
|
||||||
|
const promptText = String(JSON.parse(String(fetchMock.mock.calls[0][1]?.body)).messages[1].content);
|
||||||
|
expect(promptText).not.toContain(monitor.url);
|
||||||
|
expect(promptText).toContain('Detected problem:');
|
||||||
|
expect(promptText).toContain('Typical response time when healthy: about 180 ms');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fetch when AI settings are disabled', async () => {
|
||||||
|
await seedMonitorAndIncident();
|
||||||
|
await seedAiSettings(false);
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await expect(generateIncidentMessage(env, { monitor, result: failedResult })).resolves.toBeNull();
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['network failure', async () => Promise.reject(new Error('offline'))],
|
||||||
|
['error response', async () => new Response(null, { status: 500 })],
|
||||||
|
['malformed JSON', async () => new Response('not json', { status: 200 })],
|
||||||
|
])('swallows a %s and leaves the incident unchanged', async (_label, implementation) => {
|
||||||
|
await seedMonitorAndIncident();
|
||||||
|
await seedAiSettings();
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects generated content containing a URL', async () => {
|
||||||
|
await seedMonitorAndIncident();
|
||||||
|
await seedAiSettings();
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () => Response.json({ choices: [{ message: { content: 'Service failed at https://internal.example.com.' } }] })),
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
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')
|
||||||
|
).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();
|
||||||
|
body = await (
|
||||||
|
await SELF.fetch('https://example.com/api/status')
|
||||||
|
).json<{
|
||||||
|
services: Array<{ message: string | null }>;
|
||||||
|
}>();
|
||||||
|
expect(body.services[0].message).toBe(deterministicIncidentMessage(503));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ type PublicStatusResponse = {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'up' | 'down' | 'unknown';
|
status: 'up' | 'down' | 'unknown';
|
||||||
|
message: string | null;
|
||||||
lastCheckedAt: string | null;
|
lastCheckedAt: string | null;
|
||||||
uptime90d: number | null;
|
uptime90d: number | null;
|
||||||
history: Array<{ day: number; uptimePct: number | null }>;
|
history: Array<{ day: number; uptimePct: number | null }>;
|
||||||
@@ -94,6 +95,7 @@ describe('public status API', () => {
|
|||||||
expect(body.services[0]).toMatchObject({
|
expect(body.services[0]).toMatchObject({
|
||||||
name: 'Public API',
|
name: 'Public API',
|
||||||
status: 'up',
|
status: 'up',
|
||||||
|
message: null,
|
||||||
uptime90d: null,
|
uptime90d: null,
|
||||||
history: [],
|
history: [],
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user