mirror of
https://github.com/Nezumi-2711/uptime-monitoring.git
synced 2026-09-22 13:48:31 +00:00
feat: add tracking feature
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
CREATE TABLE `incidents` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`monitor_id` integer NOT NULL,
|
||||
`started_at` integer NOT NULL,
|
||||
`resolved_at` integer,
|
||||
`start_status_code` integer,
|
||||
`start_error` text,
|
||||
`duration_ms` integer,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`monitor_id`) REFERENCES `monitors`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `incidents_monitor_id_started_at_idx` ON `incidents` (`monitor_id`,`started_at`);--> statement-breakpoint
|
||||
CREATE TABLE `monitor_daily_stats` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`monitor_id` integer NOT NULL,
|
||||
`day` integer NOT NULL,
|
||||
`total_checks` integer NOT NULL,
|
||||
`up_checks` integer NOT NULL,
|
||||
`avg_latency_ms` integer,
|
||||
`min_latency_ms` integer,
|
||||
`max_latency_ms` integer,
|
||||
FOREIGN KEY (`monitor_id`) REFERENCES `monitors`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `monitor_daily_stats_monitor_id_day_uidx` ON `monitor_daily_stats` (`monitor_id`,`day`);--> statement-breakpoint
|
||||
CREATE TABLE `notification_settings` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`webhook_url` text,
|
||||
`webhook_enabled` integer DEFAULT false NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `monitors` ADD `alerts_enabled` integer DEFAULT true NOT NULL;
|
||||
@@ -0,0 +1,592 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "c137c06e-d876-42ae-a0a9-39115b3f0ebf",
|
||||
"prevId": "92127a68-ea12-46dd-bab7-1d7d91259fd4",
|
||||
"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": {}
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,13 @@
|
||||
"when": 1787839000288,
|
||||
"tag": "0002_lean_mac_gargan",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1787887541718,
|
||||
"tag": "0003_colorful_kingpin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+9
-1
@@ -2,13 +2,21 @@ import { RequireAuth } from "./components/RequireAuth";
|
||||
import { usePathname } from "./lib/router";
|
||||
import { DashboardPage } from "./pages/DashboardPage";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { MonitorDetailPage } from "./pages/MonitorDetailPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
|
||||
function App() {
|
||||
const pathname = usePathname();
|
||||
if (pathname === "/login") return <LoginPage />;
|
||||
const monitorMatch = pathname.match(/^\/monitors\/(\d+)\/?$/);
|
||||
const page = monitorMatch
|
||||
? <MonitorDetailPage id={Number(monitorMatch[1])} />
|
||||
: pathname === "/settings"
|
||||
? <SettingsPage />
|
||||
: <DashboardPage />;
|
||||
return (
|
||||
<RequireAuth>
|
||||
<DashboardPage />
|
||||
{page}
|
||||
</RequireAuth>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function getJson<T>(
|
||||
}
|
||||
|
||||
function sendJson<T>(
|
||||
method: "POST" | "PATCH" | "DELETE",
|
||||
method: "POST" | "PUT" | "PATCH" | "DELETE",
|
||||
input: RequestInfo | URL,
|
||||
body?: unknown,
|
||||
init: RequestInit = {},
|
||||
@@ -74,6 +74,14 @@ export function patchJson<T>(
|
||||
return sendJson<T>("PATCH", input, body, init);
|
||||
}
|
||||
|
||||
export function putJson<T>(
|
||||
input: RequestInfo | URL,
|
||||
body?: unknown,
|
||||
init: RequestInit = {},
|
||||
) {
|
||||
return sendJson<T>("PUT", input, body, init);
|
||||
}
|
||||
|
||||
export function deleteJson<T>(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit = {},
|
||||
|
||||
@@ -11,6 +11,7 @@ export type Monitor = {
|
||||
intervalSeconds: number;
|
||||
timeoutMs: number;
|
||||
enabled: boolean;
|
||||
alertsEnabled: boolean;
|
||||
lastOk: boolean | null;
|
||||
lastStatusCode: number | null;
|
||||
lastLatencyMs: number | null;
|
||||
@@ -28,6 +29,7 @@ export type MonitorInput = {
|
||||
intervalSeconds: number;
|
||||
timeoutMs: number;
|
||||
enabled?: boolean;
|
||||
alertsEnabled?: boolean;
|
||||
};
|
||||
|
||||
export type CheckResult = {
|
||||
@@ -37,6 +39,36 @@ export type CheckResult = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type Check = CheckResult & {
|
||||
id: number;
|
||||
monitorId: number;
|
||||
checkedAt: string;
|
||||
};
|
||||
|
||||
export type Incident = {
|
||||
id: number;
|
||||
monitorId: number;
|
||||
startedAt: string;
|
||||
resolvedAt: string | null;
|
||||
startStatusCode: number | null;
|
||||
startError: string | null;
|
||||
durationMs: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type StatsWindow = {
|
||||
uptimePct: number | null;
|
||||
totalChecks: number;
|
||||
upChecks: number;
|
||||
avgLatencyMs: number | null;
|
||||
incidentCount: number;
|
||||
};
|
||||
|
||||
export type MonitorStats = {
|
||||
windows: Record<"24h" | "7d" | "30d" | "90d", StatsWindow>;
|
||||
};
|
||||
|
||||
export function listMonitors(signal?: AbortSignal) {
|
||||
return getJson<{ monitors: Monitor[] }>("/api/monitors", {
|
||||
signal,
|
||||
@@ -44,6 +76,22 @@ export function listMonitors(signal?: AbortSignal) {
|
||||
});
|
||||
}
|
||||
|
||||
export function getMonitor(id: number, signal?: AbortSignal) {
|
||||
return getJson<{ monitor: Monitor }>(`/api/monitors/${id}`, { signal, credentials: "same-origin" });
|
||||
}
|
||||
|
||||
export function listChecks(id: number, limit = 100, signal?: AbortSignal) {
|
||||
return getJson<{ checks: Check[] }>(`/api/monitors/${id}/checks?limit=${limit}`, { signal, credentials: "same-origin" });
|
||||
}
|
||||
|
||||
export function getMonitorStats(id: number, signal?: AbortSignal) {
|
||||
return getJson<MonitorStats>(`/api/monitors/${id}/stats`, { signal, credentials: "same-origin" });
|
||||
}
|
||||
|
||||
export function listIncidents(id: number, limit = 50, signal?: AbortSignal) {
|
||||
return getJson<{ incidents: Incident[] }>(`/api/monitors/${id}/incidents?limit=${limit}`, { signal, credentials: "same-origin" });
|
||||
}
|
||||
|
||||
export function createMonitor(input: MonitorInput) {
|
||||
return postJson<{ monitor: Monitor }>("/api/monitors", input);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { getJson, postJson, putJson } from "./http";
|
||||
|
||||
export type NotificationSettings = {
|
||||
id: number;
|
||||
webhookUrl: string | null;
|
||||
webhookEnabled: boolean;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type NotificationSettingsInput = Pick<NotificationSettings, "webhookUrl" | "webhookEnabled">;
|
||||
|
||||
export function getNotificationSettings(signal?: AbortSignal) {
|
||||
return getJson<{ settings: NotificationSettings }>("/api/settings/notifications", {
|
||||
signal,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
}
|
||||
|
||||
export function updateNotificationSettings(input: NotificationSettingsInput) {
|
||||
return putJson<{ settings: NotificationSettings }>("/api/settings/notifications", input);
|
||||
}
|
||||
|
||||
export function testNotificationWebhook() {
|
||||
return postJson<{ ok: true }>("/api/settings/notifications/test");
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Check } from "../../api/monitors";
|
||||
|
||||
export function LatencySparkline({ checks }: { checks: Check[] }) {
|
||||
const values = checks.toReversed().map((check) => check.latencyMs);
|
||||
if (values.length < 2) return <div className="chart-empty">More checks are needed to draw latency.</div>;
|
||||
const width = 720;
|
||||
const height = 180;
|
||||
const padding = 12;
|
||||
const minimum = Math.min(...values);
|
||||
const maximum = Math.max(...values);
|
||||
const range = Math.max(maximum - minimum, 1);
|
||||
const points = values.map((value, index) => {
|
||||
const x = padding + (index / (values.length - 1)) * (width - padding * 2);
|
||||
const y = height - padding - ((value - minimum) / range) * (height - padding * 2);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(" ");
|
||||
|
||||
return (
|
||||
<div className="sparkline-wrap">
|
||||
<svg className="latency-sparkline" viewBox={`0 0 ${width} ${height}`} role="img" aria-label={`Latency from ${minimum} to ${maximum} milliseconds`}>
|
||||
<defs>
|
||||
<linearGradient id="latency-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#3ecf8e" stopOpacity="0.22" />
|
||||
<stop offset="1" stopColor="#3ecf8e" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path className="chart-grid-line" d={`M 0 ${height * 0.25} H ${width} M 0 ${height * 0.5} H ${width} M 0 ${height * 0.75} H ${width}`} />
|
||||
<polygon points={`${padding},${height - padding} ${points} ${width - padding},${height - padding}`} fill="url(#latency-fill)" />
|
||||
<polyline points={points} fill="none" stroke="#24b47e" strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
<div className="chart-scale"><span>{maximum} ms</span><span>{minimum} ms</span></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Check } from "../../api/monitors";
|
||||
|
||||
export function UptimeBar({ checks }: { checks: Check[] }) {
|
||||
if (checks.length === 0) return <div className="chart-empty">No availability checks recorded yet.</div>;
|
||||
const ordered = checks.toReversed();
|
||||
return (
|
||||
<div>
|
||||
<div className="uptime-bar" role="img" aria-label={`${ordered.filter((check) => check.ok).length} of ${ordered.length} recent checks succeeded`}>
|
||||
{ordered.map((check) => (
|
||||
<span
|
||||
className={check.ok ? "is-up" : "is-down"}
|
||||
key={check.id}
|
||||
title={`${new Date(check.checkedAt).toLocaleString()} — ${check.ok ? "Up" : "Down"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="uptime-legend"><span>Oldest</span><span><i className="legend-up" /> Up <i className="legend-down" /> Down</span><span>Latest</span></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { type FormEvent, useState } from "react";
|
||||
import { ArrowRight, Database, RefreshCw, Zap } from "lucide-react";
|
||||
import type { Monitor, MonitorInput, MonitorMethod } from "../api/monitors";
|
||||
import { useLogoutMutation } from "../queries/auth";
|
||||
import { navigate } from "../lib/router";
|
||||
import {
|
||||
useCreateMonitorMutation,
|
||||
useDeleteMonitorMutation,
|
||||
@@ -70,6 +71,7 @@ export function DashboardPage() {
|
||||
intervalSeconds: monitor.intervalSeconds,
|
||||
timeoutMs: monitor.timeoutMs,
|
||||
enabled: monitor.enabled,
|
||||
alertsEnabled: monitor.alertsEnabled,
|
||||
});
|
||||
setFormOpen(true);
|
||||
}
|
||||
@@ -106,6 +108,7 @@ export function DashboardPage() {
|
||||
</a>
|
||||
<div className="nav-actions">
|
||||
<span className="header-context">Production monitors</span>
|
||||
<button className="nav-auth" type="button" onClick={() => navigate("/settings")}>Settings</button>
|
||||
<button className="nav-auth" type="button" onClick={() => logoutMutation.mutate()} disabled={logoutMutation.isPending}>
|
||||
{logoutMutation.isPending ? "Signing out…" : "Sign out"}
|
||||
</button>
|
||||
@@ -149,7 +152,8 @@ export function DashboardPage() {
|
||||
<label className="field"><span>Interval</span><select value={form.intervalSeconds} onChange={(event) => setForm({ ...form, intervalSeconds: Number(event.target.value) })}><option value="300">5 minutes</option><option value="900">15 minutes</option><option value="1800">30 minutes</option><option value="3600">1 hour</option><option value="86400">24 hours</option></select></label>
|
||||
<label className="field"><span>Timeout (ms)</span><input type="number" min="1000" max="30000" step="1000" value={form.timeoutMs} onChange={(event) => setForm({ ...form, timeoutMs: event.target.valueAsNumber })} required /></label>
|
||||
<label className="toggle-field"><input type="checkbox" checked={form.enabled ?? true} onChange={(event) => setForm({ ...form, enabled: event.target.checked })} /><span>Enable scheduled checks</span></label>
|
||||
<div className="form-actions">
|
||||
<label className="toggle-field"><input type="checkbox" checked={form.alertsEnabled ?? true} onChange={(event) => setForm({ ...form, alertsEnabled: event.target.checked })} /><span>Enable incident alerts</span></label>
|
||||
<div className="form-actions compact-actions">
|
||||
<button className="secondary-button" type="button" onClick={closeForm}>Cancel</button>
|
||||
<button className="primary-button" type="submit" disabled={formMutation.isPending}>{formMutation.isPending ? "Saving…" : editing ? "Save changes" : "Add monitor"}</button>
|
||||
</div>
|
||||
@@ -182,9 +186,9 @@ export function DashboardPage() {
|
||||
const toggling = updateMutation.isPending && updateMutation.variables?.id === monitor.id;
|
||||
return (
|
||||
<article className={`service-row ${monitor.enabled ? "" : "is-disabled"}`} key={monitor.id}>
|
||||
<div className="service-name"><span className="service-icon"><Database /></span><div><strong>{monitor.name}</strong><small title={monitor.url}>{monitor.url}</small><span className="monitor-meta">{monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m</span></div></div>
|
||||
<div className="service-name"><span className="service-icon"><Database /></span><div><button className="monitor-name-link" type="button" onClick={() => navigate(`/monitors/${monitor.id}`)}>{monitor.name}</button><small title={monitor.url}>{monitor.url}</small><span className="monitor-meta">{monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m</span></div></div>
|
||||
<div className="monitor-result"><span className={`row-status ${checking ? "checking" : status.className}`}><i />{checking ? "Checking" : status.label}</span><code>{monitor.lastStatusCode === null ? "—" : `HTTP ${monitor.lastStatusCode}`} · {monitor.lastLatencyMs === null ? "—" : `${monitor.lastLatencyMs} ms`}</code><small title={monitor.lastError ?? undefined}>{monitor.lastError ?? formatCheckedAt(monitor.lastCheckedAt)}</small></div>
|
||||
<div className="row-actions"><button type="button" onClick={() => checkMutation.mutate(monitor.id)} disabled={checking}>Check now</button><button type="button" onClick={() => openEditForm(monitor)}>Edit</button><button type="button" onClick={() => updateMutation.mutate({ id: monitor.id, input: { enabled: !monitor.enabled } })} disabled={toggling}>{monitor.enabled ? "Disable" : "Enable"}</button><button className="danger-action" type="button" onClick={() => handleDelete(monitor)} disabled={deleting}>{deleting ? "Deleting…" : "Delete"}</button></div>
|
||||
<div className="row-actions"><button type="button" onClick={() => navigate(`/monitors/${monitor.id}`)}>History</button><button type="button" onClick={() => checkMutation.mutate(monitor.id)} disabled={checking}>Check now</button><button type="button" onClick={() => openEditForm(monitor)}>Edit</button><button type="button" onClick={() => updateMutation.mutate({ id: monitor.id, input: { enabled: !monitor.enabled } })} disabled={toggling}>{monitor.enabled ? "Disable" : "Enable"}</button><button className="danger-action" type="button" onClick={() => handleDelete(monitor)} disabled={deleting}>{deleting ? "Deleting…" : "Delete"}</button></div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ArrowLeft, BellOff, CheckCircle2, Clock3, ExternalLink, RefreshCw, Zap } from "lucide-react";
|
||||
import { LatencySparkline } from "../components/charts/LatencySparkline";
|
||||
import { UptimeBar } from "../components/charts/UptimeBar";
|
||||
import { navigate } from "../lib/router";
|
||||
import { useLogoutMutation } from "../queries/auth";
|
||||
import {
|
||||
useMonitorChecksQuery,
|
||||
useMonitorIncidentsQuery,
|
||||
useMonitorQuery,
|
||||
useMonitorStatsQuery,
|
||||
useRunCheckMutation,
|
||||
} from "../queries/monitors";
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat("en", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | null, startedAt: string) {
|
||||
const duration = ms ?? Math.max(0, Date.now() - new Date(startedAt).getTime());
|
||||
if (duration < 60_000) return `${Math.max(1, Math.round(duration / 1000))} sec`;
|
||||
if (duration < 3_600_000) return `${Math.round(duration / 60_000)} min`;
|
||||
if (duration < 86_400_000) return `${Math.round(duration / 3_600_000)} hr`;
|
||||
return `${Math.round(duration / 86_400_000)} days`;
|
||||
}
|
||||
|
||||
function statusDetails(lastOk: boolean | null) {
|
||||
if (lastOk === true) return { label: "Operational", className: "online" };
|
||||
if (lastOk === false) return { label: "Down", className: "offline" };
|
||||
return { label: "Awaiting first check", className: "checking" };
|
||||
}
|
||||
|
||||
export function MonitorDetailPage({ id }: { id: number }) {
|
||||
const monitorQuery = useMonitorQuery(id);
|
||||
const checksQuery = useMonitorChecksQuery(id);
|
||||
const statsQuery = useMonitorStatsQuery(id);
|
||||
const incidentsQuery = useMonitorIncidentsQuery(id);
|
||||
const checkMutation = useRunCheckMutation();
|
||||
const logoutMutation = useLogoutMutation();
|
||||
const monitor = monitorQuery.data?.monitor;
|
||||
const checks = checksQuery.data?.checks ?? [];
|
||||
const incidents = incidentsQuery.data?.incidents ?? [];
|
||||
const status = statusDetails(monitor?.lastOk ?? null);
|
||||
const openIncident = incidents.find((incident) => incident.resolvedAt === null);
|
||||
|
||||
if (monitorQuery.isPending) return <div className="full-page-loading"><RefreshCw className="loading-mark" /><p>Loading monitor history…</p></div>;
|
||||
if (monitorQuery.isError || !monitor) return (
|
||||
<div className="detail-error"><strong>Monitor not found</strong><p>The requested monitor could not be loaded.</p><button className="secondary-button" onClick={() => navigate("/")}>Return to dashboard</button></div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="dashboard-shell">
|
||||
<header className="dashboard-header"><div className="dashboard-header-inner">
|
||||
<button className="brand brand-button" type="button" onClick={() => navigate("/")}><Zap className="brand-mark" fill="currentColor" /><span>upwatch</span></button>
|
||||
<div className="nav-actions"><button className="nav-auth" onClick={() => navigate("/settings")}>Settings</button><button className="nav-auth" onClick={() => logoutMutation.mutate()} disabled={logoutMutation.isPending}>Sign out</button></div>
|
||||
</div></header>
|
||||
<main className="dashboard-main detail-main">
|
||||
<button className="back-link" type="button" onClick={() => navigate("/")}><ArrowLeft /> All monitors</button>
|
||||
<section className="detail-hero">
|
||||
<div className="detail-title"><span className={`status-orb ${status.className}`} /><div><p className="overline">Monitor #{monitor.id}</p><h1>{monitor.name}</h1><a href={monitor.url} target="_blank" rel="noreferrer">{monitor.url}<ExternalLink /></a></div></div>
|
||||
<div className="detail-actions"><span className={`row-status ${status.className}`}><i />{status.label}</span>{!monitor.alertsEnabled && <span className="muted-alert"><BellOff /> Alerts muted</span>}<button className="primary-button" type="button" onClick={() => checkMutation.mutate(id)} disabled={checkMutation.isPending}>{checkMutation.isPending ? "Checking…" : "Check now"}</button></div>
|
||||
</section>
|
||||
|
||||
<section className="sla-grid" aria-label="Uptime windows">
|
||||
{(["24h", "7d", "30d", "90d"] as const).map((key) => {
|
||||
const window = statsQuery.data?.windows[key];
|
||||
return <article className="sla-card" key={key}><p>{key} uptime</p><strong>{window?.uptimePct == null ? "—" : `${window.uptimePct.toFixed(3)}%`}</strong><span>{window?.totalChecks ?? 0} checks · {window?.avgLatencyMs ?? "—"} ms avg</span></article>;
|
||||
})}
|
||||
<article className={`sla-card incident-summary ${openIncident ? "has-incident" : ""}`}><p>Current incident</p><strong>{openIncident ? formatDuration(null, openIncident.startedAt) : "None"}</strong><span>{openIncident ? `Open since ${formatDate(openIncident.startedAt)}` : "Everything is operational"}</span></article>
|
||||
</section>
|
||||
|
||||
<div className="detail-grid">
|
||||
<section className="data-panel chart-panel"><div className="data-panel-heading"><div><p className="overline">Response time</p><h2>Latency</h2></div><span>Last {checks.length} checks</span></div><LatencySparkline checks={checks} /></section>
|
||||
<section className="data-panel uptime-panel"><div className="data-panel-heading"><div><p className="overline">Availability</p><h2>Recent uptime</h2></div><span>{checks.filter((check) => check.ok).length}/{checks.length} successful</span></div><UptimeBar checks={checks} /></section>
|
||||
</div>
|
||||
|
||||
<div className="detail-grid lower-grid">
|
||||
<section className="data-panel"><div className="data-panel-heading"><div><p className="overline">Event stream</p><h2>Recent checks</h2></div></div><div className="data-table-wrap"><table className="data-table"><thead><tr><th>Status</th><th>Response</th><th>Latency</th><th>Checked</th></tr></thead><tbody>{checks.slice(0, 20).map((check) => <tr key={check.id}><td><span className={`row-status ${check.ok ? "online" : "offline"}`}><i />{check.ok ? "Up" : "Down"}</span></td><td><code>{check.statusCode ? `HTTP ${check.statusCode}` : check.error ?? "Failed"}</code></td><td>{check.latencyMs} ms</td><td>{formatDate(check.checkedAt)}</td></tr>)}</tbody></table>{checks.length === 0 && <div className="table-empty">No checks recorded.</div>}</div></section>
|
||||
<section className="data-panel"><div className="data-panel-heading"><div><p className="overline">Downtime</p><h2>Incidents</h2></div><span>{incidents.length} recorded</span></div><div className="incident-list">{incidents.map((incident) => <article className={incident.resolvedAt ? "resolved" : "open"} key={incident.id}><span>{incident.resolvedAt ? <CheckCircle2 /> : <Clock3 />}</span><div><strong>{incident.resolvedAt ? "Resolved incident" : "Incident in progress"}</strong><p>{incident.startError ?? (incident.startStatusCode ? `HTTP ${incident.startStatusCode}` : "Endpoint became unavailable")}</p><small>{formatDate(incident.startedAt)} · {formatDuration(incident.durationMs, incident.startedAt)}</small></div></article>)}{incidents.length === 0 && <div className="table-empty">No downtime incidents recorded.</div>}</div></section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { ArrowLeft, BellRing, Send, Zap } from "lucide-react";
|
||||
import type { NotificationSettings } from "../api/settings";
|
||||
import { navigate } from "../lib/router";
|
||||
import { useLogoutMutation } from "../queries/auth";
|
||||
import { useNotificationSettingsQuery, useTestNotificationWebhookMutation, useUpdateNotificationSettingsMutation } from "../queries/settings";
|
||||
|
||||
function SettingsForm({ settings }: { settings: NotificationSettings }) {
|
||||
const [webhookUrl, setWebhookUrl] = useState(settings.webhookUrl ?? "");
|
||||
const [webhookEnabled, setWebhookEnabled] = useState(settings.webhookEnabled);
|
||||
const updateMutation = useUpdateNotificationSettingsMutation();
|
||||
const testMutation = useTestNotificationWebhookMutation();
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
updateMutation.mutate({ webhookUrl: webhookUrl.trim() || null, webhookEnabled });
|
||||
}
|
||||
|
||||
return <form className="settings-form" onSubmit={submit}>
|
||||
<label className="field"><span>Webhook URL</span><input type="url" value={webhookUrl} onChange={(event) => setWebhookUrl(event.target.value)} placeholder="https://hooks.example.com/services/…" /></label>
|
||||
<label className="settings-toggle"><input type="checkbox" checked={webhookEnabled} onChange={(event) => setWebhookEnabled(event.target.checked)} /><span><strong>Enable incident alerts</strong><small>Send a webhook when a monitor goes down and when it recovers.</small></span></label>
|
||||
<div className="settings-actions"><button className="secondary-button" type="button" onClick={() => testMutation.mutate()} disabled={!settings.webhookUrl || testMutation.isPending}><Send /> {testMutation.isPending ? "Sending…" : "Send test"}</button><button className="primary-button" type="submit" disabled={updateMutation.isPending}>{updateMutation.isPending ? "Saving…" : "Save settings"}</button></div>
|
||||
{updateMutation.isSuccess && <p className="settings-success">Notification settings saved.</p>}
|
||||
{testMutation.isSuccess && <p className="settings-success">Test webhook delivered successfully.</p>}
|
||||
{(updateMutation.isError || testMutation.isError) && <p className="form-error">{(updateMutation.error ?? testMutation.error)?.message ?? "Request failed"}</p>}
|
||||
</form>;
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const settingsQuery = useNotificationSettingsQuery();
|
||||
const logoutMutation = useLogoutMutation();
|
||||
return <div className="dashboard-shell">
|
||||
<header className="dashboard-header"><div className="dashboard-header-inner"><button className="brand brand-button" onClick={() => navigate("/")}><Zap className="brand-mark" fill="currentColor" /><span>upwatch</span></button><div className="nav-actions"><span className="header-context">Settings</span><button className="nav-auth" onClick={() => logoutMutation.mutate()}>Sign out</button></div></div></header>
|
||||
<main className="settings-main"><button className="back-link" type="button" onClick={() => navigate("/")}><ArrowLeft /> Dashboard</button><section className="settings-heading"><p className="overline">Integrations</p><h1>Notifications</h1><p>Route monitor transitions to Slack, Discord, or any service that accepts JSON webhooks.</p></section>
|
||||
<section className="settings-card"><div className="settings-card-intro"><span><BellRing /></span><div><h2>Incident webhook</h2><p>Upwatch sends a compact JSON payload for down and recovery events. Delivery failures never interrupt monitoring.</p></div></div>{settingsQuery.isPending ? <div className="table-empty">Loading settings…</div> : settingsQuery.isError ? <p className="form-error">Unable to load notification settings.</p> : <SettingsForm key={settingsQuery.data.settings.updatedAt ?? "new"} settings={settingsQuery.data.settings} />}</section>
|
||||
<section className="payload-preview"><p className="overline">Payload preview</p><pre>{`{
|
||||
"event": "down",
|
||||
"monitor": { "id": 12, "name": "API", "url": "https://api.example.com" },
|
||||
"statusCode": 500,
|
||||
"error": "Expected HTTP 200, received 500",
|
||||
"at": "2026-08-28T03:25:00.000Z"
|
||||
}`}</pre></section>
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import { queryOptions, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
createMonitor,
|
||||
deleteMonitor,
|
||||
getMonitor,
|
||||
getMonitorStats,
|
||||
listChecks,
|
||||
listIncidents,
|
||||
listMonitors,
|
||||
runMonitorCheck,
|
||||
updateMonitor,
|
||||
@@ -12,6 +16,10 @@ import { queryClient } from "../lib/query-client";
|
||||
export const monitorKeys = {
|
||||
all: ["monitors"] as const,
|
||||
list: () => [...monitorKeys.all, "list"] as const,
|
||||
detail: (id: number) => [...monitorKeys.all, "detail", id] as const,
|
||||
checks: (id: number) => [...monitorKeys.all, "checks", id] as const,
|
||||
stats: (id: number) => [...monitorKeys.all, "stats", id] as const,
|
||||
incidents: (id: number) => [...monitorKeys.all, "incidents", id] as const,
|
||||
};
|
||||
|
||||
export const monitorsQueryOptions = () =>
|
||||
@@ -26,6 +34,47 @@ export function useMonitorsQuery() {
|
||||
return useQuery(monitorsQueryOptions());
|
||||
}
|
||||
|
||||
const liveQueryDefaults = {
|
||||
refetchInterval: 60_000,
|
||||
refetchIntervalInBackground: false,
|
||||
} as const;
|
||||
|
||||
export function useMonitorQuery(id: number) {
|
||||
return useQuery({
|
||||
queryKey: monitorKeys.detail(id),
|
||||
queryFn: ({ signal }) => getMonitor(id, signal),
|
||||
enabled: Number.isSafeInteger(id) && id > 0,
|
||||
...liveQueryDefaults,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMonitorChecksQuery(id: number) {
|
||||
return useQuery({
|
||||
queryKey: monitorKeys.checks(id),
|
||||
queryFn: ({ signal }) => listChecks(id, 100, signal),
|
||||
enabled: Number.isSafeInteger(id) && id > 0,
|
||||
...liveQueryDefaults,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMonitorStatsQuery(id: number) {
|
||||
return useQuery({
|
||||
queryKey: monitorKeys.stats(id),
|
||||
queryFn: ({ signal }) => getMonitorStats(id, signal),
|
||||
enabled: Number.isSafeInteger(id) && id > 0,
|
||||
...liveQueryDefaults,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMonitorIncidentsQuery(id: number) {
|
||||
return useQuery({
|
||||
queryKey: monitorKeys.incidents(id),
|
||||
queryFn: ({ signal }) => listIncidents(id, 50, signal),
|
||||
enabled: Number.isSafeInteger(id) && id > 0,
|
||||
...liveQueryDefaults,
|
||||
});
|
||||
}
|
||||
|
||||
function invalidateMonitors() {
|
||||
return queryClient.invalidateQueries({ queryKey: monitorKeys.all });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getNotificationSettings,
|
||||
testNotificationWebhook,
|
||||
updateNotificationSettings,
|
||||
type NotificationSettingsInput,
|
||||
} from "../api/settings";
|
||||
import { queryClient } from "../lib/query-client";
|
||||
|
||||
export const settingsKeys = {
|
||||
all: ["settings"] as const,
|
||||
notifications: () => [...settingsKeys.all, "notifications"] as const,
|
||||
};
|
||||
|
||||
export function useNotificationSettingsQuery() {
|
||||
return useQuery({
|
||||
queryKey: settingsKeys.notifications(),
|
||||
queryFn: ({ signal }) => getNotificationSettings(signal),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateNotificationSettingsMutation() {
|
||||
return useMutation({
|
||||
mutationFn: (input: NotificationSettingsInput) => updateNotificationSettings(input),
|
||||
onSuccess: (data) => queryClient.setQueryData(settingsKeys.notifications(), data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestNotificationWebhookMutation() {
|
||||
return useMutation({ mutationFn: testNotificationWebhook });
|
||||
}
|
||||
@@ -33,6 +33,7 @@ button { color: inherit; }
|
||||
.dashboard-header { position: sticky; z-index: 20; top: 0; border-bottom: 1px solid #ededed; background: rgb(255 255 255 / 0.94); backdrop-filter: blur(14px); }
|
||||
.dashboard-header-inner { display: flex; align-items: center; justify-content: space-between; width: min(1280px, calc(100% - 48px)); height: 68px; margin: 0 auto; }
|
||||
.brand { display: inline-flex; align-items: center; gap: 8px; font-size: 20px; font-weight: 600; letter-spacing: -0.6px; }
|
||||
.brand-button { padding: 0; border: 0; background: transparent; cursor: pointer; }
|
||||
.brand-mark { width: 26px; height: 26px; color: var(--primary-deep); }
|
||||
.nav-actions { display: flex; align-items: center; gap: 20px; }
|
||||
.header-context { padding-right: 20px; border-right: 1px solid #e5e5e5; font-size: 13px; color: var(--muted); }
|
||||
@@ -82,6 +83,7 @@ button { color: inherit; }
|
||||
.toggle-field { display: flex; align-items: center; gap: 9px; align-self: end; min-height: 42px; font-size: 13px; color: #444; cursor: pointer; }
|
||||
.toggle-field input { width: 16px; height: 16px; accent-color: var(--primary-deep); }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 10px; align-self: end; grid-column: span 3; }
|
||||
.form-actions.compact-actions { grid-column: span 2; }
|
||||
.form-error { grid-column: 1 / -1; margin: 0; padding: 10px 12px; border: 1px solid #efcaca; border-radius: 6px; font-size: 13px; color: #9f2f2f; background: #fff6f6; }
|
||||
|
||||
.services-panel { margin-top: 24px; border: 1px solid #dedede; border-radius: 8px; overflow: hidden; background: #fff; }
|
||||
@@ -105,6 +107,8 @@ button { color: inherit; }
|
||||
.service-icon svg { width: 19px; height: 19px; }
|
||||
.service-name strong, .service-name small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.service-name strong { font-size: 15px; font-weight: 500; }
|
||||
.monitor-name-link { display: block; max-width: 100%; padding: 0; overflow: hidden; border: 0; font-size: 15px; font-weight: 500; text-align: left; text-overflow: ellipsis; white-space: nowrap; background: transparent; cursor: pointer; }
|
||||
.monitor-name-link:hover { color: #16885b; text-decoration: underline; text-underline-offset: 3px; }
|
||||
.service-name small { margin-top: 4px; font: 400 12px/1.4 "IBM Plex Mono", monospace; color: #888; }
|
||||
.monitor-meta { display: block; margin-top: 5px; font-size: 12px; color: #aaa; }
|
||||
.monitor-result { display: grid; justify-items: start; gap: 6px; min-width: 0; }
|
||||
@@ -159,6 +163,92 @@ button { color: inherit; }
|
||||
.auth-submit:disabled { cursor: wait; opacity: 0.62; }
|
||||
.auth-footnote { position: relative; z-index: 1; margin: 0; font-size: 11px; color: #858585; }
|
||||
|
||||
.detail-main { padding-top: 34px; }
|
||||
.back-link { display: inline-flex; align-items: center; gap: 7px; padding: 0; border: 0; font-size: 13px; color: var(--muted); background: transparent; cursor: pointer; }
|
||||
.back-link:hover { color: var(--ink); }
|
||||
.back-link svg { width: 15px; height: 15px; }
|
||||
.detail-hero { display: flex; align-items: flex-end; justify-content: space-between; gap: 32px; margin-top: 34px; padding-bottom: 36px; border-bottom: 1px solid #e7e7e7; }
|
||||
.detail-title { display: flex; align-items: center; gap: 18px; min-width: 0; }
|
||||
.status-orb { flex: 0 0 auto; width: 16px; height: 16px; border: 4px solid #f2d4d4; border-radius: 50%; background: #c74f4f; box-shadow: 0 0 0 8px #fff; }
|
||||
.status-orb.online { border-color: #c5f1df; background: var(--primary-deep); }
|
||||
.status-orb.checking { border-color: #eee5bd; background: #c6aa38; }
|
||||
.detail-title h1 { margin: 0; overflow: hidden; font-size: 40px; font-weight: 500; line-height: 1.1; letter-spacing: -1.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.detail-title a { display: inline-flex; align-items: center; gap: 6px; max-width: min(70vw, 680px); margin-top: 9px; overflow: hidden; font: 400 13px/1.4 "IBM Plex Mono", monospace; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.detail-title a:hover { color: var(--primary-deep); }
|
||||
.detail-title a svg { flex: 0 0 auto; width: 13px; height: 13px; }
|
||||
.detail-actions { display: flex; align-items: center; gap: 18px; }
|
||||
.muted-alert { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
|
||||
.muted-alert svg { width: 14px; height: 14px; }
|
||||
.sla-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-top: 24px; }
|
||||
.sla-card { min-width: 0; padding: 20px; border: 1px solid #e3e3e3; border-radius: 8px; background: #fff; }
|
||||
.sla-card p { margin: 0 0 17px; font-size: 12px; color: var(--muted); }
|
||||
.sla-card strong { display: block; font: 500 23px/1 "IBM Plex Mono", monospace; letter-spacing: -1px; }
|
||||
.sla-card span { display: block; margin-top: 11px; overflow: hidden; font-size: 11px; color: var(--faint); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sla-card.incident-summary { background: #fafafa; }
|
||||
.sla-card.incident-summary.has-incident { border-color: #e8c3c3; background: #fff8f8; }
|
||||
.sla-card.incident-summary.has-incident strong { color: #a83e3e; }
|
||||
.detail-grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(300px, 0.75fr); gap: 16px; margin-top: 16px; }
|
||||
.detail-grid.lower-grid { grid-template-columns: minmax(0, 1.4fr) minmax(340px, 0.8fr); align-items: start; }
|
||||
.data-panel { overflow: hidden; border: 1px solid #dedede; border-radius: 8px; background: #fff; }
|
||||
.data-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 82px; padding: 18px 22px; border-bottom: 1px solid #ededed; }
|
||||
.data-panel-heading .overline { margin-bottom: 5px; }
|
||||
.data-panel-heading h2 { margin: 0; font-size: 18px; font-weight: 500; }
|
||||
.data-panel-heading > span { font: 400 11px/1.4 "IBM Plex Mono", monospace; color: var(--faint); }
|
||||
.chart-panel { min-height: 330px; }
|
||||
.sparkline-wrap { position: relative; height: 245px; padding: 26px 24px 24px; background: linear-gradient(180deg, #fdfefe, #fff); }
|
||||
.latency-sparkline { width: 100%; height: 100%; overflow: visible; }
|
||||
.chart-grid-line { fill: none; stroke: #eeeeee; stroke-width: 1; stroke-dasharray: 3 5; }
|
||||
.chart-scale { position: absolute; inset: 19px 22px 20px auto; display: flex; flex-direction: column; justify-content: space-between; pointer-events: none; font: 400 9px/1 "IBM Plex Mono", monospace; color: #aaa; }
|
||||
.chart-empty, .table-empty { display: grid; min-height: 150px; place-items: center; padding: 24px; font-size: 13px; color: var(--muted); text-align: center; }
|
||||
.uptime-panel { min-height: 330px; }
|
||||
.uptime-bar { display: flex; align-items: stretch; gap: 2px; height: 132px; padding: 28px 22px 14px; }
|
||||
.uptime-bar span { flex: 1; min-width: 2px; border-radius: 2px; background: #3ecf8e; transition: transform 120ms ease, opacity 120ms ease; }
|
||||
.uptime-bar span.is-down { background: #d95c5c; }
|
||||
.uptime-bar span:hover { z-index: 1; opacity: .75; transform: scaleY(1.06); }
|
||||
.uptime-legend { display: flex; justify-content: space-between; padding: 14px 22px 0; border-top: 1px solid #f0f0f0; font-size: 10px; color: #999; }
|
||||
.uptime-legend span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.uptime-legend i { width: 6px; height: 6px; border-radius: 50%; background: var(--primary-deep); }
|
||||
.uptime-legend i.legend-down { margin-left: 5px; background: #d95c5c; }
|
||||
.data-table-wrap { overflow-x: auto; }
|
||||
.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 { max-height: 556px; overflow: auto; }
|
||||
.incident-list article { display: flex; gap: 13px; padding: 18px 20px; }
|
||||
.incident-list article + article { border-top: 1px solid #ededed; }
|
||||
.incident-list article > span { display: grid; flex: 0 0 auto; width: 30px; height: 30px; place-items: center; border-radius: 50%; color: #16885b; background: #eaf9f2; }
|
||||
.incident-list article.open > span { color: #aa4141; background: #fff0f0; }
|
||||
.incident-list article svg { width: 15px; height: 15px; }
|
||||
.incident-list strong { font-size: 13px; font-weight: 500; }
|
||||
.incident-list p { margin: 4px 0 6px; font-size: 12px; line-height: 1.45; color: var(--muted); }
|
||||
.incident-list small { font: 400 10px/1.4 "IBM Plex Mono", monospace; color: #aaa; }
|
||||
.detail-error { display: grid; min-height: 100dvh; place-content: center; justify-items: center; padding: 24px; text-align: center; }
|
||||
.detail-error p { color: var(--muted); }
|
||||
|
||||
.settings-main { width: min(760px, calc(100% - 48px)); margin: 0 auto; padding: 34px 0 80px; }
|
||||
.settings-heading { margin-top: 42px; }
|
||||
.settings-heading h1 { margin: 0; font-size: 40px; font-weight: 500; letter-spacing: -1.5px; }
|
||||
.settings-heading > p:last-child { margin: 12px 0 0; color: var(--muted); }
|
||||
.settings-card { margin-top: 38px; overflow: hidden; border: 1px solid #dedede; border-radius: 10px; background: #fff; box-shadow: 0 8px 24px rgb(0 0 0 / .035); }
|
||||
.settings-card-intro { display: flex; gap: 16px; padding: 26px; border-bottom: 1px solid #ededed; }
|
||||
.settings-card-intro > span { display: grid; flex: 0 0 auto; width: 42px; height: 42px; place-items: center; border: 1px solid #bcead6; border-radius: 8px; color: #16885b; background: #f0fbf6; }
|
||||
.settings-card-intro svg { width: 19px; height: 19px; }
|
||||
.settings-card-intro h2 { margin: 0; font-size: 18px; font-weight: 500; }
|
||||
.settings-card-intro p { margin: 6px 0 0; font-size: 13px; line-height: 1.55; color: var(--muted); }
|
||||
.settings-form { display: grid; gap: 24px; padding: 26px; }
|
||||
.settings-toggle { display: flex; align-items: flex-start; gap: 11px; cursor: pointer; }
|
||||
.settings-toggle input { width: 17px; height: 17px; margin-top: 2px; accent-color: var(--primary-deep); }
|
||||
.settings-toggle strong, .settings-toggle small { display: block; }
|
||||
.settings-toggle strong { font-size: 13px; font-weight: 500; }
|
||||
.settings-toggle small { margin-top: 4px; color: var(--muted); }
|
||||
.settings-actions { display: flex; justify-content: flex-end; gap: 10px; padding-top: 20px; border-top: 1px solid #ededed; }
|
||||
.settings-actions svg { width: 14px; height: 14px; }
|
||||
.settings-success { margin: -8px 0 0; padding: 10px 12px; border: 1px solid #bde9d5; border-radius: 6px; font-size: 12px; color: #16754f; background: #f2fbf7; }
|
||||
.payload-preview { margin-top: 18px; padding: 24px 26px; border-radius: 9px; color: #fff; background: var(--night); }
|
||||
.payload-preview .overline { color: #83dcb4; }
|
||||
.payload-preview pre { margin: 16px 0 0; overflow-x: auto; font: 400 12px/1.7 "IBM Plex Mono", monospace; color: #d7d7d7; }
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes blink { 50% { opacity: 0.35; } }
|
||||
@keyframes loading-pulse { 50% { opacity: 0.4; transform: scale(0.94); } }
|
||||
@@ -169,9 +259,12 @@ button { color: inherit; }
|
||||
.monitor-form { grid-template-columns: repeat(2, 1fr); }
|
||||
.field-name, .field-url { grid-column: span 1; }
|
||||
.form-actions { grid-column: span 1; }
|
||||
.form-actions.compact-actions { grid-column: span 2; }
|
||||
.services-title { display: none; }
|
||||
.service-row { grid-template-columns: minmax(280px, 1fr) minmax(200px, 0.7fr); }
|
||||
.row-actions { grid-column: 1 / -1; justify-content: flex-start; margin-left: 54px; }
|
||||
.sla-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
.detail-grid, .detail-grid.lower-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
@@ -188,10 +281,17 @@ button { color: inherit; }
|
||||
.monitor-form { grid-template-columns: 1fr; }
|
||||
.field-name, .field-url, .form-actions { grid-column: auto; }
|
||||
.form-actions { justify-content: stretch; }
|
||||
.form-actions.compact-actions { grid-column: auto; }
|
||||
.form-actions button { flex: 1; }
|
||||
.service-row { grid-template-columns: 1fr; gap: 18px; }
|
||||
.row-actions { grid-column: auto; margin-left: 54px; }
|
||||
.dashboard-page-footer { flex-direction: column; gap: 8px; }
|
||||
.detail-hero { align-items: flex-start; flex-direction: column; }
|
||||
.detail-actions { flex-wrap: wrap; }
|
||||
.detail-title h1 { font-size: 34px; }
|
||||
.sla-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.sla-card.incident-summary { grid-column: 1 / -1; }
|
||||
.settings-main { width: min(100% - 32px, 760px); }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
@@ -205,6 +305,15 @@ button { color: inherit; }
|
||||
.service-row { padding: 18px 16px; }
|
||||
.row-actions { margin-left: 0; }
|
||||
.row-actions button { flex: 1; }
|
||||
.nav-actions { gap: 12px; }
|
||||
.detail-title { align-items: flex-start; }
|
||||
.status-orb { margin-top: 11px; }
|
||||
.sla-grid { grid-template-columns: 1fr; }
|
||||
.sla-card.incident-summary { grid-column: auto; }
|
||||
.data-panel-heading { padding: 16px; }
|
||||
.settings-heading h1 { font-size: 34px; }
|
||||
.settings-card-intro, .settings-form { padding: 20px; }
|
||||
.settings-actions { align-items: stretch; flex-direction: column-reverse; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import type { Database } from "../db/client";
|
||||
import { checks, incidents, monitors } from "../db/schema";
|
||||
import type { CheckResult, Monitor } from "./run-check";
|
||||
|
||||
export type IncidentTransition = "opened" | "resolved" | null;
|
||||
|
||||
type BatchStatement = Parameters<Database["batch"]>[0][number];
|
||||
|
||||
export function buildResultStatements(
|
||||
db: Database,
|
||||
monitor: Monitor,
|
||||
result: CheckResult,
|
||||
checkedAt: Date,
|
||||
) {
|
||||
const statements: BatchStatement[] = [
|
||||
db.insert(checks).values({
|
||||
monitorId: monitor.id,
|
||||
ok: result.ok,
|
||||
statusCode: result.statusCode,
|
||||
latencyMs: result.latencyMs,
|
||||
error: result.error,
|
||||
checkedAt,
|
||||
}),
|
||||
db
|
||||
.update(monitors)
|
||||
.set({
|
||||
lastOk: result.ok,
|
||||
lastStatusCode: result.statusCode,
|
||||
lastLatencyMs: result.latencyMs,
|
||||
lastError: result.error,
|
||||
lastCheckedAt: checkedAt,
|
||||
updatedAt: checkedAt,
|
||||
})
|
||||
.where(eq(monitors.id, monitor.id)),
|
||||
];
|
||||
|
||||
let transition: IncidentTransition = null;
|
||||
if (monitor.lastOk !== false && !result.ok) {
|
||||
statements.push(
|
||||
db.insert(incidents).values({
|
||||
monitorId: monitor.id,
|
||||
startedAt: checkedAt,
|
||||
startStatusCode: result.statusCode,
|
||||
startError: result.error,
|
||||
createdAt: checkedAt,
|
||||
updatedAt: checkedAt,
|
||||
}),
|
||||
);
|
||||
transition = "opened";
|
||||
} else if (monitor.lastOk === false && result.ok) {
|
||||
statements.push(
|
||||
db
|
||||
.update(incidents)
|
||||
.set({
|
||||
resolvedAt: checkedAt,
|
||||
durationMs: sql`${checkedAt.getTime()} - ${incidents.startedAt}`,
|
||||
updatedAt: checkedAt,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(incidents.monitorId, monitor.id),
|
||||
isNull(incidents.resolvedAt),
|
||||
),
|
||||
),
|
||||
);
|
||||
transition = "resolved";
|
||||
}
|
||||
|
||||
return { statements, transition };
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { getDb } from "../db/client";
|
||||
import { checks, monitors } from "../db/schema";
|
||||
import { monitors } from "../db/schema";
|
||||
import { sendIncidentAlert } from "../notifications/webhook";
|
||||
import { buildResultStatements } from "./persist-result";
|
||||
import { runCheck } from "./run-check";
|
||||
|
||||
const MAX_MONITORS_PER_RUN = 40;
|
||||
@@ -12,7 +14,10 @@ export type DueCheckSummary = {
|
||||
down: number;
|
||||
};
|
||||
|
||||
export async function runDueChecks(env: Env): Promise<DueCheckSummary> {
|
||||
export async function runDueChecks(
|
||||
env: Env,
|
||||
ctx?: Pick<ExecutionContext, "waitUntil">,
|
||||
): Promise<DueCheckSummary> {
|
||||
const db = getDb(env);
|
||||
const now = Date.now();
|
||||
const due = await db
|
||||
@@ -47,27 +52,30 @@ export async function runDueChecks(env: Env): Promise<DueCheckSummary> {
|
||||
completed.push(...results);
|
||||
}
|
||||
|
||||
const statements = completed.flatMap(({ monitor, result, checkedAt }) => [
|
||||
db.insert(checks).values({
|
||||
monitorId: monitor.id,
|
||||
ok: result.ok,
|
||||
statusCode: result.statusCode,
|
||||
latencyMs: result.latencyMs,
|
||||
error: result.error,
|
||||
const persisted = completed.map(({ monitor, result, checkedAt }) => ({
|
||||
monitor,
|
||||
result,
|
||||
checkedAt,
|
||||
}),
|
||||
db.update(monitors).set({
|
||||
lastOk: result.ok,
|
||||
lastStatusCode: result.statusCode,
|
||||
lastLatencyMs: result.latencyMs,
|
||||
lastError: result.error,
|
||||
lastCheckedAt: checkedAt,
|
||||
updatedAt: checkedAt,
|
||||
}).where(eq(monitors.id, monitor.id)),
|
||||
]);
|
||||
...buildResultStatements(db, monitor, result, checkedAt),
|
||||
}));
|
||||
const statements = persisted.flatMap((item) => item.statements);
|
||||
|
||||
await db.batch(statements as [typeof statements[number], ...typeof statements]);
|
||||
|
||||
const notifications = persisted.flatMap((item) => item.transition === null ? [] : [
|
||||
sendIncidentAlert(env, {
|
||||
monitor: item.monitor,
|
||||
kind: item.transition,
|
||||
result: item.result,
|
||||
at: item.checkedAt,
|
||||
}),
|
||||
]);
|
||||
if (notifications.length > 0) {
|
||||
const notificationWork = Promise.all(notifications).then(() => undefined);
|
||||
if (ctx) ctx.waitUntil(notificationWork);
|
||||
else await notificationWork;
|
||||
}
|
||||
|
||||
const up = completed.reduce((count, item) => count + Number(item.result.ok), 0);
|
||||
return { checked: completed.length, up, down: completed.length - up };
|
||||
}
|
||||
|
||||
+49
-1
@@ -1,4 +1,4 @@
|
||||
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const adminCredentials = sqliteTable("admin_credentials", {
|
||||
id: integer("id").primaryKey(),
|
||||
@@ -7,6 +7,14 @@ export const adminCredentials = sqliteTable("admin_credentials", {
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
||||
});
|
||||
|
||||
export const notificationSettings = sqliteTable("notification_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
webhookUrl: text("webhook_url"),
|
||||
webhookEnabled: integer("webhook_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
||||
});
|
||||
|
||||
export const sessions = sqliteTable(
|
||||
"sessions",
|
||||
{
|
||||
@@ -39,6 +47,7 @@ export const monitors = sqliteTable(
|
||||
intervalSeconds: integer("interval_seconds").notNull().default(300),
|
||||
timeoutMs: integer("timeout_ms").notNull().default(10_000),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
alertsEnabled: integer("alerts_enabled", { mode: "boolean" }).notNull().default(true),
|
||||
lastOk: integer("last_ok", { mode: "boolean" }),
|
||||
lastStatusCode: integer("last_status_code"),
|
||||
lastLatencyMs: integer("last_latency_ms"),
|
||||
@@ -75,3 +84,42 @@ export const checks = sqliteTable(
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const incidents = sqliteTable(
|
||||
"incidents",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
monitorId: integer("monitor_id")
|
||||
.notNull()
|
||||
.references(() => monitors.id, { onDelete: "cascade" }),
|
||||
startedAt: integer("started_at", { mode: "timestamp_ms" }).notNull(),
|
||||
resolvedAt: integer("resolved_at", { mode: "timestamp_ms" }),
|
||||
startStatusCode: integer("start_status_code"),
|
||||
startError: text("start_error"),
|
||||
durationMs: integer("duration_ms"),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("incidents_monitor_id_started_at_idx").on(table.monitorId, table.startedAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const monitorDailyStats = sqliteTable(
|
||||
"monitor_daily_stats",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
monitorId: integer("monitor_id")
|
||||
.notNull()
|
||||
.references(() => monitors.id, { onDelete: "cascade" }),
|
||||
day: integer("day", { mode: "timestamp_ms" }).notNull(),
|
||||
totalChecks: integer("total_checks").notNull(),
|
||||
upChecks: integer("up_checks").notNull(),
|
||||
avgLatencyMs: integer("avg_latency_ms"),
|
||||
minLatencyMs: integer("min_latency_ms"),
|
||||
maxLatencyMs: integer("max_latency_ms"),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("monitor_daily_stats_monitor_id_day_uidx").on(table.monitorId, table.day),
|
||||
],
|
||||
);
|
||||
|
||||
+16
-2
@@ -3,7 +3,9 @@ import { csrf } from "hono/csrf";
|
||||
import { runDueChecks } from "./checks/run-due-checks";
|
||||
import authRoutes from "./routes/auth";
|
||||
import monitorRoutes from "./routes/monitors";
|
||||
import settingsRoutes from "./routes/settings";
|
||||
import { cleanupExpiredAuthRecords } from "./scheduled/cleanup";
|
||||
import { runDailyRollup } from "./scheduled/rollup";
|
||||
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
@@ -23,12 +25,24 @@ app.get("/api/health", async (context) => {
|
||||
|
||||
app.route("/", authRoutes);
|
||||
app.route("/api/monitors", monitorRoutes);
|
||||
app.route("/api/settings", settingsRoutes);
|
||||
|
||||
export default {
|
||||
fetch: app.fetch,
|
||||
async scheduled(controller, env) {
|
||||
async scheduled(controller, env, ctx) {
|
||||
if (controller.cron === "5 0 * * *") {
|
||||
const result = await runDailyRollup(env, new Date(controller.scheduledTime));
|
||||
console.log(JSON.stringify({
|
||||
message: "daily rollup completed",
|
||||
cron: controller.cron,
|
||||
scheduledTime: controller.scheduledTime,
|
||||
...result,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
await cleanupExpiredAuthRecords(env);
|
||||
const result = await runDueChecks(env);
|
||||
const result = await runDueChecks(env, ctx);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
message: "scheduled run completed",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { CheckResult, Monitor } from "../checks/run-check";
|
||||
import { getDb } from "../db/client";
|
||||
import { notificationSettings } from "../db/schema";
|
||||
|
||||
export type IncidentAlert = {
|
||||
monitor: Monitor;
|
||||
kind: "opened" | "resolved";
|
||||
result: CheckResult;
|
||||
at: Date;
|
||||
};
|
||||
|
||||
type WebhookPayload = {
|
||||
event: "down" | "recovered" | "test";
|
||||
monitor: { id: number; name: string; url: string };
|
||||
statusCode: number | null;
|
||||
error: string | null;
|
||||
at: string;
|
||||
};
|
||||
|
||||
async function postWebhook(url: string, payload: WebhookPayload): Promise<boolean> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Upwatch/1.0 (+incident webhook)",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
await response.body?.cancel();
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
export async function sendTestWebhook(url: string): Promise<boolean> {
|
||||
try {
|
||||
return await postWebhook(url, {
|
||||
event: "test",
|
||||
monitor: { id: 0, name: "Upwatch test", url: "https://example.com/health" },
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
message: "test webhook failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendIncidentAlert(
|
||||
env: Env,
|
||||
alert: IncidentAlert,
|
||||
): Promise<boolean> {
|
||||
if (!alert.monitor.alertsEnabled) return false;
|
||||
|
||||
try {
|
||||
const [settings] = await getDb(env)
|
||||
.select()
|
||||
.from(notificationSettings)
|
||||
.where(eq(notificationSettings.id, 1))
|
||||
.limit(1);
|
||||
if (!settings?.webhookEnabled || !settings.webhookUrl) return false;
|
||||
|
||||
const ok = await postWebhook(settings.webhookUrl, {
|
||||
event: alert.kind === "opened" ? "down" : "recovered",
|
||||
monitor: {
|
||||
id: alert.monitor.id,
|
||||
name: alert.monitor.name,
|
||||
url: alert.monitor.url,
|
||||
},
|
||||
statusCode: alert.result.statusCode,
|
||||
error: alert.result.error,
|
||||
at: alert.at.toISOString(),
|
||||
});
|
||||
if (!ok) {
|
||||
console.warn(JSON.stringify({
|
||||
message: "incident webhook returned an error",
|
||||
monitorId: alert.monitor.id,
|
||||
}));
|
||||
}
|
||||
return ok;
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
message: "incident webhook failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
monitorId: alert.monitor.id,
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+129
-21
@@ -1,9 +1,11 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, desc, eq, gte, isNull, lt, or, sql } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
import { buildResultStatements } from "../checks/persist-result";
|
||||
import { runCheck } from "../checks/run-check";
|
||||
import { getDb } from "../db/client";
|
||||
import { checks, monitors } from "../db/schema";
|
||||
import { checks, incidents, monitorDailyStats, monitors } from "../db/schema";
|
||||
import { requireAuth, type AuthVariables } from "../lib/require-auth";
|
||||
import { sendIncidentAlert } from "../notifications/webhook";
|
||||
|
||||
type MonitorMethod = "GET" | "HEAD" | "POST";
|
||||
|
||||
@@ -15,6 +17,7 @@ type ParsedMonitorInput = {
|
||||
intervalSeconds?: number;
|
||||
timeoutMs?: number;
|
||||
enabled?: boolean;
|
||||
alertsEnabled?: boolean;
|
||||
};
|
||||
|
||||
type ParseResult =
|
||||
@@ -91,6 +94,12 @@ export function parseMonitorInput(body: unknown, partial = false): ParseResult {
|
||||
}
|
||||
value.enabled = body.enabled;
|
||||
}
|
||||
if ("alertsEnabled" in body) {
|
||||
if (typeof body.alertsEnabled !== "boolean") {
|
||||
return { ok: false, message: "alertsEnabled must be a boolean" };
|
||||
}
|
||||
value.alertsEnabled = body.alertsEnabled;
|
||||
}
|
||||
|
||||
return { ok: true, value };
|
||||
}
|
||||
@@ -100,6 +109,33 @@ function parseId(rawId: string) {
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
function parseLimit(raw: string | undefined, fallback: number, maximum: number) {
|
||||
if (!raw) return fallback;
|
||||
const value = Number(raw);
|
||||
return Number.isSafeInteger(value) && value > 0 ? Math.min(value, maximum) : fallback;
|
||||
}
|
||||
|
||||
type StatsWindow = {
|
||||
uptimePct: number | null;
|
||||
totalChecks: number;
|
||||
upChecks: number;
|
||||
avgLatencyMs: number | null;
|
||||
incidentCount: number;
|
||||
};
|
||||
|
||||
function asStatsWindow(
|
||||
row: { totalChecks: number; upChecks: number; avgLatencyMs: number | null },
|
||||
incidentCount: number,
|
||||
): StatsWindow {
|
||||
return {
|
||||
uptimePct: row.totalChecks > 0 ? Math.round((row.upChecks / row.totalChecks) * 100_000) / 1_000 : null,
|
||||
totalChecks: row.totalChecks,
|
||||
upChecks: row.upChecks,
|
||||
avgLatencyMs: row.avgLatencyMs,
|
||||
incidentCount,
|
||||
};
|
||||
}
|
||||
|
||||
const monitorRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>();
|
||||
|
||||
monitorRoutes.use("*", requireAuth);
|
||||
@@ -109,6 +145,89 @@ monitorRoutes.get("/", async (context) => {
|
||||
return context.json({ monitors: rows });
|
||||
});
|
||||
|
||||
monitorRoutes.get("/:id", async (context) => {
|
||||
const id = parseId(context.req.param("id"));
|
||||
if (id === null) return context.json({ message: "Monitor not found" }, 404);
|
||||
const [monitor] = await getDb(context.env).select().from(monitors).where(eq(monitors.id, id)).limit(1);
|
||||
if (!monitor) return context.json({ message: "Monitor not found" }, 404);
|
||||
return context.json({ monitor });
|
||||
});
|
||||
|
||||
monitorRoutes.get("/:id/checks", async (context) => {
|
||||
const id = parseId(context.req.param("id"));
|
||||
if (id === null) return context.json({ message: "Monitor not found" }, 404);
|
||||
const limit = parseLimit(context.req.query("limit"), 100, 500);
|
||||
const rows = await getDb(context.env)
|
||||
.select()
|
||||
.from(checks)
|
||||
.where(eq(checks.monitorId, id))
|
||||
.orderBy(desc(checks.checkedAt))
|
||||
.limit(limit);
|
||||
return context.json({ checks: rows });
|
||||
});
|
||||
|
||||
monitorRoutes.get("/:id/incidents", async (context) => {
|
||||
const id = parseId(context.req.param("id"));
|
||||
if (id === null) return context.json({ message: "Monitor not found" }, 404);
|
||||
const limit = parseLimit(context.req.query("limit"), 50, 200);
|
||||
const rows = await getDb(context.env)
|
||||
.select()
|
||||
.from(incidents)
|
||||
.where(eq(incidents.monitorId, id))
|
||||
.orderBy(desc(incidents.startedAt))
|
||||
.limit(limit);
|
||||
return context.json({ incidents: rows });
|
||||
});
|
||||
|
||||
monitorRoutes.get("/:id/stats", async (context) => {
|
||||
const id = parseId(context.req.param("id"));
|
||||
if (id === null) return context.json({ message: "Monitor not found" }, 404);
|
||||
const db = getDb(context.env);
|
||||
const [monitor] = await db.select({ id: monitors.id }).from(monitors).where(eq(monitors.id, id)).limit(1);
|
||||
if (!monitor) return context.json({ message: "Monitor not found" }, 404);
|
||||
|
||||
const now = Date.now();
|
||||
const currentDayMs = Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), new Date(now).getUTCDate());
|
||||
const windows = [
|
||||
{ key: "24h", start: now - 24 * 60 * 60 * 1000, raw: true },
|
||||
{ key: "7d", start: now - 7 * 24 * 60 * 60 * 1000, raw: true },
|
||||
{ key: "30d", start: currentDayMs - 29 * 24 * 60 * 60 * 1000, raw: false },
|
||||
{ key: "90d", start: currentDayMs - 89 * 24 * 60 * 60 * 1000, raw: false },
|
||||
] as const;
|
||||
|
||||
const results = await Promise.all(windows.map(async (window) => {
|
||||
const [aggregate] = window.raw
|
||||
? await db.select({
|
||||
totalChecks: sql<number>`count(*)`,
|
||||
upChecks: sql<number>`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
|
||||
avgLatencyMs: sql<number | null>`round(avg(${checks.latencyMs}))`,
|
||||
}).from(checks).where(and(eq(checks.monitorId, id), gte(checks.checkedAt, new Date(window.start))))
|
||||
: await db.select({
|
||||
totalChecks: sql<number>`coalesce(sum(total_checks), 0)`,
|
||||
upChecks: sql<number>`coalesce(sum(up_checks), 0)`,
|
||||
avgLatencyMs: sql<number | null>`round(sum(avg_latency_ms * total_checks) / nullif(sum(total_checks), 0))`,
|
||||
}).from(sql`(
|
||||
select total_checks, up_checks, avg_latency_ms
|
||||
from monitor_daily_stats
|
||||
where monitor_id = ${id} and day >= ${window.start} and day < ${currentDayMs}
|
||||
union all
|
||||
select count(*), coalesce(sum(case when ok = 1 then 1 else 0 end), 0), round(avg(latency_ms))
|
||||
from checks
|
||||
where monitor_id = ${id} and checked_at >= ${currentDayMs}
|
||||
)`);
|
||||
const [incidentAggregate] = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(incidents)
|
||||
.where(and(
|
||||
eq(incidents.monitorId, id),
|
||||
gte(incidents.startedAt, new Date(window.start)),
|
||||
or(isNull(incidents.resolvedAt), gte(incidents.resolvedAt, new Date(window.start))),
|
||||
));
|
||||
return [window.key, asStatsWindow(aggregate, incidentAggregate.count)] as const;
|
||||
}));
|
||||
|
||||
return context.json({ windows: Object.fromEntries(results) as Record<(typeof windows)[number]["key"], StatsWindow> });
|
||||
});
|
||||
|
||||
monitorRoutes.post("/", async (context) => {
|
||||
let body: unknown;
|
||||
try {
|
||||
@@ -131,6 +250,7 @@ monitorRoutes.post("/", async (context) => {
|
||||
intervalSeconds: parsed.value.intervalSeconds!,
|
||||
timeoutMs: parsed.value.timeoutMs!,
|
||||
enabled: parsed.value.enabled ?? true,
|
||||
alertsEnabled: parsed.value.alertsEnabled ?? true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
@@ -191,26 +311,14 @@ monitorRoutes.post("/:id/check", async (context) => {
|
||||
|
||||
const result = await runCheck(monitor);
|
||||
const checkedAt = new Date();
|
||||
const [, updated] = await db.batch([
|
||||
db.insert(checks).values({
|
||||
monitorId: monitor.id,
|
||||
ok: result.ok,
|
||||
statusCode: result.statusCode,
|
||||
latencyMs: result.latencyMs,
|
||||
error: result.error,
|
||||
checkedAt,
|
||||
}),
|
||||
db.update(monitors).set({
|
||||
lastOk: result.ok,
|
||||
lastStatusCode: result.statusCode,
|
||||
lastLatencyMs: result.latencyMs,
|
||||
lastError: result.error,
|
||||
lastCheckedAt: checkedAt,
|
||||
updatedAt: checkedAt,
|
||||
}).where(eq(monitors.id, monitor.id)).returning(),
|
||||
]);
|
||||
const { statements, transition } = buildResultStatements(db, monitor, result, checkedAt);
|
||||
await db.batch(statements as [typeof statements[number], ...typeof statements]);
|
||||
if (transition) {
|
||||
await sendIncidentAlert(context.env, { monitor, kind: transition, result, at: checkedAt });
|
||||
}
|
||||
const [updated] = await db.select().from(monitors).where(eq(monitors.id, monitor.id)).limit(1);
|
||||
|
||||
return context.json({ result, monitor: updated[0] });
|
||||
return context.json({ result, monitor: updated });
|
||||
});
|
||||
|
||||
export default monitorRoutes;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
import { getDb } from "../db/client";
|
||||
import { notificationSettings } from "../db/schema";
|
||||
import { requireAuth, type AuthVariables } from "../lib/require-auth";
|
||||
import { sendTestWebhook } from "../notifications/webhook";
|
||||
|
||||
type NotificationInput = {
|
||||
webhookUrl: string | null;
|
||||
webhookEnabled: boolean;
|
||||
};
|
||||
|
||||
function parseNotificationInput(value: unknown): NotificationInput | string {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return "Invalid request body";
|
||||
}
|
||||
const body = value as Record<string, unknown>;
|
||||
if (typeof body.webhookEnabled !== "boolean") {
|
||||
return "webhookEnabled must be a boolean";
|
||||
}
|
||||
const rawUrl = typeof body.webhookUrl === "string" ? body.webhookUrl.trim() : body.webhookUrl;
|
||||
if (rawUrl !== null && typeof rawUrl !== "string") return "webhookUrl must be a URL or null";
|
||||
let webhookUrl = rawUrl || null;
|
||||
if (webhookUrl) {
|
||||
try {
|
||||
const url = new URL(webhookUrl);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("protocol");
|
||||
webhookUrl = url.toString();
|
||||
} catch {
|
||||
return "Enter a valid http or https webhook URL";
|
||||
}
|
||||
}
|
||||
if (body.webhookEnabled && !webhookUrl) return "A webhook URL is required when alerts are enabled";
|
||||
return { webhookUrl, webhookEnabled: body.webhookEnabled };
|
||||
}
|
||||
|
||||
const settingsRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>();
|
||||
settingsRoutes.use("*", requireAuth);
|
||||
|
||||
settingsRoutes.get("/notifications", async (context) => {
|
||||
const [settings] = await getDb(context.env)
|
||||
.select()
|
||||
.from(notificationSettings)
|
||||
.where(eq(notificationSettings.id, 1))
|
||||
.limit(1);
|
||||
return context.json({
|
||||
settings: settings ?? { id: 1, webhookUrl: null, webhookEnabled: false, createdAt: null, updatedAt: null },
|
||||
});
|
||||
});
|
||||
|
||||
settingsRoutes.put("/notifications", async (context) => {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await context.req.json();
|
||||
} catch {
|
||||
return context.json({ message: "Invalid request body" }, 400);
|
||||
}
|
||||
const input = parseNotificationInput(body);
|
||||
if (typeof input === "string") return context.json({ message: input }, 400);
|
||||
|
||||
const db = getDb(context.env);
|
||||
const now = new Date();
|
||||
const [settings] = await db
|
||||
.insert(notificationSettings)
|
||||
.values({ id: 1, ...input, createdAt: now, updatedAt: now })
|
||||
.onConflictDoUpdate({
|
||||
target: notificationSettings.id,
|
||||
set: { ...input, updatedAt: now },
|
||||
})
|
||||
.returning();
|
||||
return context.json({ settings });
|
||||
});
|
||||
|
||||
settingsRoutes.post("/notifications/test", async (context) => {
|
||||
const [settings] = await getDb(context.env)
|
||||
.select()
|
||||
.from(notificationSettings)
|
||||
.where(eq(notificationSettings.id, 1))
|
||||
.limit(1);
|
||||
if (!settings?.webhookUrl) return context.json({ message: "Save a webhook URL first" }, 400);
|
||||
const delivered = await sendTestWebhook(settings.webhookUrl);
|
||||
if (!delivered) return context.json({ message: "Webhook delivery failed" }, 502);
|
||||
return context.json({ ok: true });
|
||||
});
|
||||
|
||||
export default settingsRoutes;
|
||||
@@ -1,9 +1,10 @@
|
||||
import { lt } from "drizzle-orm";
|
||||
import { getDb } from "../db/client";
|
||||
import { checks, loginAttempts, sessions } from "../db/schema";
|
||||
import { checks, loginAttempts, monitorDailyStats, sessions } from "../db/schema";
|
||||
|
||||
const LOGIN_ATTEMPT_RETENTION_MS = 60 * 60 * 1000;
|
||||
const CHECK_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const DAILY_STATS_RETENTION_MS = 400 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export async function cleanupExpiredAuthRecords(env: Env) {
|
||||
const db = getDb(env);
|
||||
@@ -27,5 +28,13 @@ export async function cleanupExpiredAuthRecords(env: Env) {
|
||||
new Date(now.getTime() - CHECK_RETENTION_MS),
|
||||
),
|
||||
),
|
||||
db
|
||||
.delete(monitorDailyStats)
|
||||
.where(
|
||||
lt(
|
||||
monitorDailyStats.day,
|
||||
new Date(now.getTime() - DAILY_STATS_RETENTION_MS),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { and, gte, lt, sql } from "drizzle-orm";
|
||||
import { getDb } from "../db/client";
|
||||
import { checks, monitorDailyStats } from "../db/schema";
|
||||
|
||||
export type DailyRollupSummary = {
|
||||
day: string;
|
||||
monitors: number;
|
||||
};
|
||||
|
||||
export async function runDailyRollup(
|
||||
env: Env,
|
||||
now = new Date(),
|
||||
): Promise<DailyRollupSummary> {
|
||||
const db = getDb(env);
|
||||
const currentUtcDay = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
const dayStart = new Date(currentUtcDay - 24 * 60 * 60 * 1000);
|
||||
const dayEnd = new Date(currentUtcDay);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
monitorId: checks.monitorId,
|
||||
totalChecks: sql<number>`count(*)`,
|
||||
upChecks: sql<number>`sum(case when ${checks.ok} = 1 then 1 else 0 end)`,
|
||||
avgLatencyMs: sql<number | null>`round(avg(${checks.latencyMs}))`,
|
||||
minLatencyMs: sql<number | null>`min(${checks.latencyMs})`,
|
||||
maxLatencyMs: sql<number | null>`max(${checks.latencyMs})`,
|
||||
})
|
||||
.from(checks)
|
||||
.where(and(gte(checks.checkedAt, dayStart), lt(checks.checkedAt, dayEnd)))
|
||||
.groupBy(checks.monitorId);
|
||||
|
||||
if (rows.length > 0) {
|
||||
const statements = rows.map((row) => db
|
||||
.insert(monitorDailyStats)
|
||||
.values({
|
||||
monitorId: row.monitorId,
|
||||
day: dayStart,
|
||||
totalChecks: row.totalChecks,
|
||||
upChecks: row.upChecks,
|
||||
avgLatencyMs: row.avgLatencyMs,
|
||||
minLatencyMs: row.minLatencyMs,
|
||||
maxLatencyMs: row.maxLatencyMs,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [monitorDailyStats.monitorId, monitorDailyStats.day],
|
||||
set: {
|
||||
totalChecks: row.totalChecks,
|
||||
upChecks: row.upChecks,
|
||||
avgLatencyMs: row.avgLatencyMs,
|
||||
minLatencyMs: row.minLatencyMs,
|
||||
maxLatencyMs: row.maxLatencyMs,
|
||||
},
|
||||
}));
|
||||
await db.batch(statements as [typeof statements[number], ...typeof statements]);
|
||||
}
|
||||
|
||||
return { day: dayStart.toISOString().slice(0, 10), monitors: rows.length };
|
||||
}
|
||||
+41
-2
@@ -5,6 +5,9 @@ import { runDueChecks } from "../src/worker/checks/run-due-checks";
|
||||
async function clearMonitoringTables() {
|
||||
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 notification_settings"),
|
||||
env.DB.prepare("DELETE FROM monitors"),
|
||||
]);
|
||||
}
|
||||
@@ -26,8 +29,8 @@ async function insertMonitor(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
const result = await env.DB.prepare(`
|
||||
INSERT INTO monitors
|
||||
(name, url, method, expected_status, interval_seconds, timeout_ms, enabled, last_checked_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(name, url, method, expected_status, interval_seconds, timeout_ms, enabled, alerts_enabled, last_ok, last_checked_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
|
||||
`)
|
||||
.bind(
|
||||
values.name,
|
||||
@@ -37,6 +40,7 @@ async function insertMonitor(overrides: Record<string, unknown> = {}) {
|
||||
values.interval_seconds,
|
||||
values.timeout_ms,
|
||||
values.enabled,
|
||||
overrides.last_ok ?? null,
|
||||
values.last_checked_at,
|
||||
values.created_at,
|
||||
values.updated_at,
|
||||
@@ -88,6 +92,41 @@ describe("scheduled monitor checks", () => {
|
||||
last_status_code: 500,
|
||||
last_error: "Expected HTTP 200, received 500",
|
||||
});
|
||||
const incident = await env.DB.prepare("SELECT monitor_id, resolved_at, start_status_code, start_error FROM incidents WHERE monitor_id = ?")
|
||||
.bind(id)
|
||||
.first<{ monitor_id: number; resolved_at: number | null; start_status_code: number; start_error: string }>();
|
||||
expect(incident).toEqual({
|
||||
monitor_id: id,
|
||||
resolved_at: null,
|
||||
start_status_code: 500,
|
||||
start_error: "Expected HTTP 200, received 500",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not open duplicate incidents while a monitor stays down", async () => {
|
||||
const id = await insertMonitor({ last_ok: 0 });
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 503 })));
|
||||
await runDueChecks(env);
|
||||
const count = await env.DB.prepare("SELECT COUNT(*) AS count FROM incidents WHERE monitor_id = ?")
|
||||
.bind(id)
|
||||
.first<{ count: number }>();
|
||||
expect(count?.count).toBe(0);
|
||||
});
|
||||
|
||||
it("resolves the open incident on recovery", async () => {
|
||||
const id = await insertMonitor({ last_ok: 0 });
|
||||
const startedAt = Date.now() - 60_000;
|
||||
await env.DB.prepare("INSERT INTO incidents (monitor_id, started_at, start_status_code, start_error, created_at, updated_at) VALUES (?, ?, 500, 'Down', ?, ?)")
|
||||
.bind(id, startedAt, startedAt, startedAt)
|
||||
.run();
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 200 })));
|
||||
|
||||
await runDueChecks(env);
|
||||
const incident = await env.DB.prepare("SELECT resolved_at, duration_ms FROM incidents WHERE monitor_id = ?")
|
||||
.bind(id)
|
||||
.first<{ resolved_at: number | null; duration_ms: number | null }>();
|
||||
expect(incident?.resolved_at).toEqual(expect.any(Number));
|
||||
expect(incident?.duration_ms).toBeGreaterThanOrEqual(60_000);
|
||||
});
|
||||
|
||||
it("skips disabled and not-yet-due monitors", async () => {
|
||||
|
||||
+51
-2
@@ -15,6 +15,9 @@ const VALID_MONITOR = {
|
||||
async function seedAdmin() {
|
||||
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 notification_settings"),
|
||||
env.DB.prepare("DELETE FROM monitors"),
|
||||
env.DB.prepare("DELETE FROM login_attempts"),
|
||||
env.DB.prepare("DELETE FROM sessions"),
|
||||
@@ -80,10 +83,10 @@ describe("monitor API", () => {
|
||||
it("creates a valid monitor and returns it in the list", async () => {
|
||||
const cookie = await authenticatedCookie();
|
||||
const response = await createMonitor(cookie);
|
||||
const created = await response.json<{ monitor: { id: number; name: string; enabled: boolean } }>();
|
||||
const created = await response.json<{ monitor: { id: number; name: string; enabled: boolean; alertsEnabled: boolean } }>();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(created.monitor).toMatchObject({ name: "Example", enabled: true });
|
||||
expect(created.monitor).toMatchObject({ name: "Example", enabled: true, alertsEnabled: true });
|
||||
|
||||
const listResponse = await apiFetch("/api/monitors", "GET", cookie);
|
||||
const list = await listResponse.json<{ monitors: Array<{ id: number; url: string }> }>();
|
||||
@@ -118,6 +121,52 @@ describe("monitor API", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns detail, checks, incidents, and raw stats", async () => {
|
||||
const cookie = await authenticatedCookie();
|
||||
const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>();
|
||||
const id = created.monitor.id;
|
||||
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, 200, 100, ?)").bind(id, now - 2000),
|
||||
env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 0, 500, 300, ?)").bind(id, now - 1000),
|
||||
env.DB.prepare("INSERT INTO incidents (monitor_id, started_at, start_status_code, start_error, created_at, updated_at) VALUES (?, ?, 500, 'Down', ?, ?)").bind(id, now - 1000, now - 1000, now - 1000),
|
||||
]);
|
||||
|
||||
const [detail, checksResponse, incidentsResponse, statsResponse] = await Promise.all([
|
||||
apiFetch(`/api/monitors/${id}`, "GET", cookie),
|
||||
apiFetch(`/api/monitors/${id}/checks`, "GET", cookie),
|
||||
apiFetch(`/api/monitors/${id}/incidents`, "GET", cookie),
|
||||
apiFetch(`/api/monitors/${id}/stats`, "GET", cookie),
|
||||
]);
|
||||
expect((await detail.json<{ monitor: { id: number } }>()).monitor.id).toBe(id);
|
||||
expect((await checksResponse.json<{ checks: unknown[] }>()).checks).toHaveLength(2);
|
||||
expect((await incidentsResponse.json<{ incidents: unknown[] }>()).incidents).toHaveLength(1);
|
||||
const stats = await statsResponse.json<{ windows: { "24h": { uptimePct: number; totalChecks: number; upChecks: number; avgLatencyMs: number; incidentCount: number } } }>();
|
||||
expect(stats.windows["24h"]).toEqual({ uptimePct: 50, totalChecks: 2, upChecks: 1, avgLatencyMs: 200, incidentCount: 1 });
|
||||
});
|
||||
|
||||
it("combines daily rollups with the current partial day for long-range stats", async () => {
|
||||
const cookie = await authenticatedCookie();
|
||||
const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>();
|
||||
const id = created.monitor.id;
|
||||
const now = new Date();
|
||||
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
await env.DB.batch([
|
||||
env.DB.prepare("INSERT INTO monitor_daily_stats (monitor_id, day, total_checks, up_checks, avg_latency_ms, min_latency_ms, max_latency_ms) VALUES (?, ?, 8, 6, 100, 50, 150)").bind(id, today - 24 * 60 * 60 * 1000),
|
||||
env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 200, ?)").bind(id, today + 1000),
|
||||
env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 200, ?)").bind(id, today + 2000),
|
||||
]);
|
||||
|
||||
const response = await apiFetch(`/api/monitors/${id}/stats`, "GET", cookie);
|
||||
const stats = await response.json<{ windows: { "30d": { uptimePct: number; totalChecks: number; upChecks: number; avgLatencyMs: number } } }>();
|
||||
expect(stats.windows["30d"]).toMatchObject({
|
||||
uptimePct: 80,
|
||||
totalChecks: 10,
|
||||
upChecks: 8,
|
||||
avgLatencyMs: 120,
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes the monitor and its check records explicitly", async () => {
|
||||
const cookie = await authenticatedCookie();
|
||||
const created = await (await createMonitor(cookie)).json<{ monitor: { id: number } }>();
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { applyD1Migrations, env, type D1Migration } from "cloudflare:test";
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { runDailyRollup } from "../src/worker/scheduled/rollup";
|
||||
|
||||
describe("daily monitor rollups", () => {
|
||||
beforeAll(async () => {
|
||||
const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
|
||||
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await env.DB.batch([
|
||||
env.DB.prepare("DELETE FROM monitor_daily_stats"),
|
||||
env.DB.prepare("DELETE FROM checks"),
|
||||
env.DB.prepare("DELETE FROM incidents"),
|
||||
env.DB.prepare("DELETE FROM monitors"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("aggregates the previous UTC day and safely upserts on rerun", async () => {
|
||||
const now = new Date("2026-08-28T00:05:00.000Z");
|
||||
const createdAt = now.getTime();
|
||||
const insert = await env.DB.prepare("INSERT INTO monitors (name, url, method, expected_status, interval_seconds, timeout_ms, enabled, alerts_enabled, created_at, updated_at) VALUES ('API', 'https://example.com', 'GET', 200, 300, 10000, 1, 1, ?, ?)")
|
||||
.bind(createdAt, createdAt)
|
||||
.run();
|
||||
const id = Number(insert.meta.last_row_id);
|
||||
await env.DB.batch([
|
||||
env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 100, ?)").bind(id, Date.parse("2026-08-27T02:00:00Z")),
|
||||
env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 1, 200, 200, ?)").bind(id, Date.parse("2026-08-27T12:00:00Z")),
|
||||
env.DB.prepare("INSERT INTO checks (monitor_id, ok, status_code, latency_ms, checked_at) VALUES (?, 0, 500, 300, ?)").bind(id, Date.parse("2026-08-27T22:00:00Z")),
|
||||
]);
|
||||
|
||||
expect(await runDailyRollup(env, now)).toEqual({ day: "2026-08-27", monitors: 1 });
|
||||
await runDailyRollup(env, now);
|
||||
const rows = await env.DB.prepare("SELECT day, total_checks, up_checks, avg_latency_ms, min_latency_ms, max_latency_ms FROM monitor_daily_stats WHERE monitor_id = ?").bind(id).all();
|
||||
expect(rows.results).toHaveLength(1);
|
||||
expect(rows.results[0]).toEqual({
|
||||
day: Date.parse("2026-08-27T00:00:00Z"),
|
||||
total_checks: 3,
|
||||
up_checks: 2,
|
||||
avg_latency_ms: 200,
|
||||
min_latency_ms: 100,
|
||||
max_latency_ms: 300,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { applyD1Migrations, env, type D1Migration } from "cloudflare:test";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sendIncidentAlert } from "../src/worker/notifications/webhook";
|
||||
import type { Monitor } from "../src/worker/checks/run-check";
|
||||
|
||||
describe("incident webhooks", () => {
|
||||
beforeAll(async () => {
|
||||
const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
|
||||
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await env.DB.prepare("DELETE FROM notification_settings").run();
|
||||
const now = Date.now();
|
||||
await env.DB.prepare("INSERT INTO notification_settings (id, webhook_url, webhook_enabled, created_at, updated_at) VALUES (1, 'https://hooks.example.test/events', 1, ?, ?)").bind(now, now).run();
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
const monitor = {
|
||||
id: 7, name: "API", url: "https://api.example.com", method: "GET", expectedStatus: 200,
|
||||
intervalSeconds: 300, timeoutMs: 10000, enabled: true, alertsEnabled: true, lastOk: true,
|
||||
lastStatusCode: 200, lastLatencyMs: 30, lastError: null, lastCheckedAt: null,
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
} satisfies Monitor;
|
||||
|
||||
it("sends the compact down payload", async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const at = new Date("2026-08-28T03:25:00Z");
|
||||
expect(await sendIncidentAlert(env, { monitor, kind: "opened", result: { ok: false, statusCode: 500, latencyMs: 42, error: "Down" }, at })).toBe(true);
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(String(init?.body))).toEqual({
|
||||
event: "down",
|
||||
monitor: { id: 7, name: "API", url: "https://api.example.com" },
|
||||
statusCode: 500,
|
||||
error: "Down",
|
||||
at: "2026-08-28T03:25:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("swallows webhook network failures", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network unavailable"); }));
|
||||
await expect(sendIncidentAlert(env, { monitor, kind: "resolved", result: { ok: true, statusCode: 200, latencyMs: 20, error: null }, at: new Date() })).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"run_worker_first": ["/api/*"]
|
||||
},
|
||||
"triggers": {
|
||||
"crons": ["*/5 * * * *"]
|
||||
"crons": ["*/5 * * * *", "5 0 * * *"]
|
||||
},
|
||||
"d1_databases": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user