refactor tier 3
This commit is contained in:
@@ -4,6 +4,9 @@ import { monitors } from "./routes/monitors";
|
||||
import { account } from "./routes/auth";
|
||||
import { internal } from "./routes/internal";
|
||||
import { channels } from "./routes/channels";
|
||||
import { statusPages } from "./routes/status_pages";
|
||||
import { incidents } from "./routes/incidents";
|
||||
import { startRollupJob } from "./jobs/rollup";
|
||||
import { migrate } from "./db";
|
||||
import { SECURITY_HEADERS } from "../../shared/auth";
|
||||
|
||||
@@ -17,6 +20,7 @@ process.on("uncaughtException", (err) => {
|
||||
});
|
||||
|
||||
await migrate();
|
||||
await startRollupJob();
|
||||
|
||||
const elysia = new Elysia()
|
||||
.get("/", () => ({
|
||||
@@ -27,6 +31,8 @@ const elysia = new Elysia()
|
||||
.use(account)
|
||||
.use(monitors)
|
||||
.use(channels)
|
||||
.use(statusPages)
|
||||
.use(incidents)
|
||||
.use(ingest)
|
||||
.use(internal);
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import sql from "../db";
|
||||
|
||||
// Aggregates raw pings into monitor_uptime_rollup so status pages and dashboard
|
||||
// widgets can compute uptime % over arbitrary windows without ever scanning the
|
||||
// pings table at read time. Three resolutions: hourly, daily, weekly.
|
||||
//
|
||||
// Each pass aggregates the *current* bucket only. The query is bounded by the
|
||||
// bucket size, not the table size, so it's cheap regardless of history depth.
|
||||
|
||||
type BucketType = "hourly" | "daily" | "weekly";
|
||||
|
||||
const BUCKET_TRUNC: Record<BucketType, string> = {
|
||||
hourly: "hour",
|
||||
daily: "day",
|
||||
weekly: "week",
|
||||
};
|
||||
|
||||
async function rollupCurrent(bucket: BucketType): Promise<number> {
|
||||
const trunc = BUCKET_TRUNC[bucket];
|
||||
// Aggregate the bucket containing now(). ON CONFLICT updates if the row exists,
|
||||
// so this is safe to run repeatedly during the bucket's lifetime.
|
||||
const result = await sql`
|
||||
INSERT INTO monitor_uptime_rollup (monitor_id, region, bucket_type, bucket_start, total, up_count, avg_latency)
|
||||
SELECT
|
||||
monitor_id,
|
||||
COALESCE(region, 'default') AS region,
|
||||
${bucket} AS bucket_type,
|
||||
date_trunc(${trunc}, checked_at) AS bucket_start,
|
||||
count(*)::int AS total,
|
||||
count(*) FILTER (WHERE up)::int AS up_count,
|
||||
avg(latency_ms)::real AS avg_latency
|
||||
FROM pings
|
||||
WHERE checked_at >= date_trunc(${trunc}, now())
|
||||
GROUP BY monitor_id, COALESCE(region, 'default'), date_trunc(${trunc}, checked_at)
|
||||
ON CONFLICT (monitor_id, region, bucket_type, bucket_start) DO UPDATE SET
|
||||
total = EXCLUDED.total,
|
||||
up_count = EXCLUDED.up_count,
|
||||
avg_latency = EXCLUDED.avg_latency
|
||||
`;
|
||||
return result.count ?? 0;
|
||||
}
|
||||
|
||||
// Walk back N units and aggregate any buckets that don't exist yet. Used at
|
||||
// startup so a freshly-deployed system has historical data immediately.
|
||||
async function backfillRecent(bucket: BucketType, units: number): Promise<number> {
|
||||
const trunc = BUCKET_TRUNC[bucket];
|
||||
// Build the interval string entirely in JS so postgres.js binds a single text
|
||||
// parameter. Avoids the int || text type-mismatch trap inside SQL.
|
||||
const intervalLiteral = `${units} ${trunc}s`;
|
||||
const result = await sql`
|
||||
INSERT INTO monitor_uptime_rollup (monitor_id, region, bucket_type, bucket_start, total, up_count, avg_latency)
|
||||
SELECT
|
||||
monitor_id,
|
||||
COALESCE(region, 'default') AS region,
|
||||
${bucket} AS bucket_type,
|
||||
date_trunc(${trunc}, checked_at) AS bucket_start,
|
||||
count(*)::int AS total,
|
||||
count(*) FILTER (WHERE up)::int AS up_count,
|
||||
avg(latency_ms)::real AS avg_latency
|
||||
FROM pings
|
||||
WHERE checked_at >= date_trunc(${trunc}, now()) - ${intervalLiteral}::interval
|
||||
GROUP BY monitor_id, COALESCE(region, 'default'), date_trunc(${trunc}, checked_at)
|
||||
ON CONFLICT (monitor_id, region, bucket_type, bucket_start) DO NOTHING
|
||||
`;
|
||||
return result.count ?? 0;
|
||||
}
|
||||
|
||||
let started = false;
|
||||
|
||||
export async function startRollupJob() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
|
||||
// Startup backfill: gives existing accounts immediate history without waiting
|
||||
// for the periodic timers to wander backwards. Cheap because pings is indexed
|
||||
// on checked_at and the units are bounded.
|
||||
try {
|
||||
const [h, d, w] = await Promise.all([
|
||||
backfillRecent("hourly", 48), // 48h of hourly buckets
|
||||
backfillRecent("daily", 90), // 90 days of daily buckets
|
||||
backfillRecent("weekly", 26), // 26 weeks of weekly buckets
|
||||
]);
|
||||
console.log(`[rollup] backfilled rows: hourly=${h} daily=${d} weekly=${w}`);
|
||||
} catch (e) {
|
||||
console.warn("[rollup] backfill failed:", e);
|
||||
}
|
||||
|
||||
// Periodic refreshes for the *current* bucket of each resolution.
|
||||
setInterval(() => { rollupCurrent("hourly").catch((e) => console.warn("[rollup] hourly failed:", e)); }, 5 * 60 * 1000);
|
||||
setInterval(() => { rollupCurrent("daily").catch((e) => console.warn("[rollup] daily failed:", e)); }, 30 * 60 * 1000);
|
||||
setInterval(() => { rollupCurrent("weekly").catch((e) => console.warn("[rollup] weekly failed:", e)); }, 6 * 60 * 60 * 1000);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { requireAuth } from "./auth";
|
||||
import sql from "../db";
|
||||
|
||||
const Status = t.Union([t.Literal("investigating"), t.Literal("identified"), t.Literal("monitoring"), t.Literal("resolved")]);
|
||||
const Severity = t.Union([t.Literal("minor"), t.Literal("major"), t.Literal("critical")]);
|
||||
|
||||
const IncidentBody = t.Object({
|
||||
title: t.String({ minLength: 1, maxLength: 200 }),
|
||||
status: Status,
|
||||
severity: t.Optional(Severity),
|
||||
pinned: t.Optional(t.Boolean()),
|
||||
monitor_ids: t.Optional(t.Array(t.String())),
|
||||
status_page_ids: t.Optional(t.Array(t.String())),
|
||||
initial_update: t.Optional(t.Object({
|
||||
body: t.String({ minLength: 1, maxLength: 10_000 }),
|
||||
})),
|
||||
});
|
||||
|
||||
const IncidentUpdateBody = t.Object({
|
||||
status: Status,
|
||||
body: t.String({ minLength: 1, maxLength: 10_000 }),
|
||||
});
|
||||
|
||||
// HTML escape every byte first, then walk a tiny markdown subset and produce
|
||||
// safe HTML. Anything we didn't explicitly enable stays escaped. Output is
|
||||
// stored in incident_updates.body_html and rendered without further processing.
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function renderMarkdown(src: string): string {
|
||||
let out = escapeHtml(src.trim());
|
||||
// Inline code first so we don't expand markdown inside it.
|
||||
const codeStash: string[] = [];
|
||||
out = out.replace(/`([^`\n]+?)`/g, (_m, code) => {
|
||||
codeStash.push(code);
|
||||
return `\u0000${codeStash.length - 1}\u0000`;
|
||||
});
|
||||
// Links: [text](http(s)://...)
|
||||
out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_m, text, url) =>
|
||||
`<a href="${url}" rel="noopener nofollow" target="_blank">${text}</a>`,
|
||||
);
|
||||
// Bold then italic.
|
||||
out = out.replace(/\*\*([^*\n]+?)\*\*/g, "<strong>$1</strong>");
|
||||
out = out.replace(/\*([^*\n]+?)\*/g, "<em>$1</em>");
|
||||
// Restore inline code as <code>.
|
||||
out = out.replace(/\u0000(\d+)\u0000/g, (_m, i) => `<code>${codeStash[Number(i)]}</code>`);
|
||||
// Paragraphs: split on blank lines, single newlines become <br>.
|
||||
const paras = out.split(/\n{2,}/).map((p) => `<p>${p.replace(/\n/g, "<br>")}</p>`);
|
||||
return paras.join("");
|
||||
}
|
||||
|
||||
async function attachJoins(incidentId: string, accountId: string, monitorIds: string[] | undefined, pageIds: string[] | undefined) {
|
||||
if (monitorIds !== undefined) {
|
||||
await sql`DELETE FROM incident_monitors WHERE incident_id = ${incidentId}`;
|
||||
if (monitorIds.length > 0) {
|
||||
const owned = await sql<{ id: string }[]>`
|
||||
SELECT id FROM monitors
|
||||
WHERE account_id = ${accountId} AND id = ANY(${sql.array(monitorIds)}::text[])
|
||||
`;
|
||||
if (owned.length > 0) {
|
||||
const rows = owned.map((o) => ({ incident_id: incidentId, monitor_id: o.id }));
|
||||
await sql`INSERT INTO incident_monitors ${sql(rows, "incident_id", "monitor_id")}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pageIds !== undefined) {
|
||||
await sql`DELETE FROM incident_status_pages WHERE incident_id = ${incidentId}`;
|
||||
if (pageIds.length > 0) {
|
||||
const owned = await sql<{ id: string }[]>`
|
||||
SELECT id FROM status_pages
|
||||
WHERE account_id = ${accountId} AND id = ANY(${sql.array(pageIds)}::uuid[])
|
||||
`;
|
||||
if (owned.length > 0) {
|
||||
const rows = owned.map((o) => ({ incident_id: incidentId, status_page_id: o.id }));
|
||||
await sql`INSERT INTO incident_status_pages ${sql(rows, "incident_id", "status_page_id")}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const incidents = new Elysia({ prefix: "/incidents" })
|
||||
.use(requireAuth)
|
||||
|
||||
.get("/", async ({ accountId }) => {
|
||||
return sql`
|
||||
SELECT id, title, status, severity, pinned, started_at, resolved_at, created_at
|
||||
FROM incidents
|
||||
WHERE account_id = ${accountId}
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 200
|
||||
`;
|
||||
}, { detail: { summary: "List incidents", tags: ["incidents"] } })
|
||||
|
||||
.post("/", async ({ accountId, body }) => {
|
||||
const [row] = await sql`
|
||||
INSERT INTO incidents (account_id, title, status, severity, pinned, resolved_at)
|
||||
VALUES (
|
||||
${accountId}, ${body.title}, ${body.status},
|
||||
${body.severity ?? 'minor'}, ${body.pinned ?? true},
|
||||
${body.status === 'resolved' ? sql`now()` : null}
|
||||
)
|
||||
RETURNING *
|
||||
`;
|
||||
await attachJoins(row.id, accountId, body.monitor_ids, body.status_page_ids);
|
||||
if (body.initial_update) {
|
||||
const html = renderMarkdown(body.initial_update.body);
|
||||
await sql`
|
||||
INSERT INTO incident_updates (incident_id, status, body, body_html)
|
||||
VALUES (${row.id}, ${body.status}, ${body.initial_update.body}, ${html})
|
||||
`;
|
||||
}
|
||||
return row;
|
||||
}, { body: IncidentBody, detail: { summary: "Create incident", tags: ["incidents"] } })
|
||||
|
||||
.get("/:id", async ({ accountId, params, set }) => {
|
||||
const [incident] = await sql`
|
||||
SELECT * FROM incidents WHERE id = ${params.id} AND account_id = ${accountId}
|
||||
`;
|
||||
if (!incident) { set.status = 404; return { error: "Not found" }; }
|
||||
const updates = await sql`
|
||||
SELECT id, status, body, body_html, created_at FROM incident_updates
|
||||
WHERE incident_id = ${params.id} ORDER BY created_at ASC
|
||||
`;
|
||||
const monitorRows = await sql<{ monitor_id: string }[]>`
|
||||
SELECT monitor_id FROM incident_monitors WHERE incident_id = ${params.id}
|
||||
`;
|
||||
const pageRows = await sql<{ status_page_id: string }[]>`
|
||||
SELECT status_page_id FROM incident_status_pages WHERE incident_id = ${params.id}
|
||||
`;
|
||||
return {
|
||||
...incident,
|
||||
updates,
|
||||
monitor_ids: monitorRows.map((r) => r.monitor_id),
|
||||
status_page_ids: pageRows.map((r) => r.status_page_id),
|
||||
};
|
||||
}, { detail: { summary: "Get incident", tags: ["incidents"] } })
|
||||
|
||||
.patch("/:id", async ({ accountId, params, body, set }) => {
|
||||
const [row] = await sql`
|
||||
UPDATE incidents SET
|
||||
title = COALESCE(${body.title ?? null}, title),
|
||||
status = COALESCE(${body.status ?? null}, status),
|
||||
severity = COALESCE(${body.severity ?? null}, severity),
|
||||
pinned = COALESCE(${body.pinned ?? null}, pinned),
|
||||
resolved_at = CASE WHEN ${body.status === 'resolved'} THEN COALESCE(resolved_at, now())
|
||||
WHEN ${body.status != null && body.status !== 'resolved'} THEN NULL
|
||||
ELSE resolved_at END
|
||||
WHERE id = ${params.id} AND account_id = ${accountId}
|
||||
RETURNING *
|
||||
`;
|
||||
if (!row) { set.status = 404; return { error: "Not found" }; }
|
||||
await attachJoins(row.id, accountId, body.monitor_ids, body.status_page_ids);
|
||||
return row;
|
||||
}, { body: t.Partial(IncidentBody), detail: { summary: "Update incident", tags: ["incidents"] } })
|
||||
|
||||
.post("/:id/updates", async ({ accountId, params, body, set }) => {
|
||||
const [incident] = await sql<{ id: string }[]>`
|
||||
SELECT id FROM incidents WHERE id = ${params.id} AND account_id = ${accountId}
|
||||
`;
|
||||
if (!incident) { set.status = 404; return { error: "Not found" }; }
|
||||
const html = renderMarkdown(body.body);
|
||||
const [update] = await sql`
|
||||
INSERT INTO incident_updates (incident_id, status, body, body_html)
|
||||
VALUES (${params.id}, ${body.status}, ${body.body}, ${html})
|
||||
RETURNING *
|
||||
`;
|
||||
// Bring the parent incident's status into sync with the latest update.
|
||||
await sql`
|
||||
UPDATE incidents SET
|
||||
status = ${body.status},
|
||||
resolved_at = CASE WHEN ${body.status === 'resolved'} THEN COALESCE(resolved_at, now()) ELSE NULL END
|
||||
WHERE id = ${params.id}
|
||||
`;
|
||||
return update;
|
||||
}, { body: IncidentUpdateBody, detail: { summary: "Post incident update", tags: ["incidents"] } })
|
||||
|
||||
.delete("/:id", async ({ accountId, params, set }) => {
|
||||
const [row] = await sql`
|
||||
DELETE FROM incidents WHERE id = ${params.id} AND account_id = ${accountId}
|
||||
RETURNING id
|
||||
`;
|
||||
if (!row) { set.status = 404; return { error: "Not found" }; }
|
||||
return { deleted: true };
|
||||
}, { detail: { summary: "Delete incident", tags: ["incidents"] } });
|
||||
@@ -19,8 +19,18 @@ const MonitorBody = t.Object({
|
||||
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;
|
||||
@@ -39,9 +49,18 @@ async function replaceMonitorChannels(monitorId: string, accountId: string, chan
|
||||
export const monitors = new Elysia({ prefix: "/monitors" })
|
||||
.use(requireAuth)
|
||||
|
||||
.get("/", async ({ accountId }) => {
|
||||
.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", tags: ["monitors"] } })
|
||||
}, { detail: { summary: "List monitors (optional ?tag= filter)", tags: ["monitors"] } })
|
||||
|
||||
.post("/", async ({ accountId, plan, body, set }) => {
|
||||
const limits = getPlanLimits(plan);
|
||||
@@ -91,6 +110,7 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
||||
RETURNING *
|
||||
`;
|
||||
if (body.channel_ids) await replaceMonitorChannels(monitor.id, accountId, body.channel_ids);
|
||||
if (body.tags) await replaceMonitorTags(monitor.id, body.tags);
|
||||
return monitor;
|
||||
}, { body: MonitorBody, detail: { summary: "Create monitor", tags: ["monitors"] } })
|
||||
|
||||
@@ -107,7 +127,10 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
||||
const channels = await sql<{ channel_id: string }[]>`
|
||||
SELECT channel_id FROM monitor_notifications WHERE monitor_id = ${params.id}
|
||||
`;
|
||||
return { ...monitor, results, channel_ids: channels.map((c) => c.channel_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 }) => {
|
||||
@@ -153,6 +176,7 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
||||
`;
|
||||
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);
|
||||
return monitor;
|
||||
}, { body: t.Partial(MonitorBody), detail: { summary: "Update monitor", tags: ["monitors"] } })
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { requireAuth } from "./auth";
|
||||
import sql from "../db";
|
||||
|
||||
const Theme = t.Union([t.Literal("auto"), t.Literal("light"), t.Literal("dark")]);
|
||||
const Window = t.Union([t.Literal("24h"), t.Literal("7d"), t.Literal("30d"), t.Literal("90d")]);
|
||||
|
||||
const StatusPageBody = t.Object({
|
||||
slug: t.String({ minLength: 1, maxLength: 80, pattern: "^[a-z0-9][a-z0-9-]*$", description: "URL slug, lowercase + hyphens" }),
|
||||
title: t.String({ minLength: 1, maxLength: 200 }),
|
||||
description: t.Optional(t.Nullable(t.String({ maxLength: 2000 }))),
|
||||
theme: t.Optional(Theme),
|
||||
password: t.Optional(t.Nullable(t.String({ description: "Plain text. Will be hashed at write time. Pass null to clear." }))),
|
||||
index_search: t.Optional(t.Boolean()),
|
||||
show_powered_by: t.Optional(t.Boolean()),
|
||||
show_response_time: t.Optional(t.Boolean()),
|
||||
show_cert_expiry: t.Optional(t.Boolean()),
|
||||
default_window: t.Optional(Window),
|
||||
custom_css: t.Optional(t.Nullable(t.String({ maxLength: 50_000 }))),
|
||||
footer_text: t.Optional(t.Nullable(t.String({ maxLength: 5000 }))),
|
||||
og_image_url: t.Optional(t.Nullable(t.String({ maxLength: 2048 }))),
|
||||
analytics_html: t.Optional(t.Nullable(t.String({ maxLength: 5000 }))),
|
||||
auto_refresh_s: t.Optional(t.Number({ minimum: 10, maximum: 3600 })),
|
||||
groups: t.Optional(t.Array(t.Object({
|
||||
name: t.String({ minLength: 1, maxLength: 200 }),
|
||||
position: t.Optional(t.Number()),
|
||||
}))),
|
||||
monitors: t.Optional(t.Array(t.Object({
|
||||
monitor_id: t.String(),
|
||||
group_index: t.Optional(t.Nullable(t.Number())),
|
||||
display_name: t.Optional(t.Nullable(t.String({ maxLength: 200 }))),
|
||||
position: t.Optional(t.Number()),
|
||||
}))),
|
||||
});
|
||||
|
||||
// Strip @import and expression() from custom CSS — basic sanity, not a full
|
||||
// parser. The CSS still runs in the visitor's browser; this just blocks the
|
||||
// most common smuggling vectors.
|
||||
function sanitizeCss(css: string | null | undefined): string | null {
|
||||
if (!css) return null;
|
||||
return css
|
||||
.replace(/@import[^;]*;?/gi, "")
|
||||
.replace(/expression\s*\(/gi, "");
|
||||
}
|
||||
|
||||
async function hashPassword(plain: string): Promise<string> {
|
||||
return await Bun.password.hash(plain, { algorithm: "bcrypt", cost: 10 });
|
||||
}
|
||||
|
||||
async function replaceGroupsAndMonitors(
|
||||
pageId: string,
|
||||
accountId: string,
|
||||
groups: { name: string; position?: number }[] | undefined,
|
||||
monitorsList: { monitor_id: string; group_index?: number | null; display_name?: string | null; position?: number }[] | undefined,
|
||||
) {
|
||||
if (groups !== undefined) {
|
||||
await sql`DELETE FROM status_page_groups WHERE status_page_id = ${pageId}`;
|
||||
}
|
||||
const groupIds: string[] = [];
|
||||
if (groups && groups.length > 0) {
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
const g = groups[i]!;
|
||||
const [row] = await sql<{ id: string }[]>`
|
||||
INSERT INTO status_page_groups (status_page_id, name, position)
|
||||
VALUES (${pageId}, ${g.name}, ${g.position ?? i})
|
||||
RETURNING id
|
||||
`;
|
||||
groupIds.push(row!.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (monitorsList !== undefined) {
|
||||
await sql`DELETE FROM status_page_monitors WHERE status_page_id = ${pageId}`;
|
||||
}
|
||||
if (monitorsList && monitorsList.length > 0) {
|
||||
// Validate that the monitors all belong to this account.
|
||||
const monitorIds = monitorsList.map((m) => m.monitor_id);
|
||||
const owned = await sql<{ id: string }[]>`
|
||||
SELECT id FROM monitors
|
||||
WHERE account_id = ${accountId} AND id = ANY(${sql.array(monitorIds)}::text[])
|
||||
`;
|
||||
const ownedSet = new Set(owned.map((o) => o.id));
|
||||
const rows: any[] = [];
|
||||
for (let i = 0; i < monitorsList.length; i++) {
|
||||
const m = monitorsList[i]!;
|
||||
if (!ownedSet.has(m.monitor_id)) continue;
|
||||
const groupId = m.group_index != null && groupIds[m.group_index] ? groupIds[m.group_index] : null;
|
||||
rows.push({
|
||||
status_page_id: pageId,
|
||||
monitor_id: m.monitor_id,
|
||||
group_id: groupId,
|
||||
display_name: m.display_name ?? null,
|
||||
position: m.position ?? i,
|
||||
});
|
||||
}
|
||||
if (rows.length > 0) {
|
||||
await sql`
|
||||
INSERT INTO status_page_monitors ${sql(rows, "status_page_id", "monitor_id", "group_id", "display_name", "position")}
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const statusPages = new Elysia({ prefix: "/status-pages" })
|
||||
.use(requireAuth)
|
||||
|
||||
.get("/", async ({ accountId }) => {
|
||||
return sql`
|
||||
SELECT id, slug, title, description, theme, default_window, created_at, updated_at
|
||||
FROM status_pages
|
||||
WHERE account_id = ${accountId}
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
}, { detail: { summary: "List status pages", tags: ["status-pages"] } })
|
||||
|
||||
.post("/", async ({ accountId, body, set }) => {
|
||||
const password_hash = body.password ? await hashPassword(body.password) : null;
|
||||
const css = sanitizeCss(body.custom_css);
|
||||
let row;
|
||||
try {
|
||||
[row] = await sql`
|
||||
INSERT INTO status_pages (
|
||||
account_id, slug, title, description, theme, password_hash, index_search,
|
||||
show_powered_by, show_response_time, show_cert_expiry, default_window,
|
||||
custom_css, footer_text, og_image_url, analytics_html, auto_refresh_s
|
||||
)
|
||||
VALUES (
|
||||
${accountId}, ${body.slug}, ${body.title}, ${body.description ?? null},
|
||||
${body.theme ?? 'auto'}, ${password_hash}, ${body.index_search ?? true},
|
||||
${body.show_powered_by ?? true}, ${body.show_response_time ?? true},
|
||||
${body.show_cert_expiry ?? false}, ${body.default_window ?? '24h'},
|
||||
${css}, ${body.footer_text ?? null}, ${body.og_image_url ?? null},
|
||||
${body.analytics_html ?? null}, ${body.auto_refresh_s ?? 60}
|
||||
)
|
||||
RETURNING *
|
||||
`;
|
||||
} catch (e: any) {
|
||||
if (e?.code === "23505") { set.status = 409; return { error: "Slug already in use" }; }
|
||||
throw e;
|
||||
}
|
||||
await replaceGroupsAndMonitors(row.id, accountId, body.groups, body.monitors);
|
||||
return row;
|
||||
}, { body: StatusPageBody, detail: { summary: "Create status page", tags: ["status-pages"] } })
|
||||
|
||||
.get("/:id", async ({ accountId, params, set }) => {
|
||||
const [page] = await sql`
|
||||
SELECT * FROM status_pages WHERE id = ${params.id} AND account_id = ${accountId}
|
||||
`;
|
||||
if (!page) { set.status = 404; return { error: "Not found" }; }
|
||||
const groups = await sql`
|
||||
SELECT id, name, position FROM status_page_groups
|
||||
WHERE status_page_id = ${page.id} ORDER BY position ASC
|
||||
`;
|
||||
const monitors = await sql`
|
||||
SELECT spm.monitor_id, spm.group_id, spm.display_name, spm.position, m.name, m.url
|
||||
FROM status_page_monitors spm
|
||||
JOIN monitors m ON m.id = spm.monitor_id
|
||||
WHERE spm.status_page_id = ${page.id}
|
||||
ORDER BY spm.position ASC
|
||||
`;
|
||||
delete (page as any).password_hash;
|
||||
return { ...page, has_password: !!(page as any).password_hash || false, groups, monitors };
|
||||
}, { detail: { summary: "Get status page", tags: ["status-pages"] } })
|
||||
|
||||
.patch("/:id", async ({ accountId, params, body, set }) => {
|
||||
const password_hash =
|
||||
body.password === undefined ? null
|
||||
: body.password === null ? null
|
||||
: await hashPassword(body.password);
|
||||
const css = body.custom_css === undefined ? null : sanitizeCss(body.custom_css);
|
||||
let row;
|
||||
try {
|
||||
[row] = await sql`
|
||||
UPDATE status_pages SET
|
||||
slug = COALESCE(${body.slug ?? null}, slug),
|
||||
title = COALESCE(${body.title ?? null}, title),
|
||||
description = COALESCE(${body.description ?? null}, description),
|
||||
theme = COALESCE(${body.theme ?? null}, theme),
|
||||
password_hash = CASE WHEN ${body.password === null} THEN NULL
|
||||
WHEN ${body.password !== undefined} THEN ${password_hash}
|
||||
ELSE password_hash END,
|
||||
index_search = COALESCE(${body.index_search ?? null}, index_search),
|
||||
show_powered_by = COALESCE(${body.show_powered_by ?? null}, show_powered_by),
|
||||
show_response_time = COALESCE(${body.show_response_time ?? null}, show_response_time),
|
||||
show_cert_expiry = COALESCE(${body.show_cert_expiry ?? null}, show_cert_expiry),
|
||||
default_window = COALESCE(${body.default_window ?? null}, default_window),
|
||||
custom_css = CASE WHEN ${body.custom_css !== undefined} THEN ${css} ELSE custom_css END,
|
||||
footer_text = COALESCE(${body.footer_text ?? null}, footer_text),
|
||||
og_image_url = COALESCE(${body.og_image_url ?? null}, og_image_url),
|
||||
analytics_html = COALESCE(${body.analytics_html ?? null}, analytics_html),
|
||||
auto_refresh_s = COALESCE(${body.auto_refresh_s ?? null}, auto_refresh_s),
|
||||
updated_at = now()
|
||||
WHERE id = ${params.id} AND account_id = ${accountId}
|
||||
RETURNING *
|
||||
`;
|
||||
} catch (e: any) {
|
||||
if (e?.code === "23505") { set.status = 409; return { error: "Slug already in use" }; }
|
||||
throw e;
|
||||
}
|
||||
if (!row) { set.status = 404; return { error: "Not found" }; }
|
||||
await replaceGroupsAndMonitors(row.id, accountId, body.groups, body.monitors);
|
||||
return row;
|
||||
}, { body: t.Partial(StatusPageBody), detail: { summary: "Update status page", tags: ["status-pages"] } })
|
||||
|
||||
.delete("/:id", async ({ accountId, params, set }) => {
|
||||
const [row] = await sql`
|
||||
DELETE FROM status_pages WHERE id = ${params.id} AND account_id = ${accountId}
|
||||
RETURNING id
|
||||
`;
|
||||
if (!row) { set.status = 404; return { error: "Not found" }; }
|
||||
return { deleted: true };
|
||||
}, { detail: { summary: "Delete status page", tags: ["status-pages"] } });
|
||||
Reference in New Issue
Block a user