149 lines
6.5 KiB
TypeScript
149 lines
6.5 KiB
TypeScript
import { Elysia, t } from "elysia";
|
|
import { requireAuth } from "./auth";
|
|
import sql from "../db";
|
|
import { validateMonitorUrl } from "../utils/ssrf";
|
|
import { getPlanLimits } from "../utils/plans";
|
|
|
|
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)" })),
|
|
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." })),
|
|
});
|
|
|
|
export const monitors = new Elysia({ prefix: "/monitors" })
|
|
.use(requireAuth)
|
|
|
|
// List monitors
|
|
.get("/", async ({ accountId }) => {
|
|
return sql`SELECT * FROM monitors WHERE account_id = ${accountId} ORDER BY created_at DESC`;
|
|
}, { detail: { summary: "List monitors", tags: ["monitors"] } })
|
|
|
|
// Create monitor
|
|
.post("/", async ({ accountId, plan, body, set }) => {
|
|
const limits = getPlanLimits(plan);
|
|
|
|
// Enforce monitor count limit
|
|
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.` };
|
|
}
|
|
|
|
// Enforce minimum interval for plan
|
|
const interval = body.interval_s ?? 30;
|
|
if (interval < limits.minIntervalS) {
|
|
set.status = 400;
|
|
return { error: `Minimum interval for ${plan} plan is ${limits.minIntervalS}s` };
|
|
}
|
|
|
|
// SSRF protection
|
|
const ssrfError = await validateMonitorUrl(body.url);
|
|
if (ssrfError) { set.status = 400; return { error: ssrfError }; }
|
|
|
|
const regions = body.regions ?? [];
|
|
const [monitor] = await sql`
|
|
INSERT INTO monitors (account_id, name, url, method, request_headers, request_body, timeout_ms, interval_s, 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.query ? sql.json(body.query) : null},
|
|
${sql.array(regions)}
|
|
)
|
|
RETURNING *
|
|
`;
|
|
return monitor;
|
|
}, { body: MonitorBody, detail: { summary: "Create monitor", tags: ["monitors"] } })
|
|
|
|
// Get monitor + recent status
|
|
.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
|
|
`;
|
|
return { ...monitor, results };
|
|
}, { detail: { summary: "Get monitor with results", tags: ["monitors"] } })
|
|
|
|
// Update monitor
|
|
.patch("/:id", async ({ accountId, plan, params, body, set }) => {
|
|
// Enforce minimum interval for plan
|
|
if (body.interval_s != null) {
|
|
const limits = getPlanLimits(plan);
|
|
if (body.interval_s < limits.minIntervalS) {
|
|
set.status = 400;
|
|
return { error: `Minimum interval for ${plan} plan is ${limits.minIntervalS}s` };
|
|
}
|
|
}
|
|
|
|
// SSRF protection on URL change
|
|
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),
|
|
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" }; }
|
|
return monitor;
|
|
}, { body: t.Partial(MonitorBody), detail: { summary: "Update monitor", tags: ["monitors"] } })
|
|
|
|
// Delete monitor
|
|
.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" }; }
|
|
return { deleted: true };
|
|
}, { detail: { summary: "Delete monitor", tags: ["monitors"] } })
|
|
|
|
// Toggle enabled
|
|
.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" }; }
|
|
return monitor;
|
|
}, { detail: { summary: "Toggle monitor on/off", tags: ["monitors"] } })
|
|
|
|
// Check history
|
|
.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);
|
|
return sql`
|
|
SELECT * FROM pings
|
|
WHERE monitor_id = ${params.id}
|
|
ORDER BY checked_at DESC LIMIT ${limit}
|
|
`;
|
|
}, { detail: { summary: "Get ping history", tags: ["monitors"] } });
|