fix various issues

This commit is contained in:
nate 2026-04-24 23:40:42 +04:00
parent 114f35cb9b
commit d9d38c6fea
12 changed files with 284 additions and 238 deletions

View File

@ -32,7 +32,8 @@ export async function dispatchForMonitor(monitorId: string, event: NotificationE
const channels = await sql<ChannelRow[]>`
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;

View File

@ -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<string[]> {
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"] } })

View File

@ -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<string[]> {
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"] } })

View File

@ -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`

View File

@ -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,

View File

@ -158,7 +158,7 @@
<!-- Edit form -->
<div class="card-static p-6">
<h3 class="text-sm text-gray-400 mb-4">Edit Monitor</h3>
<%~ 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 }) %>
</div>
</div>
</main>

View File

@ -134,21 +134,20 @@
<div class="cb">
<div class="cb-header"><span class="cb-lang">json - request body</span></div>
<pre>{
<span class="k">"name"</span>: <span class="s">"My API"</span>,
<span class="k">"url"</span>: <span class="s">"https://api.example.com/health"</span>,
<span class="k">"interval_s"</span>: <span class="n">60</span>, <span class="c">// check every 60 seconds (min: 2)</span>
<span class="k">"method"</span>: <span class="s">"POST"</span>, <span class="c">// optional - default: GET</span>
<span class="k">"request_headers"</span>: { <span class="s">"X-Api-Key"</span>: <span class="s">"secret"</span> }, <span class="c">// optional</span>
<span class="k">"request_body"</span>: <span class="s">"{\"ping\": true}"</span>, <span class="c">// optional - Content-Type defaults to application/json</span>
<span class="k">"regions"</span>: [<span class="s">"eu-central"</span>, <span class="s">"us-west"</span>], <span class="c">// optional - default: all regions</span>
<span class="k">"timeout_ms"</span>: <span class="n">10000</span>, <span class="c">// optional - default: 10000</span>
<span class="k">"max_retries"</span>: <span class="n">2</span>, <span class="c">// optional - retry N times before declaring DOWN. Default: 0</span>
<span class="k">"retry_interval_s"</span>: <span class="n">30</span>, <span class="c">// optional - seconds between retries. Default: 30</span>
<span class="k">"resend_interval"</span>: <span class="n">10</span>, <span class="c">// optional - re-alert every Nth consecutive DOWN beat. 0 = never. Default: 0</span>
<span class="k">"cert_alert_days"</span>: <span class="n">0</span>, <span class="c">// optional - alert when TLS cert is within N days of expiry. 0 disables. Default: 0 (disabled)</span>
<span class="k">"max_redirects"</span>: <span class="n">1</span>, <span class="c">// optional - follow up to N redirects (0-4). Default: 1</span>
<span class="k">"channel_ids"</span>: [<span class="s">"&lt;uuid&gt;"</span>], <span class="c">// optional - notification channels to attach</span>
<span class="k">"query"</span>: { ... } <span class="c">// optional - see Query Language below</span>
&nbsp;&nbsp;<span class="k">"name"</span>: <span class="s">"My API"</span>,
&nbsp;&nbsp;<span class="k">"url"</span>: <span class="s">"https://api.example.com/health"</span>,
&nbsp;&nbsp;<span class="k">"interval_s"</span>: <span class="n">60</span>, <span class="c">// check every 60 seconds (min: 2)</span>
&nbsp;&nbsp;<span class="k">"method"</span>: <span class="s">"POST"</span>, <span class="c">// optional - default: GET</span>
&nbsp;&nbsp;<span class="k">"request_headers"</span>: { <span class="s">"X-Api-Key"</span>: <span class="s">"secret"</span> }, <span class="c">// optional</span>
&nbsp;&nbsp;<span class="k">"request_body"</span>: <span class="s">"{\"ping\": true}"</span>, <span class="c">// optional - Content-Type defaults to application/json</span>
&nbsp;&nbsp;<span class="k">"regions"</span>: [<span class="s">"eu-central"</span>, <span class="s">"us-west"</span>], <span class="c">// optional - default: all regions</span>
&nbsp;&nbsp;<span class="k">"timeout_ms"</span>: <span class="n">10000</span>, <span class="c">// optional - default: 10000</span>
&nbsp;&nbsp;<span class="k">"max_retries"</span>: <span class="n">2</span>, <span class="c">// optional - retry N times before declaring DOWN. Default: 0</span>
&nbsp;&nbsp;<span class="k">"retry_interval_s"</span>: <span class="n">30</span>, <span class="c">// optional - seconds between retries. Default: 30</span>
&nbsp;&nbsp;<span class="k">"resend_interval"</span>: <span class="n">10</span>, <span class="c">// optional - re-alert every Nth consecutive DOWN beat. 0 = never. Default: 0</span>
&nbsp;&nbsp;<span class="k">"cert_alert_days"</span>: <span class="n">0</span>, <span class="c">// optional - alert when TLS cert is within N days of expiry. 0 disables. Default: 0 (disabled)</span>
&nbsp;&nbsp;<span class="k">"max_redirects"</span>: <span class="n">1</span>, <span class="c">// optional - follow up to N redirects (0-4). Default: 1</span>
&nbsp;&nbsp;<span class="k">"query"</span>: { ... } <span class="c">// optional - see Query Language below</span>
}</pre>
</div>
<table>
@ -167,7 +166,6 @@
<tr><td>resend_interval</td><td>number</td><td>If a monitor stays DOWN, re-fire a notification every Nth consecutive down beat. 0 disables resend. Default: 0.</td></tr>
<tr><td>cert_alert_days</td><td>number</td><td>Fire a separate <code>cert</code> notification when the TLS certificate is within N days of expiring. 0 disables. Default: 0 (disabled).</td></tr>
<tr><td>max_redirects</td><td>number</td><td>Follow up to N redirects (0-4). 0 = don't follow. Default: 1.</td></tr>
<tr><td>channel_ids</td><td>string[]</td><td>Notification channel IDs to attach. See <a href="#notifications">Notifications</a>.</td></tr>
<tr><td>query</td><td>object</td><td>Query conditions - see below</td></tr>
</tbody>
</table>
@ -204,7 +202,7 @@
<!-- Notifications -->
<div id="notifications" class="section">
<h2>Notifications</h2>
<p>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 <strong>webhook</strong> provider; more (Discord, Slack, Email) are designed as drop-ins.</p>
<p>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 <strong>webhook</strong> provider; more (Discord, Slack, Email) are designed as drop-ins.</p>
<h3>List channels</h3>
<div class="endpoint"><span class="method get">GET</span><span class="path">/notifications/channels</span></div>
@ -215,14 +213,15 @@
<div class="cb">
<div class="cb-header"><span class="cb-lang">json - request body</span></div>
<pre>{
<span class="k">"name"</span>: <span class="s">"On-call webhook"</span>,
<span class="k">"kind"</span>: <span class="s">"webhook"</span>,
<span class="k">"config"</span>: {
<span class="k">"url"</span>: <span class="s">"https://hooks.example.com/pingql"</span>,
<span class="k">"headers"</span>: { <span class="s">"X-Team"</span>: <span class="s">"infra"</span> }, <span class="c">// optional</span>
<span class="k">"secret"</span>: <span class="s">"shared-hmac-secret"</span> <span class="c">// optional - signs payloads</span>
},
<span class="k">"enabled"</span>: <span class="n">true</span> <span class="c">// optional - default true</span>
&nbsp;&nbsp;<span class="k">"name"</span>: <span class="s">"On-call webhook"</span>,
&nbsp;&nbsp;<span class="k">"kind"</span>: <span class="s">"webhook"</span>,
&nbsp;&nbsp;<span class="k">"config"</span>: {
&nbsp;&nbsp;&nbsp;&nbsp;<span class="k">"url"</span>: <span class="s">"https://hooks.example.com/pingql"</span>,
&nbsp;&nbsp;&nbsp;&nbsp;<span class="k">"headers"</span>: { <span class="s">"X-Team"</span>: <span class="s">"infra"</span> }, <span class="c">// optional</span>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="k">"secret"</span>: <span class="s">"shared-hmac-secret"</span> <span class="c">// optional - signs payloads</span>
&nbsp;&nbsp;},
&nbsp;&nbsp;<span class="k">"monitor_ids"</span>: [<span class="s">"&lt;monitor-id&gt;"</span>], <span class="c">// optional - monitors to dispatch for</span>
&nbsp;&nbsp;<span class="k">"enabled"</span>: <span class="n">true</span> <span class="c">// optional - default true</span>
}</pre>
</div>
<table>
@ -232,12 +231,13 @@
<tr><td>kind</td><td>string</td><td>Provider type. Currently only <code>webhook</code>.</td></tr>
<tr><td>config</td><td>object</td><td>Provider-specific config. For <code>webhook</code>, requires <code>url</code> (http/https). Optional <code>headers</code> object and <code>secret</code> for HMAC signing.</td></tr>
<tr><td>enabled</td><td>boolean</td><td>Disabled channels are skipped during dispatch but remain attached.</td></tr>
<tr><td>monitor_ids</td><td>string[]</td><td>Monitor IDs this channel dispatches for. See <a href="#notifications">Attaching monitors</a> below.</td></tr>
</tbody>
</table>
<h3>Update channel</h3>
<div class="endpoint"><span class="method patch">PATCH</span><span class="path">/notifications/channels/:id</span></div>
<p class="endpoint-desc">All fields optional. Provide a partial body to change name, config, or enabled state.</p>
<p class="endpoint-desc">All fields optional. Provide a partial body to change name, config, enabled state, or <code>monitor_ids</code>.</p>
<h3>Delete channel</h3>
<div class="endpoint"><span class="method delete">DELETE</span><span class="path">/notifications/channels/:id</span></div>
@ -247,15 +247,15 @@
<div class="endpoint"><span class="method post">POST</span><span class="path">/notifications/channels/:id/test</span></div>
<p class="endpoint-desc">Sends a synthetic <code>test</code> 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.</p>
<h3>Attaching channels to monitors</h3>
<p>Pass <code>channel_ids</code> 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.</p>
<h3>Attaching monitors to channels</h3>
<p>Pass <code>monitor_ids</code> 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.</p>
<div class="cb">
<div class="cb-header"><span class="cb-lang">http</span></div>
<pre>PATCH /monitors/abc123def456
<pre>PATCH /notifications/channels/5fb1c0bf-…
Authorization: Bearer &lt;key&gt;
Content-Type: application/json
{ <span class="k">"channel_ids"</span>: [<span class="s">"5fb1c0bf-…"</span>, <span class="s">"a72e0d91-…"</span>] }</pre>
{ <span class="k">"monitor_ids"</span>: [<span class="s">"abc123def456"</span>, <span class="s">"f0e1d2c3b4a59687"</span>] }</pre>
</div>
</div>
@ -273,16 +273,16 @@ Content-Type: application/json
<div class="cb">
<div class="cb-header"><span class="cb-lang">json - request body</span></div>
<pre>{
<span class="k">"slug"</span>: <span class="s">"my-app"</span>,
<span class="k">"title"</span>: <span class="s">"My App Status"</span>,
<span class="k">"groups"</span>: [
{ <span class="k">"name"</span>: <span class="s">"Core"</span>, <span class="k">"position"</span>: <span class="n">0</span> },
{ <span class="k">"name"</span>: <span class="s">"Integrations"</span>, <span class="k">"position"</span>: <span class="n">1</span> }
],
<span class="k">"monitors"</span>: [
{ <span class="k">"monitor_id"</span>: <span class="s">"a1b2c3d4e5f67890"</span>, <span class="k">"group_index"</span>: <span class="n">0</span> },
{ <span class="k">"monitor_id"</span>: <span class="s">"f0e1d2c3b4a59687"</span>, <span class="k">"group_index"</span>: <span class="n">1</span>, <span class="k">"display_name"</span>: <span class="s">"Stripe"</span> }
]
&nbsp;&nbsp;<span class="k">"slug"</span>: <span class="s">"my-app"</span>,
&nbsp;&nbsp;<span class="k">"title"</span>: <span class="s">"My App Status"</span>,
&nbsp;&nbsp;<span class="k">"groups"</span>: [
&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="k">"name"</span>: <span class="s">"Core"</span>, <span class="k">"position"</span>: <span class="n">0</span> },
&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="k">"name"</span>: <span class="s">"Integrations"</span>, <span class="k">"position"</span>: <span class="n">1</span> }
&nbsp;&nbsp;],
&nbsp;&nbsp;<span class="k">"monitors"</span>: [
&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="k">"monitor_id"</span>: <span class="s">"a1b2c3d4e5f67890"</span>, <span class="k">"group_index"</span>: <span class="n">0</span> },
&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="k">"monitor_id"</span>: <span class="s">"f0e1d2c3b4a59687"</span>, <span class="k">"group_index"</span>: <span class="n">1</span>, <span class="k">"display_name"</span>: <span class="s">"Stripe"</span> }
&nbsp;&nbsp;]
}</pre>
</div>
<table>
@ -361,14 +361,14 @@ Content-Type: application/json
<div class="cb">
<div class="cb-header"><span class="cb-lang">json - request body</span></div>
<pre>{
<span class="k">"title"</span>: <span class="s">"API returning 503s"</span>,
<span class="k">"status"</span>: <span class="s">"investigating"</span>,
<span class="k">"severity"</span>: <span class="s">"major"</span>,
<span class="k">"monitor_ids"</span>: [<span class="s">"a1b2c3d4e5f67890"</span>],
<span class="k">"status_page_ids"</span>: [<span class="s">"f8c1a2b3-..."</span>],
<span class="k">"initial_update"</span>: {
<span class="k">"body"</span>: <span class="s">"We're seeing elevated error rates on the API. Investigating now."</span>
}
&nbsp;&nbsp;<span class="k">"title"</span>: <span class="s">"API returning 503s"</span>,
&nbsp;&nbsp;<span class="k">"status"</span>: <span class="s">"investigating"</span>,
&nbsp;&nbsp;<span class="k">"severity"</span>: <span class="s">"major"</span>,
&nbsp;&nbsp;<span class="k">"monitor_ids"</span>: [<span class="s">"a1b2c3d4e5f67890"</span>],
&nbsp;&nbsp;<span class="k">"status_page_ids"</span>: [<span class="s">"f8c1a2b3-..."</span>],
&nbsp;&nbsp;<span class="k">"initial_update"</span>: {
&nbsp;&nbsp;&nbsp;&nbsp;<span class="k">"body"</span>: <span class="s">"We're seeing elevated error rates on the API. Investigating now."</span>
&nbsp;&nbsp;}
}</pre>
</div>
<table>
@ -397,8 +397,8 @@ Content-Type: application/json
<div class="cb">
<div class="cb-header"><span class="cb-lang">json - request body</span></div>
<pre>{
<span class="k">"status"</span>: <span class="s">"identified"</span>,
<span class="k">"body"</span>: <span class="s">"Root cause identified - a bad deploy at 14:02 UTC. Rolling back now."</span>
&nbsp;&nbsp;<span class="k">"status"</span>: <span class="s">"identified"</span>,
&nbsp;&nbsp;<span class="k">"body"</span>: <span class="s">"Root cause identified - a bad deploy at 14:02 UTC. Rolling back now."</span>
}</pre>
</div>
<p>The <code>body</code> field supports basic markdown: <code>**bold**</code>, <code>*italic*</code>, <code>`code`</code>, and <code>[links](url)</code>. The incident's status is automatically synced to the latest update's status.</p>
@ -504,13 +504,13 @@ Content-Type: application/json
<div class="cb">
<div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{
<span class="o">"$and"</span>: [
{ <span class="k">"status"</span>: <span class="n">200</span> },
{ <span class="o">"$or"</span>: [
{ <span class="o">"$json"</span>: { <span class="s">"$.region"</span>: { <span class="o">"$eq"</span>: <span class="s">"us"</span> } } },
{ <span class="o">"$json"</span>: { <span class="s">"$.region"</span>: { <span class="o">"$eq"</span>: <span class="s">"eu"</span> } } }
] }
]
&nbsp;&nbsp;<span class="o">"$and"</span>: [
&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="k">"status"</span>: <span class="n">200</span> },
&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="o">"$or"</span>: [
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="o">"$json"</span>: { <span class="s">"$.region"</span>: { <span class="o">"$eq"</span>: <span class="s">"us"</span> } } },
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="o">"$json"</span>: { <span class="s">"$.region"</span>: { <span class="o">"$eq"</span>: <span class="s">"eu"</span> } } }
&nbsp;&nbsp;&nbsp;&nbsp;] }
&nbsp;&nbsp;]
}</pre>
</div>
</div>
@ -526,10 +526,10 @@ Content-Type: application/json
<h3>JSON API response shape</h3>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{
<span class="o">"$and"</span>: [
{ <span class="k">"status"</span>: <span class="n">200</span> },
{ <span class="o">"$json"</span>: { <span class="s">"$.ok"</span>: { <span class="o">"$eq"</span>: <span class="n">true</span> } } }
]
&nbsp;&nbsp;<span class="o">"$and"</span>: [
&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="k">"status"</span>: <span class="n">200</span> },
&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="o">"$json"</span>: { <span class="s">"$.ok"</span>: { <span class="o">"$eq"</span>: <span class="n">true</span> } } }
&nbsp;&nbsp;]
}</pre></div>
<h3>Performance monitor (down if slow)</h3>
@ -553,41 +553,41 @@ Content-Type: application/json
<p>Wrap <code>$or</code> in <code>$not</code> to mark down when any condition matches.</p>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{ <span class="o">"$not"</span>: {
<span class="o">"$or"</span>: [
{ <span class="k">"status"</span>: { <span class="o">"$ge"</span>: <span class="n">500</span> } },
{ <span class="k">"$time"</span>: { <span class="o">"$gt"</span>: <span class="n">3000</span> } },
{ <span class="o">"$json"</span>: { <span class="s">"$.healthy"</span>: { <span class="o">"$eq"</span>: <span class="n">false</span> } } },
{ <span class="o">"$html"</span>: { <span class="s">".error-banner"</span>: { <span class="o">"$exists"</span>: <span class="n">true</span> } } }
]
&nbsp;&nbsp;<span class="o">"$or"</span>: [
&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="k">"status"</span>: { <span class="o">"$ge"</span>: <span class="n">500</span> } },
&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="k">"$time"</span>: { <span class="o">"$gt"</span>: <span class="n">3000</span> } },
&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="o">"$json"</span>: { <span class="s">"$.healthy"</span>: { <span class="o">"$eq"</span>: <span class="n">false</span> } } },
&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="o">"$html"</span>: { <span class="s">".error-banner"</span>: { <span class="o">"$exists"</span>: <span class="n">true</span> } } }
&nbsp;&nbsp;]
} }</pre></div>
<h3>Up only when everything matches</h3>
<p>Combine <code>$and</code> with header, body, and JSON checks for a strict definition of healthy.</p>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{
<span class="o">"$and"</span>: [
{ <span class="k">"status"</span>: <span class="n">200</span> },
{ <span class="k">"headers.content-type"</span>: { <span class="o">"$co"</span>: <span class="s">"application/json"</span> } },
{ <span class="o">"$json"</span>: { <span class="s">"$.version"</span>: { <span class="o">"$sw"</span>: <span class="s">"v2"</span> } } },
{ <span class="o">"$json"</span>: { <span class="s">"$.db.connections"</span>: { <span class="o">"$lt"</span>: <span class="n">100</span> } } }
]
&nbsp;&nbsp;<span class="o">"$and"</span>: [
&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="k">"status"</span>: <span class="n">200</span> },
&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="k">"headers.content-type"</span>: { <span class="o">"$co"</span>: <span class="s">"application/json"</span> } },
&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="o">"$json"</span>: { <span class="s">"$.version"</span>: { <span class="o">"$sw"</span>: <span class="s">"v2"</span> } } },
&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="o">"$json"</span>: { <span class="s">"$.db.connections"</span>: { <span class="o">"$lt"</span>: <span class="n">100</span> } } }
&nbsp;&nbsp;]
}</pre></div>
<h3>Nested logic</h3>
<p><code>$and</code> and <code>$or</code> nest freely inside each other, as deep as you need.</p>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{
<span class="o">"$and"</span>: [
{ <span class="k">"status"</span>: { <span class="o">"$lt"</span>: <span class="n">400</span> } },
{ <span class="o">"$or"</span>: [
{ <span class="o">"$json"</span>: { <span class="s">"$.env"</span>: { <span class="o">"$eq"</span>: <span class="s">"production"</span> } } },
{ <span class="o">"$json"</span>: { <span class="s">"$.env"</span>: { <span class="o">"$eq"</span>: <span class="s">"staging"</span> } } }
] },
{ <span class="o">"$or"</span>: [
{ <span class="k">"$time"</span>: { <span class="o">"$lt"</span>: <span class="n">2000</span> } },
{ <span class="o">"$json"</span>: { <span class="s">"$.cache"</span>: { <span class="o">"$eq"</span>: <span class="s">"hit"</span> } } }
] }
]
&nbsp;&nbsp;<span class="o">"$and"</span>: [
&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="k">"status"</span>: { <span class="o">"$lt"</span>: <span class="n">400</span> } },
&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="o">"$or"</span>: [
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="o">"$json"</span>: { <span class="s">"$.env"</span>: { <span class="o">"$eq"</span>: <span class="s">"production"</span> } } },
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="o">"$json"</span>: { <span class="s">"$.env"</span>: { <span class="o">"$eq"</span>: <span class="s">"staging"</span> } } }
&nbsp;&nbsp;&nbsp;&nbsp;] },
&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="o">"$or"</span>: [
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="k">"$time"</span>: { <span class="o">"$lt"</span>: <span class="n">2000</span> } },
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{ <span class="o">"$json"</span>: { <span class="s">"$.cache"</span>: { <span class="o">"$eq"</span>: <span class="s">"hit"</span> } } }
&nbsp;&nbsp;&nbsp;&nbsp;] }
&nbsp;&nbsp;]
}</pre></div>
</div>
@ -633,8 +633,8 @@ Content-Type: application/json
<div class="cb">
<div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{
<span class="k">"channel"</span>: { <span class="k">"id"</span>: <span class="s">"&lt;uuid&gt;"</span>, <span class="k">"name"</span>: <span class="s">"On-call webhook"</span> },
<span class="k">"event"</span>: { <span class="k">"kind"</span>: <span class="s">"down"</span> | <span class="s">"up"</span> | <span class="s">"cert"</span> | <span class="s">"test"</span>, ... }
&nbsp;&nbsp;<span class="k">"channel"</span>: { <span class="k">"id"</span>: <span class="s">"&lt;uuid&gt;"</span>, <span class="k">"name"</span>: <span class="s">"On-call webhook"</span> },
&nbsp;&nbsp;<span class="k">"event"</span>: { <span class="k">"kind"</span>: <span class="s">"down"</span> | <span class="s">"up"</span> | <span class="s">"cert"</span> | <span class="s">"test"</span>, ... }
}</pre>
</div>
@ -642,19 +642,19 @@ Content-Type: application/json
<p><code>down</code> - fired on the first DOWN important beat for a region, and again every <code>resend_interval</code>th consecutive down if configured.</p>
<div class="cb"><div class="cb-header"><span class="cb-lang">json - event</span></div>
<pre>{
<span class="k">"kind"</span>: <span class="s">"down"</span>,
<span class="k">"monitor"</span>: {
<span class="k">"id"</span>: <span class="s">"abc123def456"</span>,
<span class="k">"name"</span>: <span class="s">"My API"</span>,
<span class="k">"url"</span>: <span class="s">"https://api.example.com/health"</span>,
<span class="k">"region"</span>: <span class="s">"us-west"</span> <span class="c">// always present - runners default to "default" if REGION env var is unset</span>
},
<span class="k">"ping"</span>: {
<span class="k">"status_code"</span>: <span class="n">503</span>,
<span class="k">"latency_ms"</span>: <span class="n">412</span>,
<span class="k">"error"</span>: <span class="n">null</span>,
<span class="k">"checked_at"</span>: <span class="s">"2026-04-08T14:23:00.000Z"</span>
}
&nbsp;&nbsp;<span class="k">"kind"</span>: <span class="s">"down"</span>,
&nbsp;&nbsp;<span class="k">"monitor"</span>: {
&nbsp;&nbsp;&nbsp;&nbsp;<span class="k">"id"</span>: <span class="s">"abc123def456"</span>,
&nbsp;&nbsp;&nbsp;&nbsp;<span class="k">"name"</span>: <span class="s">"My API"</span>,
&nbsp;&nbsp;&nbsp;&nbsp;<span class="k">"url"</span>: <span class="s">"https://api.example.com/health"</span>,
&nbsp;&nbsp;&nbsp;&nbsp;<span class="k">"region"</span>: <span class="s">"us-west"</span> <span class="c">// always present - runners default to "default" if REGION env var is unset</span>
&nbsp;&nbsp;},
&nbsp;&nbsp;<span class="k">"ping"</span>: {
&nbsp;&nbsp;&nbsp;&nbsp;<span class="k">"status_code"</span>: <span class="n">503</span>,
&nbsp;&nbsp;&nbsp;&nbsp;<span class="k">"latency_ms"</span>: <span class="n">412</span>,
&nbsp;&nbsp;&nbsp;&nbsp;<span class="k">"error"</span>: <span class="n">null</span>,
&nbsp;&nbsp;&nbsp;&nbsp;<span class="k">"checked_at"</span>: <span class="s">"2026-04-08T14:23:00.000Z"</span>
&nbsp;&nbsp;}
}</pre></div>
<p><code>up</code> - fired on recovery, only when <em>that same region</em> transitions back from DOWN. Same shape as <code>down</code>.</p>
@ -662,16 +662,16 @@ Content-Type: application/json
<p><code>cert</code> - fired once per renewal cycle when the TLS leaf cert drops at or below <code>cert_alert_days</code> for a region.</p>
<div class="cb"><div class="cb-header"><span class="cb-lang">json - event</span></div>
<pre>{
<span class="k">"kind"</span>: <span class="s">"cert"</span>,
<span class="k">"monitor"</span>: { <span class="k">"id"</span>: <span class="s">"…"</span>, <span class="k">"name"</span>: <span class="s">"…"</span>, <span class="k">"url"</span>: <span class="s">"…"</span>, <span class="k">"region"</span>: <span class="s">"us-west"</span> },
<span class="k">"days"</span>: <span class="n">9</span> <span class="c">// days until certificate expires</span>
&nbsp;&nbsp;<span class="k">"kind"</span>: <span class="s">"cert"</span>,
&nbsp;&nbsp;<span class="k">"monitor"</span>: { <span class="k">"id"</span>: <span class="s">"…"</span>, <span class="k">"name"</span>: <span class="s">"…"</span>, <span class="k">"url"</span>: <span class="s">"…"</span>, <span class="k">"region"</span>: <span class="s">"us-west"</span> },
&nbsp;&nbsp;<span class="k">"days"</span>: <span class="n">9</span> <span class="c">// days until certificate expires</span>
}</pre></div>
<p><code>test</code> - synthetic event from <code>POST /notifications/channels/:id/test</code>. The <code>monitor</code> object is a placeholder.</p>
<div class="cb"><div class="cb-header"><span class="cb-lang">json - event</span></div>
<pre>{
<span class="k">"kind"</span>: <span class="s">"test"</span>,
<span class="k">"monitor"</span>: { <span class="k">"id"</span>: <span class="s">"test"</span>, <span class="k">"name"</span>: <span class="s">"Test event"</span>, <span class="k">"url"</span>: <span class="s">"https://example.com"</span>, <span class="k">"region"</span>: <span class="s">""</span> }
&nbsp;&nbsp;<span class="k">"kind"</span>: <span class="s">"test"</span>,
&nbsp;&nbsp;<span class="k">"monitor"</span>: { <span class="k">"id"</span>: <span class="s">"test"</span>, <span class="k">"name"</span>: <span class="s">"Test event"</span>, <span class="k">"url"</span>: <span class="s">"https://example.com"</span>, <span class="k">"region"</span>: <span class="s">""</span> }
}</pre></div>
<h3>Verifying the signature</h3>
@ -680,8 +680,8 @@ Content-Type: application/json
<pre><span class="k">import</span> { createHmac, timingSafeEqual } <span class="k">from</span> <span class="s">"crypto"</span>;
<span class="k">function</span> verify(rawBody, headerSig, secret) {
<span class="k">const</span> expected = createHmac(<span class="s">"sha256"</span>, secret).update(rawBody).digest(<span class="s">"hex"</span>);
<span class="k">return</span> timingSafeEqual(Buffer.from(expected), Buffer.from(headerSig));
&nbsp;&nbsp;<span class="k">const</span> expected = createHmac(<span class="s">"sha256"</span>, secret).update(rawBody).digest(<span class="s">"hex"</span>);
&nbsp;&nbsp;<span class="k">return</span> timingSafeEqual(Buffer.from(expected), Buffer.from(headerSig));
}</pre>
</div>
<p>Always verify against the <em>raw</em> request body before parsing JSON.</p>

View File

@ -163,26 +163,26 @@
</head>
<body class="bg-[#0a0a0a] text-gray-100 font-sans antialiased grid-bg">
<!-- ─── DEV WARNING ─── -->
<div class="fixed top-0 left-0 right-0 z-[60] bg-yellow-500/10 border-b border-yellow-500/20 text-center py-1.5 px-4">
<p class="text-xs text-yellow-400">PingQL is in active development. Please don't rely on the service until this notice is removed.</p>
</div>
<!-- ─── HEADER ─── -->
<header class="fixed top-[34px] left-0 right-0 z-50 border-b border-border-subtle bg-[#0a0a0a]/70 backdrop-blur-md">
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<a href="/" class="font-mono text-lg font-bold tracking-tight group">Ping<span class="text-blue-400 transition-all group-hover:drop-shadow-[0_0_8px_rgba(59,130,246,0.4)]">QL</span></a>
<nav class="hidden md:flex items-center gap-7 text-sm text-gray-400">
<a href="/docs" class="hover:text-gray-200 transition-colors">Docs</a>
<a href="/privacy" class="hover:text-gray-200 transition-colors">Privacy</a>
<a href="#pricing" class="hover:text-gray-200 transition-colors">Pricing</a>
</nav>
<div class="flex items-center gap-3">
<a href="/dashboard" class="text-sm text-gray-400 hover:text-gray-200 transition-colors">Sign in</a>
<a href="/dashboard" class="text-sm text-white px-4 py-2 rounded-lg font-medium transition-all" style="background:#2563eb;box-shadow:0 1px 3px rgba(0,0,0,0.3),0 0 12px rgba(59,130,246,0.15)">Get started</a>
</div>
<!-- ─── TOP BAR (dev warning + header, flush) ─── -->
<div class="fixed top-0 left-0 right-0 z-50">
<div class="bg-yellow-500/10 backdrop-blur-md border-b border-yellow-500/20 text-center py-1.5 px-4">
<p class="text-xs text-yellow-400">PingQL is in active development. Please don't rely on the service until this notice is removed.</p>
</div>
</header>
<header class="border-b border-border-subtle bg-[#0a0a0a]/70 backdrop-blur-md">
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<a href="/" class="font-mono text-lg font-bold tracking-tight group">Ping<span class="text-blue-400 transition-all group-hover:drop-shadow-[0_0_8px_rgba(59,130,246,0.4)]">QL</span></a>
<nav class="hidden md:flex items-center gap-7 text-sm text-gray-400">
<a href="/docs" class="hover:text-gray-200 transition-colors">Docs</a>
<a href="/privacy" class="hover:text-gray-200 transition-colors">Privacy</a>
<a href="#pricing" class="hover:text-gray-200 transition-colors">Pricing</a>
</nav>
<div class="flex items-center gap-3">
<a href="/dashboard" class="text-sm text-gray-400 hover:text-gray-200 transition-colors">Sign in</a>
<a href="/dashboard" class="text-sm text-white px-4 py-2 rounded-lg font-medium transition-all" style="background:#2563eb;box-shadow:0 1px 3px rgba(0,0,0,0.3),0 0 12px rgba(59,130,246,0.15)">Get started</a>
</div>
</div>
</header>
</div>
<!-- ─── HERO ─── -->
<section class="relative min-h-screen flex items-center hero-glow pt-16">
@ -221,11 +221,11 @@
</div>
<div class="terminal-body">
<pre><span class="syn-brace">{</span>
<span class="syn-key">"$and"</span><span class="syn-brace">:</span> <span class="syn-brace">[</span>
<span class="syn-brace">{</span> <span class="syn-key">"status"</span><span class="syn-brace">:</span> <span class="syn-brace">{</span> <span class="syn-op">"$lt"</span><span class="syn-brace">:</span> <span class="syn-num">400</span> <span class="syn-brace">}</span> <span class="syn-brace">}</span>,
<span class="syn-brace">{</span> <span class="syn-op">"$json"</span><span class="syn-brace">:</span> <span class="syn-brace">{</span> <span class="syn-str">"$.db.status"</span><span class="syn-brace">:</span> <span class="syn-brace">{</span> <span class="syn-op">"$eq"</span><span class="syn-brace">:</span> <span class="syn-str">"ok"</span> <span class="syn-brace">}</span> <span class="syn-brace">}</span> <span class="syn-brace">}</span>,
<span class="syn-brace">{</span> <span class="syn-op">"$certExpiry"</span><span class="syn-brace">:</span> <span class="syn-brace">{</span> <span class="syn-op">"$gt"</span><span class="syn-brace">:</span> <span class="syn-num">14</span> <span class="syn-brace">}</span> <span class="syn-brace">}</span>
<span class="syn-brace">]</span>
&nbsp;&nbsp;<span class="syn-key">"$and"</span><span class="syn-brace">:</span> <span class="syn-brace">[</span>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="syn-brace">{</span> <span class="syn-key">"status"</span><span class="syn-brace">:</span> <span class="syn-brace">{</span> <span class="syn-op">"$lt"</span><span class="syn-brace">:</span> <span class="syn-num">400</span> <span class="syn-brace">}</span> <span class="syn-brace">}</span>,
&nbsp;&nbsp;&nbsp;&nbsp;<span class="syn-brace">{</span> <span class="syn-op">"$json"</span><span class="syn-brace">:</span> <span class="syn-brace">{</span> <span class="syn-str">"$.db.status"</span><span class="syn-brace">:</span> <span class="syn-brace">{</span> <span class="syn-op">"$eq"</span><span class="syn-brace">:</span> <span class="syn-str">"ok"</span> <span class="syn-brace">}</span> <span class="syn-brace">}</span> <span class="syn-brace">}</span>,
&nbsp;&nbsp;&nbsp;&nbsp;<span class="syn-brace">{</span> <span class="syn-op">"$certExpiry"</span><span class="syn-brace">:</span> <span class="syn-brace">{</span> <span class="syn-op">"$gt"</span><span class="syn-brace">:</span> <span class="syn-num">14</span> <span class="syn-brace">}</span> <span class="syn-brace">}</span>
&nbsp;&nbsp;<span class="syn-brace">]</span>
<span class="syn-brace">}</span></pre>
<div class="mt-4 pt-4 border-t border-border-subtle text-xs text-gray-500">
<span class="syn-comment">// status &lt; 400 AND $.db.status = "ok" AND $certExpiry &gt; 14 days</span>
@ -562,20 +562,20 @@
</div>
<div class="terminal-body text-xs sm:text-[13px]">
<pre><span class="text-gray-500">$</span> <span class="text-white">curl</span> -X POST https://pingql.com/api/pages \
-H <span class="syn-str">"X-Key: abcd-1234-efgh-5678"</span> \
-d <span class="syn-str">'{
"slug": "my-app",
"title": "My App Status",
"monitors": [
{ "monitor_id": "a1b2c3d4e5f67890" }
]
}'</span>
&nbsp;&nbsp;-H <span class="syn-str">"X-Key: abcd-1234-efgh-5678"</span> \
&nbsp;&nbsp;-d <span class="syn-str">'{
&nbsp;&nbsp;&nbsp;&nbsp;"slug": "my-app",
&nbsp;&nbsp;&nbsp;&nbsp;"title": "My App Status",
&nbsp;&nbsp;&nbsp;&nbsp;"monitors": [
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{ "monitor_id": "a1b2c3d4e5f67890" }
&nbsp;&nbsp;&nbsp;&nbsp;]
&nbsp;&nbsp;}'</span>
<span class="syn-brace">{</span>
<span class="syn-key">"id"</span>: <span class="syn-str">"f8c1a2b3-..."</span>,
<span class="syn-key">"slug"</span>: <span class="syn-str">"my-app"</span>,
<span class="syn-key">"title"</span>: <span class="syn-str">"My App Status"</span>,
<span class="syn-key">"theme"</span>: <span class="syn-str">"auto"</span>
&nbsp;&nbsp;<span class="syn-key">"id"</span>: <span class="syn-str">"f8c1a2b3-..."</span>,
&nbsp;&nbsp;<span class="syn-key">"slug"</span>: <span class="syn-str">"my-app"</span>,
&nbsp;&nbsp;<span class="syn-key">"title"</span>: <span class="syn-str">"My App Status"</span>,
&nbsp;&nbsp;<span class="syn-key">"theme"</span>: <span class="syn-str">"auto"</span>
<span class="syn-brace">}</span></pre>
</div>
</div>
@ -590,22 +590,22 @@
</div>
<div class="terminal-body text-xs sm:text-[13px]">
<pre><span class="text-gray-500">$</span> <span class="text-white">curl</span> -X POST https://pingql.com/api/monitors \
-H <span class="syn-str">"X-Key: abcd-1234-efgh-5678"</span> \
-d <span class="syn-str">'{
"name": "Production API",
"url": "https://api.example.com/health",
"interval_s": 60,
"query": {
"status": { "$lt": 400 },
"$json": { "$.ok": { "$eq": true } }
}
}'</span>
&nbsp;&nbsp;-H <span class="syn-str">"X-Key: abcd-1234-efgh-5678"</span> \
&nbsp;&nbsp;-d <span class="syn-str">'{
&nbsp;&nbsp;&nbsp;&nbsp;"name": "Production API",
&nbsp;&nbsp;&nbsp;&nbsp;"url": "https://api.example.com/health",
&nbsp;&nbsp;&nbsp;&nbsp;"interval_s": 60,
&nbsp;&nbsp;&nbsp;&nbsp;"query": {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;"status": { "$lt": 400 },
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;"$json": { "$.ok": { "$eq": true } }
&nbsp;&nbsp;&nbsp;&nbsp;}
&nbsp;&nbsp;}'</span>
<span class="syn-brace">{</span>
<span class="syn-key">"id"</span>: <span class="syn-str">"a1b2c3d4e5f67890"</span>,
<span class="syn-key">"name"</span>: <span class="syn-str">"Production API"</span>,
<span class="syn-key">"url"</span>: <span class="syn-str">"https://api.example.com/health"</span>,
<span class="syn-key">"enabled"</span>: <span class="syn-num">true</span>
&nbsp;&nbsp;<span class="syn-key">"id"</span>: <span class="syn-str">"a1b2c3d4e5f67890"</span>,
&nbsp;&nbsp;<span class="syn-key">"name"</span>: <span class="syn-str">"Production API"</span>,
&nbsp;&nbsp;<span class="syn-key">"url"</span>: <span class="syn-str">"https://api.example.com/health"</span>,
&nbsp;&nbsp;<span class="syn-key">"enabled"</span>: <span class="syn-num">true</span>
<span class="syn-brace">}</span></pre>
</div>
</div>

View File

@ -7,7 +7,7 @@
<h2 class="text-lg font-semibold text-gray-200 mt-2">Create Monitor</h2>
</div>
<%~ 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 }) %>
</main>
<script>

View File

@ -9,17 +9,19 @@
</div>
<p class="text-sm text-gray-500 leading-relaxed">
Channels are dispatched on status transitions (the "important" beats). Webhooks POST a JSON payload to your URL.
Channels are dispatched on status transitions (the "important" beats) for every monitor you attach below. Webhooks POST a JSON payload to your URL.
More providers (Discord, Slack, Email, Telegram) will land here as drop-ins.
</p>
<% const monitors = it.monitors || []; %>
<% if (!it.channels || it.channels.length === 0) { %>
<section class="card-static p-6 text-sm text-gray-500">
No channels yet. Create one below to start receiving alerts.
</section>
<% } else { %>
<% it.channels.forEach(function(c) { %>
<section class="card-static p-6">
<section class="card-static p-6 space-y-4">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
@ -42,6 +44,26 @@
</form>
</div>
</div>
<form action="/dashboard/notifications/<%= c.id %>/edit" method="POST" class="space-y-3 pt-4 border-t border-border-subtle">
<div>
<label class="block text-xs text-gray-500 mb-2">Attached monitors</label>
<% if (monitors.length === 0) { %>
<p class="text-xs text-gray-600">No monitors yet. <a href="/dashboard/monitors/new" class="text-blue-400 hover:text-blue-300">Create one</a> to attach it to this channel.</p>
<% } else { %>
<% const attached = (c.monitor_ids || []); %>
<div class="flex flex-wrap gap-2">
<% monitors.forEach(function(m) { %>
<label class="flex items-center gap-2 bg-gray-900 border border-gray-800 hover:border-gray-600 rounded-lg px-3 py-2 cursor-pointer transition-colors">
<input type="checkbox" name="monitor_ids" value="<%= m.id %>" class="accent-blue-500" <%= attached.includes(m.id) ? 'checked' : '' %>>
<span class="text-sm text-gray-300 truncate max-w-[16rem]"><%= m.name %></span>
</label>
<% }) %>
</div>
<% } %>
</div>
<button type="submit" class="px-4 py-1.5 rounded-lg border border-border-subtle text-gray-300 hover:text-white hover:border-gray-600 text-xs transition-colors">Save attachments</button>
</form>
</section>
<% }) %>
<% } %>
@ -71,6 +93,21 @@
<input name="secret" type="text" placeholder="Used to sign payloads as X-PingQL-Signature"
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 font-mono text-sm">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1.5">Monitors <span class="text-gray-600">(optional - attach now or later)</span></label>
<% if (monitors.length === 0) { %>
<p class="text-xs text-gray-600">No monitors yet. <a href="/dashboard/monitors/new" class="text-blue-400 hover:text-blue-300">Create one</a> first, then attach it here.</p>
<% } else { %>
<div class="flex flex-wrap gap-2">
<% monitors.forEach(function(m) { %>
<label class="flex items-center gap-2 bg-gray-900 border border-gray-800 hover:border-gray-600 rounded-lg px-3 py-2 cursor-pointer transition-colors">
<input type="checkbox" name="monitor_ids" value="<%= m.id %>" class="accent-blue-500">
<span class="text-sm text-gray-300 truncate max-w-[16rem]"><%= m.name %></span>
</label>
<% }) %>
</div>
<% } %>
</div>
<button type="submit" class="btn-primary px-6 py-2.5 text-sm">Create channel</button>
</form>
</section>

View File

@ -49,7 +49,6 @@
cert_alert_days: Number(document.getElementById(prefix + 'cert-alert-days').value),
max_redirects: Number(document.getElementById(prefix + 'max-redirects-follow').value),
};
body.channel_ids = [...document.querySelectorAll('.' + prefix + 'channel-check:checked')].map(el => el.value);
if (Object.keys(headers).length) body.request_headers = headers;
else body.request_headers = null;
const rb = document.getElementById(prefix + 'request-body').value.trim();

View File

@ -146,27 +146,6 @@
</select>
</div>
<%
const channels = it.channels || [];
const attached = (monitor.channel_ids && monitor.channel_ids.length) ? monitor.channel_ids : [];
%>
<div>
<label class="block text-sm text-gray-400 mb-1.5">Notification channels <span class="text-gray-600">(optional)</span></label>
<% if (channels.length === 0) { %>
<p class="text-xs text-gray-600">No channels yet. <a href="/dashboard/notifications" class="text-blue-400 hover:text-blue-300">Create one</a> to get alerted on transitions.</p>
<% } else { %>
<div class="flex flex-wrap gap-2">
<% channels.forEach(function(c) { %>
<label class="flex items-center gap-2 <%= bg %> border <%= border %> hover:border-gray-600 rounded-lg px-3 py-2 cursor-pointer transition-colors">
<input type="checkbox" name="channel_ids" value="<%= c.id %>" class="<%= prefix %>channel-check accent-blue-500" <%= attached.includes(c.id) ? 'checked' : '' %>>
<span class="text-sm text-gray-300"><%= c.name %></span>
<span class="text-xs text-gray-600 font-mono"><%= c.kind %></span>
</label>
<% }) %>
</div>
<% } %>
</div>
<%
// Default to all regions if none selected
const selectedRegions = (monitor.regions && monitor.regions.length) ? monitor.regions : regions.map(r => r[0]);