feat(api): add DELETE /api/v4/monitors/{monitor_tag} and fix alert-config orphans on delete, fixes #716

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 <noreply@anthropic.com>
This commit is contained in:
Raj Nandan Sharma
2026-06-12 13:42:08 +05:30
parent c301aaab90
commit e27ab6ff7d
11 changed files with 77 additions and 289 deletions
+1 -1
View File
@@ -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
@@ -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").
@@ -328,7 +328,8 @@ export async function DeleteMonitorAlertConfig(id: number): Promise<boolean> {
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;
}
@@ -437,6 +437,7 @@ export const DeleteMonitorCompletelyUsingTag = async (tag: string): Promise<numb
await db.deleteMonitorDataByTag(tag);
await db.deleteIncidentMonitorsByTag(tag);
await db.deleteMonitorAlertsByTag(tag);
await db.deleteMonitorAlertConfigsByMonitorTag(tag);
await db.deletePageMonitorsByTag(tag);
await db.deleteMaintenanceMonitorsByTag(tag);
await removeTagFromGroupMonitors(tag);
@@ -141,9 +141,17 @@ export class MonitorAlertConfigRepository extends BaseRepository {
}
/**
* Delete a monitor alert config by ID
* Delete a monitor alert config by ID, including all child rows.
*
* Child rows are removed explicitly even though FK cascades are declared:
* SQLite never enforces them (foreign_keys pragma is off), so relying on
* CASCADE orphans children on the default deployment. See
* docs/adr/0008-explicit-deletes-over-fk-cascades.md.
*/
async deleteMonitorAlertConfig(id: number): Promise<number> {
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<CountResult>();
if (Number(remainingMonitors?.count) === 0) {
await this.knex("monitor_alerts_config").where({ id }).del();
await this.deleteMonitorAlertConfig(id);
deletedCount++;
}
}
+4
View File
@@ -144,6 +144,10 @@ export interface UpdateMonitorResponse {
monitor: MonitorRecordTyped;
}
export interface DeleteMonitorResponse {
message: string;
}
// Monitoring Data API types
export interface MonitoringDataPoint {
monitor_tag: string;
@@ -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);
};
@@ -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"
```
@@ -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/)
@@ -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).
+24
View File
@@ -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": {