fix various issues
This commit is contained in:
parent
114f35cb9b
commit
d9d38c6fea
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"] } })
|
||||
|
||||
|
|
|
|||
|
|
@ -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"] } })
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">"<uuid>"</span>], <span class="c">// optional - notification channels to attach</span>
|
||||
<span class="k">"query"</span>: { ... } <span class="c">// optional - see Query Language below</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">"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">"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>
|
||||
<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">"monitor_ids"</span>: [<span class="s">"<monitor-id>"</span>], <span class="c">// optional - monitors to dispatch for</span>
|
||||
<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 <key>
|
||||
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> }
|
||||
]
|
||||
<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> }
|
||||
]
|
||||
}</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>
|
||||
}
|
||||
<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>
|
||||
}
|
||||
}</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>
|
||||
<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>
|
||||
}</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> } } }
|
||||
] }
|
||||
]
|
||||
<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> } } }
|
||||
] }
|
||||
]
|
||||
}</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> } } }
|
||||
]
|
||||
<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> } } }
|
||||
]
|
||||
}</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> } } }
|
||||
]
|
||||
<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> } } }
|
||||
]
|
||||
} }</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> } } }
|
||||
]
|
||||
<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> } } }
|
||||
]
|
||||
}</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> } } }
|
||||
] }
|
||||
]
|
||||
<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> } } }
|
||||
] }
|
||||
]
|
||||
}</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">"<uuid>"</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>, ... }
|
||||
<span class="k">"channel"</span>: { <span class="k">"id"</span>: <span class="s">"<uuid>"</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>, ... }
|
||||
}</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>
|
||||
}
|
||||
<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>
|
||||
}
|
||||
}</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>
|
||||
<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>
|
||||
}</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> }
|
||||
<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> }
|
||||
}</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));
|
||||
<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));
|
||||
}</pre>
|
||||
</div>
|
||||
<p>Always verify against the <em>raw</em> request body before parsing JSON.</p>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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>
|
||||
<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 < 400 AND $.db.status = "ok" AND $certExpiry > 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>
|
||||
-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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
-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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<span class="syn-brace">}</span></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue