diff --git a/apps/api/src/notifications/index.ts b/apps/api/src/notifications/index.ts index 7dcbf4b..60a3fed 100644 --- a/apps/api/src/notifications/index.ts +++ b/apps/api/src/notifications/index.ts @@ -32,7 +32,8 @@ export async function dispatchForMonitor(monitorId: string, event: NotificationE const channels = await sql` SELECT c.id, c.account_id, c.name, c.kind, c.config, c.enabled FROM notification_channels c - WHERE c.id = ANY((SELECT channel_ids FROM monitors WHERE id = ${monitorId})) + WHERE c.account_id = (SELECT account_id FROM monitors WHERE id = ${monitorId}) + AND ${monitorId} = ANY(c.monitor_ids) AND c.enabled = true `; if (channels.length === 0) return; diff --git a/apps/api/src/routes/channels.ts b/apps/api/src/routes/channels.ts index 6fb34ad..bd110f8 100644 --- a/apps/api/src/routes/channels.ts +++ b/apps/api/src/routes/channels.ts @@ -4,12 +4,23 @@ import sql from "../db"; import { dispatch, knownProviderKinds, type ChannelRow } from "../notifications"; const ChannelBody = t.Object({ - name: t.String({ minLength: 1, maxLength: 200 }), - kind: t.String({ description: "Provider kind, e.g. 'webhook'" }), - config: t.Any({ description: "Provider-specific config object" }), - enabled: t.Optional(t.Boolean()), + name: t.String({ minLength: 1, maxLength: 200 }), + kind: t.String({ description: "Provider kind, e.g. 'webhook'" }), + config: t.Any({ description: "Provider-specific config object" }), + enabled: t.Optional(t.Boolean()), + monitor_ids: t.Optional(t.Array(t.String(), { description: "Monitor IDs this channel dispatches for." })), }); +async function validateMonitorIds(accountId: string, monitorIds: string[]): Promise { + if (monitorIds.length === 0) return []; + const owned = await sql<{ id: string }[]>` + SELECT id FROM monitors + WHERE account_id = ${accountId} + AND id = ANY(${sql.array(monitorIds)}::text[]) + `; + return owned.map((o) => o.id); +} + function validateKind(kind: string): string | null { if (!knownProviderKinds().includes(kind)) { return `Unknown provider kind '${kind}'. Known: ${knownProviderKinds().join(", ")}`; @@ -35,7 +46,7 @@ export const channels = new Elysia({ prefix: "/notifications/channels" }) .get("/", async ({ accountId }) => { return sql` - SELECT id, name, kind, config, enabled, created_at + SELECT id, name, kind, config, enabled, monitor_ids, created_at FROM notification_channels WHERE account_id = ${accountId} ORDER BY created_at DESC @@ -48,10 +59,11 @@ export const channels = new Elysia({ prefix: "/notifications/channels" }) const cfgErr = validateConfig(body.kind, body.config); if (cfgErr) { set.status = 400; return { error: cfgErr }; } + const monitorIds = body.monitor_ids ? await validateMonitorIds(accountId, body.monitor_ids) : []; const [row] = await sql` - INSERT INTO notification_channels (account_id, name, kind, config, enabled) - VALUES (${accountId}, ${body.name}, ${body.kind}, ${sql.json(body.config)}, ${body.enabled ?? true}) - RETURNING id, name, kind, config, enabled, created_at + INSERT INTO notification_channels (account_id, name, kind, config, enabled, monitor_ids) + VALUES (${accountId}, ${body.name}, ${body.kind}, ${sql.json(body.config)}, ${body.enabled ?? true}, ${sql.array(monitorIds)}::text[]) + RETURNING id, name, kind, config, enabled, monitor_ids, created_at `; return row; }, { body: ChannelBody, detail: { summary: "Create notification channel", tags: ["notifications"] } }) @@ -69,14 +81,16 @@ export const channels = new Elysia({ prefix: "/notifications/channels" }) } } + const validatedMonitorIds = body.monitor_ids ? await validateMonitorIds(accountId, body.monitor_ids) : null; const [row] = await sql` UPDATE notification_channels SET - name = COALESCE(${body.name ?? null}, name), - kind = COALESCE(${body.kind ?? null}, kind), - config = COALESCE(${body.config != null ? sql.json(body.config) : null}, config), - enabled = COALESCE(${body.enabled ?? null}, enabled) + name = COALESCE(${body.name ?? null}, name), + kind = COALESCE(${body.kind ?? null}, kind), + config = COALESCE(${body.config != null ? sql.json(body.config) : null}, config), + enabled = COALESCE(${body.enabled ?? null}, enabled), + monitor_ids = COALESCE(${validatedMonitorIds ? sql.array(validatedMonitorIds) : null}::text[], monitor_ids) WHERE id = ${params.id} AND account_id = ${accountId} - RETURNING id, name, kind, config, enabled, created_at + RETURNING id, name, kind, config, enabled, monitor_ids, created_at `; if (!row) { set.status = 404; return { error: "Not found" }; } return row; @@ -89,11 +103,6 @@ export const channels = new Elysia({ prefix: "/notifications/channels" }) RETURNING id `; if (!row) { set.status = 404; return { error: "Not found" }; } - // Remove this channel from any monitors that reference it. - await sql` - UPDATE monitors SET channel_ids = array_remove(channel_ids, ${params.id}::uuid) - WHERE account_id = ${accountId} AND ${params.id}::uuid = ANY(channel_ids) - `; return { deleted: true }; }, { detail: { summary: "Delete notification channel", tags: ["notifications"] } }) diff --git a/apps/api/src/routes/monitors.ts b/apps/api/src/routes/monitors.ts index 8f9b252..4c3a8ac 100644 --- a/apps/api/src/routes/monitors.ts +++ b/apps/api/src/routes/monitors.ts @@ -20,7 +20,6 @@ const MonitorBody = t.Object({ max_redirects: t.Optional(t.Number({ minimum: 0, maximum: 4, default: 1, description: "Follow up to N redirects. 0 = don't follow. Default 1." })), query: t.Optional(t.Any({ description: "PingQL query - filter conditions for up/down" })), regions: t.Optional(t.Array(t.String(), { description: "Regions to run checks from. Empty array = all regions." })), - channel_ids: t.Optional(t.Array(t.String(), { description: "Notification channel IDs to attach to this monitor." })), tags: t.Optional(t.Array(t.String({ pattern: "^[a-z0-9][a-z0-9-]{0,40}$" }), { description: "Lowercase tag slugs for grouping. Replaces the existing tag set." })), }); @@ -28,16 +27,6 @@ function dedupeTags(tags: string[]): string[] { return Array.from(new Set(tags.map((t) => t.trim()).filter(Boolean))); } -async function validateChannelIds(accountId: string, channelIds: string[]): Promise { - if (channelIds.length === 0) return []; - const owned = await sql<{ id: string }[]>` - SELECT id FROM notification_channels - WHERE account_id = ${accountId} - AND id = ANY(${sql.array(channelIds)}::uuid[]) - `; - return owned.map((o) => o.id); -} - export const monitors = new Elysia({ prefix: "/monitors" }) .use(requireAuth) @@ -83,9 +72,8 @@ export const monitors = new Elysia({ prefix: "/monitors" }) const ssrfError = await validateMonitorUrl(body.url); if (ssrfError) { set.status = 400; return { error: ssrfError }; } const tags = body.tags ? dedupeTags(body.tags) : []; - const channelIds = body.channel_ids ? await validateChannelIds(accountId, body.channel_ids) : []; const [monitor] = await sql` - INSERT INTO monitors (account_id, name, url, method, request_headers, request_body, timeout_ms, interval_s, max_retries, retry_interval_s, resend_interval, cert_alert_days, max_redirects, query, regions, tags, channel_ids) + INSERT INTO monitors (account_id, name, url, method, request_headers, request_body, timeout_ms, interval_s, max_retries, retry_interval_s, resend_interval, cert_alert_days, max_redirects, query, regions, tags) VALUES ( ${accountId}, ${body.name}, ${body.url}, ${(body.method ?? 'GET').toUpperCase()}, @@ -100,8 +88,7 @@ export const monitors = new Elysia({ prefix: "/monitors" }) ${body.max_redirects ?? 1}, ${body.query ? sql.json(body.query) : null}, ${sql.array(regions)}, - ${sql.array(tags)}, - ${sql.array(channelIds)}::uuid[] + ${sql.array(tags)} ) RETURNING * `; @@ -145,7 +132,6 @@ export const monitors = new Elysia({ prefix: "/monitors" }) if (ssrfError) { set.status = 400; return { error: ssrfError }; } } - const validatedChannelIds = body.channel_ids ? await validateChannelIds(accountId, body.channel_ids) : null; const [monitor] = await sql` UPDATE monitors SET name = COALESCE(${body.name ?? null}, name), @@ -163,7 +149,6 @@ export const monitors = new Elysia({ prefix: "/monitors" }) query = COALESCE(${body.query ? sql.json(body.query) : null}, query), regions = COALESCE(${body.regions ? sql.array(body.regions) : null}, regions), tags = COALESCE(${body.tags ? sql.array(dedupeTags(body.tags)) : null}, tags), - channel_ids = COALESCE(${validatedChannelIds ? sql.array(validatedChannelIds) : null}::uuid[], channel_ids), updated_at = now() WHERE id = ${params.id} AND account_id = ${accountId} RETURNING * @@ -178,6 +163,10 @@ export const monitors = new Elysia({ prefix: "/monitors" }) DELETE FROM monitors WHERE id = ${params.id} AND account_id = ${accountId} RETURNING id `; if (!deleted) { set.status = 404; return { error: "Not found" }; } + await sql` + UPDATE notification_channels SET monitor_ids = array_remove(monitor_ids, ${params.id}) + WHERE account_id = ${accountId} AND ${params.id} = ANY(monitor_ids) + `; invalidateMonitorList(); return { deleted: true }; }, { detail: { summary: "Delete monitor", tags: ["monitors"] } }) diff --git a/apps/shared/db.ts b/apps/shared/db.ts index 2b64b49..8803430 100644 --- a/apps/shared/db.ts +++ b/apps/shared/db.ts @@ -35,14 +35,12 @@ export async function migrate(sql: any) { cert_alert_days INTEGER NOT NULL DEFAULT 0, max_redirects INTEGER NOT NULL DEFAULT 1, tags TEXT[] NOT NULL DEFAULT '{}', - channel_ids UUID[] NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ) `; await sql`CREATE INDEX IF NOT EXISTS idx_monitors_account ON monitors(account_id)`; await sql`CREATE INDEX IF NOT EXISTS idx_monitors_tags ON monitors USING GIN(tags)`; - await sql`CREATE INDEX IF NOT EXISTS idx_monitors_channel_ids ON monitors USING GIN(channel_ids)`; // ── pings ───────────────────────────────────────────────────────────── await sql` @@ -114,10 +112,34 @@ export async function migrate(sql: any) { kind TEXT NOT NULL, config JSONB NOT NULL, enabled BOOLEAN NOT NULL DEFAULT true, + monitor_ids TEXT[] NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT now() ) `; await sql`CREATE INDEX IF NOT EXISTS idx_notification_channels_account ON notification_channels(account_id)`; + await sql`ALTER TABLE notification_channels ADD COLUMN IF NOT EXISTS monitor_ids TEXT[] NOT NULL DEFAULT '{}'`; + await sql`CREATE INDEX IF NOT EXISTS idx_notification_channels_monitor_ids ON notification_channels USING GIN(monitor_ids)`; + + // ── one-time flip: monitors.channel_ids → notification_channels.monitor_ids ── + const [chanCol] = await sql` + SELECT 1 FROM information_schema.columns + WHERE table_name = 'monitors' AND column_name = 'channel_ids' + `; + if (chanCol) { + await sql` + UPDATE notification_channels c SET monitor_ids = sub.mids + FROM ( + SELECT m.channel_id::uuid AS cid, array_agg(m.id) AS mids + FROM ( + SELECT id, UNNEST(channel_ids) AS channel_id FROM monitors + ) m + GROUP BY m.channel_id + ) sub + WHERE c.id = sub.cid + `; + await sql`ALTER TABLE monitors DROP COLUMN IF EXISTS channel_ids`; + await sql`DROP INDEX IF EXISTS idx_monitors_channel_ids`; + } // ── status_pages ────────────────────────────────────────────────────── await sql` diff --git a/apps/web/src/routes/dashboard.ts b/apps/web/src/routes/dashboard.ts index 5ae6e63..3ea225b 100644 --- a/apps/web/src/routes/dashboard.ts +++ b/apps/web/src/routes/dashboard.ts @@ -426,25 +426,25 @@ export const dashboard = new Elysia() const resolved = await getAccountId(cookie, headers); const accountId = resolved?.accountId ?? null; if (!accountId) return redirect("/dashboard"); - const channels = await sql` - SELECT id, name, kind FROM notification_channels - WHERE account_id = ${accountId} AND enabled = true - ORDER BY created_at DESC - `; - return html("new", { nav: "monitors", plan: resolved?.plan || "free", channels }); + return html("new", { nav: "monitors", plan: resolved?.plan || "free" }); }) .get("/dashboard/notifications", async ({ cookie, headers, query }) => { const resolved = await getAccountId(cookie, headers); if (!resolved?.accountId) return redirect("/dashboard"); const channels = await sql` - SELECT id, name, kind, config, enabled, created_at + SELECT id, name, kind, config, enabled, monitor_ids, created_at FROM notification_channels WHERE account_id = ${resolved.accountId} ORDER BY created_at DESC `; + const monitors = await sql` + SELECT id, name FROM monitors + WHERE account_id = ${resolved.accountId} + ORDER BY created_at DESC + `; const testResult = query.test === "ok" ? { ok: true } : query.test_error ? { ok: false, error: String(query.test_error) } : null; - return html("notifications", { nav: "notifications", channels, testResult }); + return html("notifications", { nav: "notifications", channels, monitors, testResult }); }) .post("/dashboard/notifications/new", async ({ cookie, headers, body }) => { @@ -457,13 +457,31 @@ export const dashboard = new Elysia() config.url = (b.url || "").trim(); if (b.secret) config.secret = b.secret; } + const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []); try { const apiUrl = process.env.API_URL || "https://api.pingql.com"; const key = cookie?.pingql_key?.value; await fetch(`${apiUrl}/notifications/channels/`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` }, - body: JSON.stringify({ name: (b.name || "").trim(), kind, config }), + body: JSON.stringify({ name: (b.name || "").trim(), kind, config, monitor_ids: monitorIds }), + }); + } catch {} + return redirect("/dashboard/notifications"); + }) + + .post("/dashboard/notifications/:id/edit", async ({ cookie, headers, params, body }) => { + const resolved = await getAccountId(cookie, headers); + if (!resolved?.accountId) return redirect("/dashboard"); + const b = body as any; + const monitorIds = Array.isArray(b.monitor_ids) ? b.monitor_ids : (b.monitor_ids ? [b.monitor_ids] : []); + try { + const apiUrl = process.env.API_URL || "https://api.pingql.com"; + const key = cookie?.pingql_key?.value; + await fetch(`${apiUrl}/notifications/channels/${params.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` }, + body: JSON.stringify({ monitor_ids: monitorIds }), }); } catch {} return redirect("/dashboard/notifications"); @@ -530,14 +548,8 @@ export const dashboard = new Elysia() SELECT * FROM pings WHERE monitor_id = ${params.id} ORDER BY checked_at DESC LIMIT 200 `; - const channels = await sql` - SELECT id, name, kind FROM notification_channels - WHERE account_id = ${accountId} AND enabled = true - ORDER BY created_at DESC - `; - // channel_ids is already on the monitor row as a UUID[] column - return html("detail", { nav: "monitors", monitor, pings, plan: resolved?.plan || "free", channels }); + return html("detail", { nav: "monitors", monitor, pings, plan: resolved?.plan || "free" }); }) .get("/dashboard/monitors/:id/chart", async ({ cookie, headers, params }) => { @@ -613,7 +625,6 @@ export const dashboard = new Elysia() resend_interval: Number(b.resend_interval) || 0, cert_alert_days: b.cert_alert_days != null ? Number(b.cert_alert_days) : 0, max_redirects: b.max_redirects != null ? Number(b.max_redirects) : 1, - channel_ids: Array.isArray(b.channel_ids) ? b.channel_ids : (b.channel_ids ? [b.channel_ids] : []), regions, request_headers: Object.keys(requestHeaders).length ? requestHeaders : null, request_body: b.request_body || null, @@ -656,7 +667,6 @@ export const dashboard = new Elysia() resend_interval: Number(b.resend_interval) || 0, cert_alert_days: b.cert_alert_days != null ? Number(b.cert_alert_days) : 0, max_redirects: b.max_redirects != null ? Number(b.max_redirects) : 1, - channel_ids: Array.isArray(b.channel_ids) ? b.channel_ids : (b.channel_ids ? [b.channel_ids] : []), regions, request_headers: Object.keys(requestHeaders).length ? requestHeaders : null, request_body: b.request_body || null, diff --git a/apps/web/src/views/detail.ejs b/apps/web/src/views/detail.ejs index 9be95c9..54c936c 100644 --- a/apps/web/src/views/detail.ejs +++ b/apps/web/src/views/detail.ejs @@ -158,7 +158,7 @@

Edit Monitor

- <%~ include('./partials/monitor-form', { _form: { monitor: m, isEdit: true, prefix: 'edit-', bg: 'bg-gray-800/50', border: 'border-border-subtle' }, plan: it.plan, regions: it.regions, channels: it.channels }) %> + <%~ include('./partials/monitor-form', { _form: { monitor: m, isEdit: true, prefix: 'edit-', bg: 'bg-gray-800/50', border: 'border-border-subtle' }, plan: it.plan, regions: it.regions }) %>
diff --git a/apps/web/src/views/docs.ejs b/apps/web/src/views/docs.ejs index e35d583..a219437 100644 --- a/apps/web/src/views/docs.ejs +++ b/apps/web/src/views/docs.ejs @@ -134,21 +134,20 @@
json - request body
{
-  "name":       "My API",
-  "url":        "https://api.example.com/health",
-  "interval_s": 60,        // check every 60 seconds (min: 2)
-  "method":     "POST",    // optional - default: GET
-  "request_headers": { "X-Api-Key": "secret" },  // optional
-  "request_body": "{\"ping\": true}",            // optional - Content-Type defaults to application/json
-  "regions":    ["eu-central", "us-west"],   // optional - default: all regions
-  "timeout_ms": 10000,     // optional - default: 10000
-  "max_retries":      2,    // optional - retry N times before declaring DOWN. Default: 0
-  "retry_interval_s": 30,   // optional - seconds between retries. Default: 30
-  "resend_interval":  10,   // optional - re-alert every Nth consecutive DOWN beat. 0 = never. Default: 0
-  "cert_alert_days":   0,   // optional - alert when TLS cert is within N days of expiry. 0 disables. Default: 0 (disabled)
-  "max_redirects":     1,   // optional - follow up to N redirects (0-4). Default: 1
-  "channel_ids": ["<uuid>"],  // optional - notification channels to attach
-  "query":      { ... }   // optional - see Query Language below
+  "name":       "My API",
+  "url":        "https://api.example.com/health",
+  "interval_s": 60,        // check every 60 seconds (min: 2)
+  "method":     "POST",    // optional - default: GET
+  "request_headers": { "X-Api-Key": "secret" },  // optional
+  "request_body": "{\"ping\": true}",            // optional - Content-Type defaults to application/json
+  "regions":    ["eu-central", "us-west"],   // optional - default: all regions
+  "timeout_ms": 10000,     // optional - default: 10000
+  "max_retries":      2,    // optional - retry N times before declaring DOWN. Default: 0
+  "retry_interval_s": 30,   // optional - seconds between retries. Default: 30
+  "resend_interval":  10,   // optional - re-alert every Nth consecutive DOWN beat. 0 = never. Default: 0
+  "cert_alert_days":   0,   // optional - alert when TLS cert is within N days of expiry. 0 disables. Default: 0 (disabled)
+  "max_redirects":     1,   // optional - follow up to N redirects (0-4). Default: 1
+  "query":      { ... }   // optional - see Query Language below
 }
@@ -167,7 +166,6 @@ -
resend_intervalnumberIf a monitor stays DOWN, re-fire a notification every Nth consecutive down beat. 0 disables resend. Default: 0.
cert_alert_daysnumberFire a separate cert notification when the TLS certificate is within N days of expiring. 0 disables. Default: 0 (disabled).
max_redirectsnumberFollow up to N redirects (0-4). 0 = don't follow. Default: 1.
channel_idsstring[]Notification channel IDs to attach. See Notifications.
queryobjectQuery conditions - see below
@@ -204,7 +202,7 @@

Notifications

-

Notification channels are reusable destinations attached to monitors. When an important beat fires (DOWN, recovery, or cert), each attached channel is dispatched. PingQL ships with a webhook provider; more (Discord, Slack, Email) are designed as drop-ins.

+

Each notification channel holds a list of monitors it dispatches for. When an important beat fires (DOWN, recovery, or cert) on one of its attached monitors, the channel is dispatched. PingQL ships with a webhook provider; more (Discord, Slack, Email) are designed as drop-ins.

List channels

GET/notifications/channels
@@ -215,14 +213,15 @@
json - request body
{
-  "name":    "On-call webhook",
-  "kind":    "webhook",
-  "config":  {
-    "url":     "https://hooks.example.com/pingql",
-    "headers": { "X-Team": "infra" },  // optional
-    "secret":  "shared-hmac-secret"          // optional - signs payloads
-  },
-  "enabled": true           // optional - default true
+  "name":    "On-call webhook",
+  "kind":    "webhook",
+  "config":  {
+    "url":     "https://hooks.example.com/pingql",
+    "headers": { "X-Team": "infra" },  // optional
+    "secret":  "shared-hmac-secret"          // optional - signs payloads
+  },
+  "monitor_ids": ["<monitor-id>"],  // optional - monitors to dispatch for
+  "enabled": true           // optional - default true
 }
@@ -232,12 +231,13 @@ +
kindstringProvider type. Currently only webhook.
configobjectProvider-specific config. For webhook, requires url (http/https). Optional headers object and secret for HMAC signing.
enabledbooleanDisabled channels are skipped during dispatch but remain attached.
monitor_idsstring[]Monitor IDs this channel dispatches for. See Attaching monitors below.

Update channel

PATCH/notifications/channels/:id
-

All fields optional. Provide a partial body to change name, config, or enabled state.

+

All fields optional. Provide a partial body to change name, config, enabled state, or monitor_ids.

Delete channel

DELETE/notifications/channels/:id
@@ -247,15 +247,15 @@
POST/notifications/channels/:id/test

Sends a synthetic test event through the channel and returns whether the provider accepted it. Useful to verify the URL and HMAC are wired correctly without waiting for a real outage.

-

Attaching channels to monitors

-

Pass channel_ids as an array of channel UUIDs when creating or patching a monitor. The PATCH replaces the full set; pass an empty array to detach all channels. Channels can only be attached to monitors in the same account.

+

Attaching monitors to channels

+

Pass monitor_ids as an array of monitor IDs when creating or patching a channel. The PATCH replaces the full set; pass an empty array to detach all monitors. Monitors can only be attached to channels in the same account.

http
-
PATCH /monitors/abc123def456
+          
PATCH /notifications/channels/5fb1c0bf-…
 Authorization: Bearer <key>
 Content-Type: application/json
 
-{ "channel_ids": ["5fb1c0bf-…", "a72e0d91-…"] }
+{ "monitor_ids": ["abc123def456", "f0e1d2c3b4a59687"] }
@@ -273,16 +273,16 @@ Content-Type: application/json
json - request body
{
-  "slug":  "my-app",
-  "title": "My App Status",
-  "groups": [
-    { "name": "Core", "position": 0 },
-    { "name": "Integrations", "position": 1 }
-  ],
-  "monitors": [
-    { "monitor_id": "a1b2c3d4e5f67890", "group_index": 0 },
-    { "monitor_id": "f0e1d2c3b4a59687", "group_index": 1, "display_name": "Stripe" }
-  ]
+  "slug":  "my-app",
+  "title": "My App Status",
+  "groups": [
+    { "name": "Core", "position": 0 },
+    { "name": "Integrations", "position": 1 }
+  ],
+  "monitors": [
+    { "monitor_id": "a1b2c3d4e5f67890", "group_index": 0 },
+    { "monitor_id": "f0e1d2c3b4a59687", "group_index": 1, "display_name": "Stripe" }
+  ]
 }
@@ -361,14 +361,14 @@ Content-Type: application/json
json - request body
{
-  "title":    "API returning 503s",
-  "status":   "investigating",
-  "severity": "major",
-  "monitor_ids":     ["a1b2c3d4e5f67890"],
-  "status_page_ids": ["f8c1a2b3-..."],
-  "initial_update":  {
-    "body": "We're seeing elevated error rates on the API. Investigating now."
-  }
+  "title":    "API returning 503s",
+  "status":   "investigating",
+  "severity": "major",
+  "monitor_ids":     ["a1b2c3d4e5f67890"],
+  "status_page_ids": ["f8c1a2b3-..."],
+  "initial_update":  {
+    "body": "We're seeing elevated error rates on the API. Investigating now."
+  }
 }
@@ -397,8 +397,8 @@ Content-Type: application/json
json - request body
{
-  "status": "identified",
-  "body":   "Root cause identified - a bad deploy at 14:02 UTC. Rolling back now."
+  "status": "identified",
+  "body":   "Root cause identified - a bad deploy at 14:02 UTC. Rolling back now."
 }

The body field supports basic markdown: **bold**, *italic*, `code`, and [links](url). The incident's status is automatically synced to the latest update's status.

@@ -504,13 +504,13 @@ Content-Type: application/json
json
{
-  "$and": [
-    { "status": 200 },
-    { "$or": [
-      { "$json": { "$.region": { "$eq": "us" } } },
-      { "$json": { "$.region": { "$eq": "eu" } } }
-    ] }
-  ]
+  "$and": [
+    { "status": 200 },
+    { "$or": [
+      { "$json": { "$.region": { "$eq": "us" } } },
+      { "$json": { "$.region": { "$eq": "eu" } } }
+    ] }
+  ]
 }
@@ -526,10 +526,10 @@ Content-Type: application/json

JSON API response shape

json
{
-  "$and": [
-    { "status": 200 },
-    { "$json": { "$.ok": { "$eq": true } } }
-  ]
+  "$and": [
+    { "status": 200 },
+    { "$json": { "$.ok": { "$eq": true } } }
+  ]
 }

Performance monitor (down if slow)

@@ -553,41 +553,41 @@ Content-Type: application/json

Wrap $or in $not to mark down when any condition matches.

json
{ "$not": {
-  "$or": [
-    { "status": { "$ge": 500 } },
-    { "$time": { "$gt": 3000 } },
-    { "$json": { "$.healthy": { "$eq": false } } },
-    { "$html": { ".error-banner": { "$exists": true } } }
-  ]
+  "$or": [
+    { "status": { "$ge": 500 } },
+    { "$time": { "$gt": 3000 } },
+    { "$json": { "$.healthy": { "$eq": false } } },
+    { "$html": { ".error-banner": { "$exists": true } } }
+  ]
 } }

Up only when everything matches

Combine $and with header, body, and JSON checks for a strict definition of healthy.

json
{
-  "$and": [
-    { "status": 200 },
-    { "headers.content-type": { "$co": "application/json" } },
-    { "$json": { "$.version": { "$sw": "v2" } } },
-    { "$json": { "$.db.connections": { "$lt": 100 } } }
-  ]
+  "$and": [
+    { "status": 200 },
+    { "headers.content-type": { "$co": "application/json" } },
+    { "$json": { "$.version": { "$sw": "v2" } } },
+    { "$json": { "$.db.connections": { "$lt": 100 } } }
+  ]
 }

Nested logic

$and and $or nest freely inside each other, as deep as you need.

json
{
-  "$and": [
-    { "status": { "$lt": 400 } },
-    { "$or": [
-      { "$json": { "$.env": { "$eq": "production" } } },
-      { "$json": { "$.env": { "$eq": "staging" } } }
-    ] },
-    { "$or": [
-      { "$time": { "$lt": 2000 } },
-      { "$json": { "$.cache": { "$eq": "hit" } } }
-    ] }
-  ]
+  "$and": [
+    { "status": { "$lt": 400 } },
+    { "$or": [
+      { "$json": { "$.env": { "$eq": "production" } } },
+      { "$json": { "$.env": { "$eq": "staging" } } }
+    ] },
+    { "$or": [
+      { "$time": { "$lt": 2000 } },
+      { "$json": { "$.cache": { "$eq": "hit" } } }
+    ] }
+  ]
 }
@@ -633,8 +633,8 @@ Content-Type: application/json
json
{
-  "channel": { "id": "<uuid>", "name": "On-call webhook" },
-  "event": { "kind": "down" | "up" | "cert" | "test", ... }
+  "channel": { "id": "<uuid>", "name": "On-call webhook" },
+  "event": { "kind": "down" | "up" | "cert" | "test", ... }
 }
@@ -642,19 +642,19 @@ Content-Type: application/json

down - fired on the first DOWN important beat for a region, and again every resend_intervalth consecutive down if configured.

json - event
{
-  "kind": "down",
-  "monitor": {
-    "id":     "abc123def456",
-    "name":   "My API",
-    "url":    "https://api.example.com/health",
-    "region": "us-west"     // always present - runners default to "default" if REGION env var is unset
-  },
-  "ping": {
-    "status_code": 503,
-    "latency_ms":  412,
-    "error":       null,
-    "checked_at":  "2026-04-08T14:23:00.000Z"
-  }
+  "kind": "down",
+  "monitor": {
+    "id":     "abc123def456",
+    "name":   "My API",
+    "url":    "https://api.example.com/health",
+    "region": "us-west"     // always present - runners default to "default" if REGION env var is unset
+  },
+  "ping": {
+    "status_code": 503,
+    "latency_ms":  412,
+    "error":       null,
+    "checked_at":  "2026-04-08T14:23:00.000Z"
+  }
 }

up - fired on recovery, only when that same region transitions back from DOWN. Same shape as down.

@@ -662,16 +662,16 @@ Content-Type: application/json

cert - fired once per renewal cycle when the TLS leaf cert drops at or below cert_alert_days for a region.

json - event
{
-  "kind": "cert",
-  "monitor": { "id": "…", "name": "…", "url": "…", "region": "us-west" },
-  "days": 9           // days until certificate expires
+  "kind": "cert",
+  "monitor": { "id": "…", "name": "…", "url": "…", "region": "us-west" },
+  "days": 9           // days until certificate expires
 }

test - synthetic event from POST /notifications/channels/:id/test. The monitor object is a placeholder.

json - event
{
-  "kind": "test",
-  "monitor": { "id": "test", "name": "Test event", "url": "https://example.com", "region": "" }
+  "kind": "test",
+  "monitor": { "id": "test", "name": "Test event", "url": "https://example.com", "region": "" }
 }

Verifying the signature

@@ -680,8 +680,8 @@ Content-Type: application/json
import { createHmac, timingSafeEqual } from "crypto";
 
 function verify(rawBody, headerSig, secret) {
-  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
-  return timingSafeEqual(Buffer.from(expected), Buffer.from(headerSig));
+  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
+  return timingSafeEqual(Buffer.from(expected), Buffer.from(headerSig));
 }

Always verify against the raw request body before parsing JSON.

diff --git a/apps/web/src/views/landing.ejs b/apps/web/src/views/landing.ejs index acd0bd0..c68a570 100644 --- a/apps/web/src/views/landing.ejs +++ b/apps/web/src/views/landing.ejs @@ -163,26 +163,26 @@ - -
-

PingQL is in active development. Please don't rely on the service until this notice is removed.

-
- - -
-
- PingQL - - + +
+
+

PingQL is in active development. Please don't rely on the service until this notice is removed.

-
+
+
+ PingQL + + +
+
+
@@ -221,11 +221,11 @@
{
-  "$and": [
-    { "status": { "$lt": 400 } },
-    { "$json": { "$.db.status": { "$eq": "ok" } } },
-    { "$certExpiry": { "$gt": 14 } }
-  ]
+  "$and": [
+    { "status": { "$lt": 400 } },
+    { "$json": { "$.db.status": { "$eq": "ok" } } },
+    { "$certExpiry": { "$gt": 14 } }
+  ]
 }
// status < 400 AND $.db.status = "ok" AND $certExpiry > 14 days @@ -562,20 +562,20 @@
$ curl -X POST https://pingql.com/api/pages \
-  -H "X-Key: abcd-1234-efgh-5678" \
-  -d '{
-    "slug": "my-app",
-    "title": "My App Status",
-    "monitors": [
-      { "monitor_id": "a1b2c3d4e5f67890" }
-    ]
-  }'
+  -H "X-Key: abcd-1234-efgh-5678" \
+  -d '{
+    "slug": "my-app",
+    "title": "My App Status",
+    "monitors": [
+      { "monitor_id": "a1b2c3d4e5f67890" }
+    ]
+  }'
 
 {
-  "id": "f8c1a2b3-...",
-  "slug": "my-app",
-  "title": "My App Status",
-  "theme": "auto"
+  "id": "f8c1a2b3-...",
+  "slug": "my-app",
+  "title": "My App Status",
+  "theme": "auto"
 }
@@ -590,22 +590,22 @@
$ curl -X POST https://pingql.com/api/monitors \
-  -H "X-Key: abcd-1234-efgh-5678" \
-  -d '{
-    "name": "Production API",
-    "url": "https://api.example.com/health",
-    "interval_s": 60,
-    "query": {
-      "status": { "$lt": 400 },
-      "$json": { "$.ok": { "$eq": true } }
-    }
-  }'
+  -H "X-Key: abcd-1234-efgh-5678" \
+  -d '{
+    "name": "Production API",
+    "url": "https://api.example.com/health",
+    "interval_s": 60,
+    "query": {
+      "status": { "$lt": 400 },
+      "$json": { "$.ok": { "$eq": true } }
+    }
+  }'
 
 {
-  "id": "a1b2c3d4e5f67890",
-  "name": "Production API",
-  "url": "https://api.example.com/health",
-  "enabled": true
+  "id": "a1b2c3d4e5f67890",
+  "name": "Production API",
+  "url": "https://api.example.com/health",
+  "enabled": true
 }
diff --git a/apps/web/src/views/new.ejs b/apps/web/src/views/new.ejs index c0d3367..431a3ed 100644 --- a/apps/web/src/views/new.ejs +++ b/apps/web/src/views/new.ejs @@ -7,7 +7,7 @@

Create Monitor

- <%~ include('./partials/monitor-form', { _form: { monitor: {}, isEdit: false, prefix: '', bg: 'bg-surface-solid', border: 'border-border-subtle' }, plan: it.plan, regions: it.regions, channels: it.channels }) %> + <%~ include('./partials/monitor-form', { _form: { monitor: {}, isEdit: false, prefix: '', bg: 'bg-surface-solid', border: 'border-border-subtle' }, plan: it.plan, regions: it.regions }) %>