From e27ab6ff7d36d58d93ac54da977eb5bf6ee5784c Mon Sep 17 00:00:00 2001 From: Raj Nandan Sharma Date: Fri, 12 Jun 2026 13:42:08 +0530 Subject: [PATCH] feat(api): add DELETE /api/v4/monitors/{monitor_tag} and fix alert-config orphans on delete, fixes #716 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Monitor deletion is now available via the v4 API, reusing the same DeleteMonitorCompletelyUsingTag path as the manage UI. While wiring it in, monitor deletion was found to orphan alert configs on SQLite: the code relied on FK cascades that SQLite never enforces (the foreign_keys pragma is off). Delete paths now remove child rows explicitly — v2 alerts, trigger junctions, monitor junctions — in both the by-id and by-tag config deletes; see ADR 0008 for why explicit deletes were chosen over enabling the pragma. Also corrects the CONTEXT.md Stale Member entry (deletion strips group membership and rebalances weights; only pausing produces a stale member), documents the DELETE endpoint in the OpenAPI spec, points the pages doc at the ~home token, and removes an orphaned fictional api-reference markdown page superseded by the spec tab. Co-Authored-By: Claude Fable 5 --- CONTEXT.md | 2 +- .../0008-explicit-deletes-over-fk-cascades.md | 9 + .../monitorAlertConfigController.ts | 3 +- .../server/controllers/monitorsController.ts | 1 + .../db/repositories/monitorAlertConfig.ts | 12 +- src/lib/types/api.ts | 4 + .../api/v4/monitors/[monitor_tag]/+server.ts | 20 +- .../docs/content/v4/api-reference/monitors.md | 281 ------------------ .../(docs)/docs/content/v4/configuration.md | 6 +- src/routes/(docs)/docs/content/v4/pages.md | 4 + static/api-references/v4.json | 24 ++ 11 files changed, 77 insertions(+), 289 deletions(-) create mode 100644 docs/adr/0008-explicit-deletes-over-fk-cascades.md delete mode 100644 src/routes/(docs)/docs/content/v4/api-reference/monitors.md diff --git a/CONTEXT.md b/CONTEXT.md index 5c34b7ca..cca68d89 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -46,7 +46,7 @@ A Monitoring Sample produced by a check that actually ran against the target, wh A Monitoring Sample written by the system or an admin rather than by a check: a raw heartbeat receipt (`SIGNAL`), a status pushed through the data API (`MANUAL`), a default-status fill (`DEFAULT_STATUS`), or an incident/maintenance overlay (`INCIDENT`, `MAINTENANCE`). **Stale Member**: -A Member whose monitor is no longer an Eligible Monitor (paused or deleted after being added). It remains a Member until explicitly removed, but is excluded from the group score. +A Member whose monitor was paused after being added, making it no longer an Eligible Monitor. It remains a Member until explicitly removed, but is excluded from the group score. Deletion never produces a Stale Member: deleting a monitor strips it from every group and rebalances the remaining members' weights equally. ### Alerting diff --git a/docs/adr/0008-explicit-deletes-over-fk-cascades.md b/docs/adr/0008-explicit-deletes-over-fk-cascades.md new file mode 100644 index 00000000..372d43a2 --- /dev/null +++ b/docs/adr/0008-explicit-deletes-over-fk-cascades.md @@ -0,0 +1,9 @@ +# Delete paths remove child rows explicitly and never rely on FK cascades + +The schema declares `ON DELETE CASCADE` foreign keys (alert configs → monitors, trigger/monitor junctions → configs, v2 alerts → configs), but SQLite — the default deployment — never enforces them: knex does not enable the `foreign_keys` pragma, and `knexfile.ts` does not either. Code that trusted the declared cascades (`deleteMonitorAlertConfig`, and `DeleteMonitorCompletelyUsingTag`, which skipped alert configs entirely) silently orphaned junction rows, configs, and `monitor_alerts_v2` state on SQLite, while behaving correctly on Postgres and MySQL (#716). + +We considered fixing the root cause instead: enabling `PRAGMA foreign_keys = ON` per connection via `pool.afterCreate`. That would make every declared cascade real and align the three databases. We rejected it because knex implements SQLite `ALTER TABLE` as a table rebuild, which violates FK constraints transiently — the multi-monitor-alerts migration already has to toggle the pragma off around its own rebuild — so enforcement would put every past and future migration on a tightrope, and it changes write-failure behavior for all existing SQLite installs at once (inserts that used to succeed against missing parents would start throwing). + +The rule is the opposite and uniform: a delete path owns its children and removes them explicitly, in child-before-parent order. `deleteMonitorAlertConfig` deletes v2 alerts, trigger junctions, and monitor junctions before the config row; `deleteMonitorAlertConfigsByMonitorTag` detaches the monitor and routes zero-monitor configs through that same method; `DeleteMonitorCompletelyUsingTag` calls it alongside its other per-tag cleanups. The declared cascades stay in the schema — on Postgres/MySQL they make the explicit deletes idempotent no-ops, and they document intent — but no code may depend on them. + +A consequence: shared alert configs (one config spanning several monitors) survive the deletion of one member; a config dies only when its last monitor is detached. Monitor deletion also strips the tag from group monitors and rebalances remaining member weights equally — deletion never produces a stale member (see CONTEXT.md, "Stale Member"). diff --git a/src/lib/server/controllers/monitorAlertConfigController.ts b/src/lib/server/controllers/monitorAlertConfigController.ts index 2f118407..f91b6307 100644 --- a/src/lib/server/controllers/monitorAlertConfigController.ts +++ b/src/lib/server/controllers/monitorAlertConfigController.ts @@ -328,7 +328,8 @@ export async function DeleteMonitorAlertConfig(id: number): Promise { throw new Error(`Monitor alert config with id '${id}' not found`); } - // Triggers will be deleted automatically due to CASCADE + // The repository deletes trigger/monitor junctions and v2 alerts explicitly; + // FK cascades are not enforced on SQLite const deleted = await db.deleteMonitorAlertConfig(id); return deleted > 0; } diff --git a/src/lib/server/controllers/monitorsController.ts b/src/lib/server/controllers/monitorsController.ts index cb5d3ab6..53ba366d 100644 --- a/src/lib/server/controllers/monitorsController.ts +++ b/src/lib/server/controllers/monitorsController.ts @@ -437,6 +437,7 @@ export const DeleteMonitorCompletelyUsingTag = async (tag: string): Promise { + await this.knex("monitor_alerts_v2").where({ config_id: id }).del(); + await this.knex("monitor_alerts_config_triggers").where({ monitor_alerts_id: id }).del(); + await this.knex("monitor_alerts_config_monitors").where({ monitor_alerts_id: id }).del(); return await this.knex("monitor_alerts_config").where({ id }).del(); } @@ -170,7 +178,7 @@ export class MonitorAlertConfigRepository extends BaseRepository { .where({ monitor_alerts_id: id }) .first(); if (Number(remainingMonitors?.count) === 0) { - await this.knex("monitor_alerts_config").where({ id }).del(); + await this.deleteMonitorAlertConfig(id); deletedCount++; } } diff --git a/src/lib/types/api.ts b/src/lib/types/api.ts index 2fd49a1c..791c39d7 100644 --- a/src/lib/types/api.ts +++ b/src/lib/types/api.ts @@ -144,6 +144,10 @@ export interface UpdateMonitorResponse { monitor: MonitorRecordTyped; } +export interface DeleteMonitorResponse { + message: string; +} + // Monitoring Data API types export interface MonitoringDataPoint { monitor_tag: string; diff --git a/src/routes/(api)/api/v4/monitors/[monitor_tag]/+server.ts b/src/routes/(api)/api/v4/monitors/[monitor_tag]/+server.ts index ee39c62e..7afe77ee 100644 --- a/src/routes/(api)/api/v4/monitors/[monitor_tag]/+server.ts +++ b/src/routes/(api)/api/v4/monitors/[monitor_tag]/+server.ts @@ -1,11 +1,12 @@ import { json, type RequestHandler } from "@sveltejs/kit"; import db from "$lib/server/db/db"; -import { GetMonitorsParsed } from "$lib/server/controllers/monitorsController"; +import { GetMonitorsParsed, DeleteMonitorCompletelyUsingTag } from "$lib/server/controllers/monitorsController"; import type { GetMonitorResponse, MonitorResponse, UpdateMonitorRequest, UpdateMonitorResponse, + DeleteMonitorResponse, BadRequestResponse, } from "$lib/types/api"; @@ -143,3 +144,20 @@ export const PATCH: RequestHandler = async ({ locals, request }) => { return json(response); }; + +export const DELETE: RequestHandler = async ({ locals }) => { + // Monitor is validated by middleware and available in locals + const monitor = locals.monitor!; + + // Removes the monitor and everything keyed to its tag: monitoring data, + // incident/maintenance/page links, alerts, alert configs, group + // memberships (with weight rebalancing), and caches. The scheduler drops + // the orphaned BullMQ job on its next reconcile. + await DeleteMonitorCompletelyUsingTag(monitor.tag); + + const response: DeleteMonitorResponse = { + message: `Monitor '${monitor.tag}' deleted successfully`, + }; + + return json(response); +}; diff --git a/src/routes/(docs)/docs/content/v4/api-reference/monitors.md b/src/routes/(docs)/docs/content/v4/api-reference/monitors.md deleted file mode 100644 index 943772cf..00000000 --- a/src/routes/(docs)/docs/content/v4/api-reference/monitors.md +++ /dev/null @@ -1,281 +0,0 @@ ---- -title: Monitors API -description: Create, read, update, and delete monitors via the REST API ---- - -Create, read, update, and delete monitors via the API. - -## List Monitors - -Get all monitors with their current status. - -```http -GET /api/monitors -``` - -### Parameters - -| Parameter | Type | Description | -| --------- | ------- | ------------------------------------ | -| `page` | integer | Page number (default: 1) | -| `limit` | integer | Items per page (default: 20) | -| `status` | string | Filter by status: up, down, degraded | -| `type` | string | Filter by type: API, PING, TCP, etc. | - -### Example Request - -```bash -curl -X GET "https://your-kener.com/api/monitors?status=up&limit=10" \ - -H "Authorization: Bearer YOUR_API_KEY" -``` - -### Example Response - -```json -{ - "success": true, - "data": { - "monitors": [ - { - "id": "mon_abc123", - "name": "API Server", - "type": "API", - "url": "https://api.example.com/health", - "status": "up", - "uptime": 99.95, - "lastChecked": "2024-01-15T10:30:00Z" - } - ], - "pagination": { - "page": 1, - "limit": 10, - "total": 25 - } - } -} -``` - -## Get Monitor - -Get details for a specific monitor. - -```http -GET /api/monitors/:id -``` - -### Example Request - -```bash -curl -X GET https://your-kener.com/api/monitors/mon_abc123 \ - -H "Authorization: Bearer YOUR_API_KEY" -``` - -### Example Response - -```json -{ - "success": true, - "data": { - "id": "mon_abc123", - "name": "API Server", - "type": "API", - "url": "https://api.example.com/health", - "method": "GET", - "expectedStatusCode": 200, - "interval": 60, - "timeout": 10000, - "status": "up", - "uptime": { - "day": 100, - "week": 99.95, - "month": 99.87 - }, - "responseTime": { - "current": 145, - "average": 152 - }, - "lastChecked": "2024-01-15T10:30:00Z", - "createdAt": "2024-01-01T00:00:00Z" - } -} -``` - -## Create Monitor - -Create a new monitor. - -```http -POST /api/monitors -``` - -### Request Body - -```json -{ - "name": "Production API", - "type": "API", - "url": "https://api.example.com/health", - "method": "GET", - "expectedStatusCode": 200, - "interval": 60, - "timeout": 10000, - "headers": { - "Authorization": "Bearer token" - } -} -``` - -### Monitor Types - -#### API Monitor - -```json -{ - "name": "API Health", - "type": "API", - "url": "https://api.example.com", - "method": "GET", - "expectedStatusCode": 200, - "headers": {}, - "body": null -} -``` - -#### Ping Monitor - -```json -{ - "name": "Server Ping", - "type": "PING", - "host": "server.example.com" -} -``` - -#### TCP Monitor - -```json -{ - "name": "Database Port", - "type": "TCP", - "host": "db.example.com", - "port": 5432 -} -``` - -#### DNS Monitor - -```json -{ - "name": "DNS Check", - "type": "DNS", - "host": "example.com", - "recordType": "A" -} -``` - -### Example Request - -```bash -curl -X POST https://your-kener.com/api/monitors \ - -H "Authorization: Bearer YOUR_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "Production API", - "type": "API", - "url": "https://api.example.com/health", - "interval": 60 - }' -``` - -### Example Response - -```json -{ - "success": true, - "data": { - "id": "mon_xyz789", - "name": "Production API", - "type": "API", - "status": "pending", - "createdAt": "2024-01-15T10:30:00Z" - }, - "message": "Monitor created successfully" -} -``` - -## Update Monitor - -Update an existing monitor. - -```http -PUT /api/monitors/:id -``` - -### Example Request - -```bash -curl -X PUT https://your-kener.com/api/monitors/mon_abc123 \ - -H "Authorization: Bearer YOUR_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "Updated API Name", - "interval": 120 - }' -``` - -## Delete Monitor - -Delete a monitor. - -```http -DELETE /api/monitors/:id -``` - -### Example Request - -```bash -curl -X DELETE https://your-kener.com/api/monitors/mon_abc123 \ - -H "Authorization: Bearer YOUR_API_KEY" -``` - -## Monitor History - -Get historical data for a monitor. - -```http -GET /api/monitors/:id/history -``` - -### Parameters - -| Parameter | Type | Description | -| ------------ | ------ | ---------------------------------- | -| `start` | string | Start date (ISO 8601) | -| `end` | string | End date (ISO 8601) | -| `resolution` | string | Data resolution: minute, hour, day | - -### Example Request - -```bash -curl -X GET "https://your-kener.com/api/monitors/mon_abc123/history?resolution=hour" \ - -H "Authorization: Bearer YOUR_API_KEY" -``` - -## Pause/Resume Monitor - -```http -POST /api/monitors/:id/pause -POST /api/monitors/:id/resume -``` - -### Example - -```bash -# Pause -curl -X POST https://your-kener.com/api/monitors/mon_abc123/pause \ - -H "Authorization: Bearer YOUR_API_KEY" - -# Resume -curl -X POST https://your-kener.com/api/monitors/mon_abc123/resume \ - -H "Authorization: Bearer YOUR_API_KEY" -``` diff --git a/src/routes/(docs)/docs/content/v4/configuration.md b/src/routes/(docs)/docs/content/v4/configuration.md index fdfdb5bf..dae98fe9 100644 --- a/src/routes/(docs)/docs/content/v4/configuration.md +++ b/src/routes/(docs)/docs/content/v4/configuration.md @@ -196,7 +196,7 @@ status.yourdomain.com { ## Next Steps -- Set up [Monitors](/docs/monitors) -- Configure [Incidents](/docs/incidents) +- Set up [Monitors](/docs/v4/monitors) +- Configure [Incidents](/docs/v4/incidents) - Configure [Sharing Monitors](/docs/v4/sharing) -- Explore the [API Reference](/docs/api-reference) +- Explore the [API Reference](/docs/spec/v4/) diff --git a/src/routes/(docs)/docs/content/v4/pages.md b/src/routes/(docs)/docs/content/v4/pages.md index 4f3fae06..cb5ca2c1 100644 --- a/src/routes/(docs)/docs/content/v4/pages.md +++ b/src/routes/(docs)/docs/content/v4/pages.md @@ -97,6 +97,10 @@ Example: delete services ``` +## Manage pages via API {#manage-pages-via-api} + +Pages support full CRUD through the v4 REST API (`/api/v4/pages`), including assigning monitors. The home page has an empty stored path, so the API addresses it with the special segment `~home` (for example `PATCH /api/v4/pages/~home`); its path cannot be changed and it cannot be deleted. See the [API Reference](/docs/spec/v4/) for endpoints and schemas. + ## Tips {#tips} - Keep page paths short and stable (changing links later is disruptive). diff --git a/static/api-references/v4.json b/static/api-references/v4.json index d49cdc2a..9a88952c 100644 --- a/static/api-references/v4.json +++ b/static/api-references/v4.json @@ -230,6 +230,30 @@ "$ref": "#/components/responses/InternalServerError" } } + }, + "delete": { + "tags": ["Monitors"], + "operationId": "deleteMonitor", + "summary": "Delete monitor", + "description": "Deletes the monitor and everything keyed to its tag: monitoring data, incident/maintenance/page links, alerts, alert configurations, and group memberships (remaining member weights are rebalanced equally). This is irreversible. Scheduled checks stop within seconds.", + "responses": { + "200": { + "description": "Monitor deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } } }, "/api/v4/monitors/{monitor_tag}/data": {