pingql/apps/api/src/routes/monitors.ts

247 lines
12 KiB
TypeScript

import { Elysia, t } from "elysia";
import { requireAuth } from "./auth";
import sql from "../db";
import { validateMonitorUrl } from "../utils/ssrf";
import { getPlanLimits } from "../../../shared/plans";
import { invalidateMonitorList } from "../cache/monitor-list";
const MonitorBody = t.Object({
name: t.String({ maxLength: 200, description: "Human-readable name" }),
url: t.String({ format: "uri", maxLength: 2048, description: "URL to check" }),
method: t.Optional(t.String({ default: "GET", description: "HTTP method: GET, POST, PUT, PATCH, DELETE, HEAD" })),
request_headers: t.Optional(t.Any({ description: "Request headers as key-value object" })),
request_body: t.Optional(t.Nullable(t.String({ maxLength: 65536, description: "Request body for POST/PUT/PATCH (max 64KB)" }))),
timeout_ms: t.Optional(t.Number({ minimum: 1000, maximum: 60000, default: 10000, description: "Request timeout in ms" })),
interval_s: t.Optional(t.Number({ minimum: 2, default: 30, description: "Check interval in seconds (minimum 2)" })),
max_retries: t.Optional(t.Number({ minimum: 0, maximum: 10, default: 0, description: "Retry a failing check up to N times before declaring DOWN" })),
retry_interval_s: t.Optional(t.Number({ minimum: 1, maximum: 600, default: 30, description: "Seconds between retries" })),
resend_interval: t.Optional(t.Number({ minimum: 0, maximum: 1000, default: 0, description: "Re-alert every Nth consecutive down beat. 0 = never resend." })),
cert_alert_days: t.Optional(t.Number({ minimum: 0, maximum: 365, default: 0, description: "Alert when TLS cert is within N days of expiry. 0 disables (default)." })),
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." })),
});
async function replaceMonitorTags(monitorId: string, tags: string[]) {
await sql`DELETE FROM monitor_tags WHERE monitor_id = ${monitorId}`;
if (tags.length === 0) return;
const unique = Array.from(new Set(tags.map((t) => t.trim()).filter(Boolean)));
if (unique.length === 0) return;
const rows = unique.map((tag) => ({ monitor_id: monitorId, tag }));
await sql`INSERT INTO monitor_tags ${sql(rows, "monitor_id", "tag")}`;
}
async function replaceMonitorChannels(monitorId: string, accountId: string, channelIds: string[]) {
await sql`DELETE FROM monitor_notifications WHERE monitor_id = ${monitorId}`;
if (channelIds.length === 0) return;
// Only attach channels that belong to the same account. Cast to uuid[] so the
// ANY() comparison against the uuid id column type-checks.
const owned = await sql<{ id: string }[]>`
SELECT id FROM notification_channels
WHERE account_id = ${accountId}
AND id = ANY(${sql.array(channelIds)}::uuid[])
`;
if (owned.length === 0) return;
const rows = owned.map((o) => ({ monitor_id: monitorId, channel_id: o.id }));
await sql`INSERT INTO monitor_notifications ${sql(rows, "monitor_id", "channel_id")}`;
}
export const monitors = new Elysia({ prefix: "/monitors" })
.use(requireAuth)
.get("/", async ({ accountId, query }) => {
const tag = (query as any)?.tag;
if (tag) {
return sql`
SELECT m.* FROM monitors m
JOIN monitor_tags mt ON mt.monitor_id = m.id
WHERE m.account_id = ${accountId} AND mt.tag = ${tag}
ORDER BY m.created_at DESC
`;
}
return sql`SELECT * FROM monitors WHERE account_id = ${accountId} ORDER BY created_at DESC`;
}, { detail: { summary: "List monitors (optional ?tag= filter)", tags: ["monitors"] } })
.post("/", async ({ accountId, plan, body, set }) => {
const limits = getPlanLimits(plan);
const [{ count }] = await sql`SELECT COUNT(*)::int as count FROM monitors WHERE account_id = ${accountId}`;
if (count >= limits.maxMonitors) {
set.status = 403;
return { error: `Plan limit reached: ${limits.maxMonitors} monitors (${plan}). Upgrade to create more.` };
}
const interval = body.interval_s ?? limits.minIntervalS;
if (interval < limits.minIntervalS) {
set.status = 400;
return { error: `Minimum interval for ${plan} plan is ${limits.minIntervalS}s` };
}
const retryGap = body.retry_interval_s ?? Math.max(30, limits.minIntervalS);
if (retryGap < limits.minIntervalS) {
set.status = 400;
return { error: `Retry interval for ${plan} plan must be at least ${limits.minIntervalS}s` };
}
const regions = body.regions ?? [];
if (regions.length > limits.maxRegions) {
set.status = 400;
return { error: `Free plan allows ${limits.maxRegions} region per monitor. Upgrade to use multi-region.` };
}
const ssrfError = await validateMonitorUrl(body.url);
if (ssrfError) { set.status = 400; return { error: ssrfError }; }
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, query, regions)
VALUES (
${accountId}, ${body.name}, ${body.url},
${(body.method ?? 'GET').toUpperCase()},
${body.request_headers ? sql.json(body.request_headers) : null},
${body.request_body ?? null},
${body.timeout_ms ?? 10000},
${interval},
${body.max_retries ?? 0},
${retryGap},
${body.resend_interval ?? 0},
${body.cert_alert_days ?? 0},
${body.query ? sql.json(body.query) : null},
${sql.array(regions)}
)
RETURNING *
`;
if (body.channel_ids) await replaceMonitorChannels(monitor.id, accountId, body.channel_ids);
if (body.tags) await replaceMonitorTags(monitor.id, body.tags);
invalidateMonitorList();
return monitor;
}, { body: MonitorBody, detail: { summary: "Create monitor", tags: ["monitors"] } })
.get("/:id", async ({ accountId, params, set }) => {
const [monitor] = await sql`
SELECT * FROM monitors WHERE id = ${params.id} AND account_id = ${accountId}
`;
if (!monitor) { set.status = 404; return { error: "Not found" }; }
const results = await sql`
SELECT * FROM pings WHERE monitor_id = ${params.id}
ORDER BY checked_at DESC LIMIT 100
`;
const channels = await sql<{ channel_id: string }[]>`
SELECT channel_id FROM monitor_notifications WHERE monitor_id = ${params.id}
`;
const tagRows = await sql<{ tag: string }[]>`
SELECT tag FROM monitor_tags WHERE monitor_id = ${params.id} ORDER BY tag
`;
return { ...monitor, results, channel_ids: channels.map((c) => c.channel_id), tags: tagRows.map((t) => t.tag) };
}, { detail: { summary: "Get monitor with results", tags: ["monitors"] } })
.patch("/:id", async ({ accountId, plan, params, body, set }) => {
const limits = getPlanLimits(plan);
if (body.interval_s != null && body.interval_s < limits.minIntervalS) {
set.status = 400;
return { error: `Minimum interval for ${plan} plan is ${limits.minIntervalS}s` };
}
if (body.retry_interval_s != null && body.retry_interval_s < limits.minIntervalS) {
set.status = 400;
return { error: `Retry interval for ${plan} plan must be at least ${limits.minIntervalS}s` };
}
if (body.regions && body.regions.length > limits.maxRegions) {
set.status = 400;
return { error: `Free plan allows ${limits.maxRegions} region per monitor. Upgrade to use multi-region.` };
}
if (body.url) {
const ssrfError = await validateMonitorUrl(body.url);
if (ssrfError) { set.status = 400; return { error: ssrfError }; }
}
const [monitor] = await sql`
UPDATE monitors SET
name = COALESCE(${body.name ?? null}, name),
url = COALESCE(${body.url ?? null}, url),
method = COALESCE(${body.method ? body.method.toUpperCase() : null}, method),
request_headers = COALESCE(${body.request_headers ? sql.json(body.request_headers) : null}, request_headers),
request_body = COALESCE(${body.request_body ?? null}, request_body),
timeout_ms = COALESCE(${body.timeout_ms ?? null}, timeout_ms),
interval_s = COALESCE(${body.interval_s ?? null}, interval_s),
max_retries = COALESCE(${body.max_retries ?? null}, max_retries),
retry_interval_s = COALESCE(${body.retry_interval_s ?? null}, retry_interval_s),
resend_interval = COALESCE(${body.resend_interval ?? null}, resend_interval),
cert_alert_days = COALESCE(${body.cert_alert_days ?? null}, cert_alert_days),
query = COALESCE(${body.query ? sql.json(body.query) : null}, query),
regions = COALESCE(${body.regions ? sql.array(body.regions) : null}, regions)
WHERE id = ${params.id} AND account_id = ${accountId}
RETURNING *
`;
if (!monitor) { set.status = 404; return { error: "Not found" }; }
if (body.channel_ids) await replaceMonitorChannels(monitor.id, accountId, body.channel_ids);
if (body.tags) await replaceMonitorTags(monitor.id, body.tags);
invalidateMonitorList();
return monitor;
}, { body: t.Partial(MonitorBody), detail: { summary: "Update monitor", tags: ["monitors"] } })
.delete("/:id", async ({ accountId, params, set }) => {
const [deleted] = await sql`
DELETE FROM monitors WHERE id = ${params.id} AND account_id = ${accountId} RETURNING id
`;
if (!deleted) { set.status = 404; return { error: "Not found" }; }
invalidateMonitorList();
return { deleted: true };
}, { detail: { summary: "Delete monitor", tags: ["monitors"] } })
.post("/:id/toggle", async ({ accountId, params, set }) => {
const [monitor] = await sql`
UPDATE monitors SET enabled = NOT enabled
WHERE id = ${params.id} AND account_id = ${accountId}
RETURNING id, enabled
`;
if (!monitor) { set.status = 404; return { error: "Not found" }; }
invalidateMonitorList();
return monitor;
}, { detail: { summary: "Toggle monitor on/off", tags: ["monitors"] } })
.get("/:id/pings", async ({ accountId, params, query, set }) => {
const [monitor] = await sql`
SELECT id FROM monitors WHERE id = ${params.id} AND account_id = ${accountId}
`;
if (!monitor) { set.status = 404; return { error: "Not found" }; }
const limit = Math.min(Number(query.limit) || 100, 1000);
const filter = query.filter; // "up", "down", "events", or undefined/all
const before = query.before; // cursor: ISO timestamp for pagination
const cursorClause = before ? sql`AND checked_at < ${new Date(before)}` : sql``;
if (filter === "up") {
return sql`
SELECT * FROM pings WHERE monitor_id = ${params.id} AND up = true ${cursorClause}
ORDER BY checked_at DESC LIMIT ${limit}
`;
}
if (filter === "down") {
return sql`
SELECT * FROM pings WHERE monitor_id = ${params.id} AND up = false ${cursorClause}
ORDER BY checked_at DESC LIMIT ${limit}
`;
}
if (filter === "events") {
return sql`
SELECT * FROM (
SELECT *, LAG(up) OVER (ORDER BY checked_at) AS prev_up
FROM pings WHERE monitor_id = ${params.id}
) t
WHERE prev_up IS NULL OR up != prev_up
${before ? sql`AND checked_at < ${new Date(before)}` : sql``}
ORDER BY checked_at DESC LIMIT ${limit}
`;
}
return sql`
SELECT * FROM pings WHERE monitor_id = ${params.id} ${cursorClause}
ORDER BY checked_at DESC LIMIT ${limit}
`;
}, { detail: { summary: "Get ping history", tags: ["monitors"] } });