refactor db

This commit is contained in:
nate 2026-04-09 19:18:04 +04:00
parent a58030323a
commit 29a9d6cea6
8 changed files with 109 additions and 139 deletions

View File

@ -32,8 +32,8 @@ export async function dispatchForMonitor(monitorId: string, event: NotificationE
const channels = await sql<ChannelRow[]>` const channels = await sql<ChannelRow[]>`
SELECT c.id, c.account_id, c.name, c.kind, c.config, c.enabled SELECT c.id, c.account_id, c.name, c.kind, c.config, c.enabled
FROM notification_channels c FROM notification_channels c
JOIN monitor_notifications mn ON mn.channel_id = c.id WHERE c.id = ANY((SELECT channel_ids FROM monitors WHERE id = ${monitorId}))
WHERE mn.monitor_id = ${monitorId} AND c.enabled = true AND c.enabled = true
`; `;
if (channels.length === 0) return; if (channels.length === 0) return;
await Promise.all(channels.map((c) => dispatch(c, event))); await Promise.all(channels.map((c) => dispatch(c, event)));

View File

@ -89,6 +89,11 @@ export const channels = new Elysia({ prefix: "/notifications/channels" })
RETURNING id RETURNING id
`; `;
if (!row) { set.status = 404; return { error: "Not found" }; } 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 }; return { deleted: true };
}, { detail: { summary: "Delete notification channel", tags: ["notifications"] } }) }, { detail: { summary: "Delete notification channel", tags: ["notifications"] } })

View File

@ -23,28 +23,18 @@ const MonitorBody = t.Object({
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." })), 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[]) { function dedupeTags(tags: string[]): string[] {
await sql`DELETE FROM monitor_tags WHERE monitor_id = ${monitorId}`; return Array.from(new Set(tags.map((t) => t.trim()).filter(Boolean)));
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[]) { async function validateChannelIds(accountId: string, channelIds: string[]): Promise<string[]> {
await sql`DELETE FROM monitor_notifications WHERE monitor_id = ${monitorId}`; if (channelIds.length === 0) return [];
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 }[]>` const owned = await sql<{ id: string }[]>`
SELECT id FROM notification_channels SELECT id FROM notification_channels
WHERE account_id = ${accountId} WHERE account_id = ${accountId}
AND id = ANY(${sql.array(channelIds)}::uuid[]) AND id = ANY(${sql.array(channelIds)}::uuid[])
`; `;
if (owned.length === 0) return; return owned.map((o) => o.id);
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" }) export const monitors = new Elysia({ prefix: "/monitors" })
@ -54,10 +44,9 @@ export const monitors = new Elysia({ prefix: "/monitors" })
const tag = (query as any)?.tag; const tag = (query as any)?.tag;
if (tag) { if (tag) {
return sql` return sql`
SELECT m.* FROM monitors m SELECT * FROM monitors
JOIN monitor_tags mt ON mt.monitor_id = m.id WHERE account_id = ${accountId} AND ${tag} = ANY(tags)
WHERE m.account_id = ${accountId} AND mt.tag = ${tag} ORDER BY created_at DESC
ORDER BY m.created_at DESC
`; `;
} }
return sql`SELECT * FROM monitors WHERE account_id = ${accountId} ORDER BY created_at DESC`; return sql`SELECT * FROM monitors WHERE account_id = ${accountId} ORDER BY created_at DESC`;
@ -92,8 +81,10 @@ export const monitors = new Elysia({ prefix: "/monitors" })
const ssrfError = await validateMonitorUrl(body.url); const ssrfError = await validateMonitorUrl(body.url);
if (ssrfError) { set.status = 400; return { error: ssrfError }; } 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` 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) 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, tags, channel_ids)
VALUES ( VALUES (
${accountId}, ${body.name}, ${body.url}, ${accountId}, ${body.name}, ${body.url},
${(body.method ?? 'GET').toUpperCase()}, ${(body.method ?? 'GET').toUpperCase()},
@ -106,12 +97,12 @@ export const monitors = new Elysia({ prefix: "/monitors" })
${body.resend_interval ?? 0}, ${body.resend_interval ?? 0},
${body.cert_alert_days ?? 0}, ${body.cert_alert_days ?? 0},
${body.query ? sql.json(body.query) : null}, ${body.query ? sql.json(body.query) : null},
${sql.array(regions)} ${sql.array(regions)},
${sql.array(tags)},
${sql.array(channelIds)}::uuid[]
) )
RETURNING * RETURNING *
`; `;
if (body.channel_ids) await replaceMonitorChannels(monitor.id, accountId, body.channel_ids);
if (body.tags) await replaceMonitorTags(monitor.id, body.tags);
invalidateMonitorList(); invalidateMonitorList();
return monitor; return monitor;
}, { body: MonitorBody, detail: { summary: "Create monitor", tags: ["monitors"] } }) }, { body: MonitorBody, detail: { summary: "Create monitor", tags: ["monitors"] } })
@ -126,13 +117,7 @@ export const monitors = new Elysia({ prefix: "/monitors" })
SELECT * FROM pings WHERE monitor_id = ${params.id} SELECT * FROM pings WHERE monitor_id = ${params.id}
ORDER BY checked_at DESC LIMIT 100 ORDER BY checked_at DESC LIMIT 100
`; `;
const channels = await sql<{ channel_id: string }[]>` return { ...monitor, results };
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"] } }) }, { detail: { summary: "Get monitor with results", tags: ["monitors"] } })
.patch("/:id", async ({ accountId, plan, params, body, set }) => { .patch("/:id", async ({ accountId, plan, params, body, set }) => {
@ -158,27 +143,29 @@ export const monitors = new Elysia({ prefix: "/monitors" })
if (ssrfError) { set.status = 400; return { error: ssrfError }; } if (ssrfError) { set.status = 400; return { error: ssrfError }; }
} }
const validatedChannelIds = body.channel_ids ? await validateChannelIds(accountId, body.channel_ids) : null;
const [monitor] = await sql` const [monitor] = await sql`
UPDATE monitors SET UPDATE monitors SET
name = COALESCE(${body.name ?? null}, name), name = COALESCE(${body.name ?? null}, name),
url = COALESCE(${body.url ?? null}, url), url = COALESCE(${body.url ?? null}, url),
method = COALESCE(${body.method ? body.method.toUpperCase() : null}, method), method = COALESCE(${body.method ? body.method.toUpperCase() : null}, method),
request_headers = COALESCE(${body.request_headers ? sql.json(body.request_headers) : null}, request_headers), request_headers = COALESCE(${body.request_headers ? sql.json(body.request_headers) : null}, request_headers),
request_body = COALESCE(${body.request_body ?? null}, request_body), request_body = COALESCE(${body.request_body ?? null}, request_body),
timeout_ms = COALESCE(${body.timeout_ms ?? null}, timeout_ms), timeout_ms = COALESCE(${body.timeout_ms ?? null}, timeout_ms),
interval_s = COALESCE(${body.interval_s ?? null}, interval_s), interval_s = COALESCE(${body.interval_s ?? null}, interval_s),
max_retries = COALESCE(${body.max_retries ?? null}, max_retries), max_retries = COALESCE(${body.max_retries ?? null}, max_retries),
retry_interval_s = COALESCE(${body.retry_interval_s ?? null}, retry_interval_s), retry_interval_s = COALESCE(${body.retry_interval_s ?? null}, retry_interval_s),
resend_interval = COALESCE(${body.resend_interval ?? null}, resend_interval), resend_interval = COALESCE(${body.resend_interval ?? null}, resend_interval),
cert_alert_days = COALESCE(${body.cert_alert_days ?? null}, cert_alert_days), cert_alert_days = COALESCE(${body.cert_alert_days ?? null}, cert_alert_days),
query = COALESCE(${body.query ? sql.json(body.query) : null}, query), query = COALESCE(${body.query ? sql.json(body.query) : null}, query),
regions = COALESCE(${body.regions ? sql.array(body.regions) : null}, regions) 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} WHERE id = ${params.id} AND account_id = ${accountId}
RETURNING * RETURNING *
`; `;
if (!monitor) { set.status = 404; return { error: "Not found" }; } 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(); invalidateMonitorList();
return monitor; return monitor;
}, { body: t.Partial(MonitorBody), detail: { summary: "Update monitor", tags: ["monitors"] } }) }, { body: t.Partial(MonitorBody), detail: { summary: "Update monitor", tags: ["monitors"] } })

View File

@ -81,7 +81,6 @@ export const ingest = new Elysia()
if (!monitor_check) { set.status = 404; return { error: "Monitor not found" }; } if (!monitor_check) { set.status = 404; return { error: "Monitor not found" }; }
const meta = body.meta ? { ...body.meta } : {}; const meta = body.meta ? { ...body.meta } : {};
if (body.cert_expiry_days != null) meta.cert_expiry_days = body.cert_expiry_days;
const responseBody: string | null = meta.body_preview ?? null; const responseBody: string | null = meta.body_preview ?? null;
delete meta.body_preview; delete meta.body_preview;
@ -133,7 +132,7 @@ export const ingest = new Elysia()
`; `;
const [ping] = await sql` const [ping] = await sql`
INSERT INTO pings (monitor_id, checked_at, scheduled_at, jitter_ms, status_code, latency_ms, up, important, error, meta, region, run_id) INSERT INTO pings (monitor_id, checked_at, scheduled_at, jitter_ms, status_code, latency_ms, up, important, error, meta, region, run_id, cert_expiry_days)
VALUES ( VALUES (
${body.monitor_id}, ${body.monitor_id},
${checkedAt ?? sql`now()`}, ${checkedAt ?? sql`now()`},
@ -146,7 +145,8 @@ export const ingest = new Elysia()
${body.error ?? null}, ${body.error ?? null},
${Object.keys(meta).length > 0 ? sql.json(meta) : null}, ${Object.keys(meta).length > 0 ? sql.json(meta) : null},
${region}, ${region},
${body.run_id ?? null} ${body.run_id ?? null},
${body.cert_expiry_days ?? null}
) )
RETURNING * RETURNING *
`; `;

View File

@ -42,6 +42,6 @@ export async function fetchQrBase64(text: string): Promise<string> {
export async function getAvailableCoins(): Promise<string[]> { export async function getAvailableCoins(): Promise<string[]> {
const info = await getChainInfo(); const info = await getChainInfo();
return Object.entries(info) return Object.entries(info)
.filter(([_, v]) => v?.node?.uptime != null) .filter(([_, v]) => v != null)
.map(([k]) => k); .map(([k]) => k);
} }

View File

@ -1,15 +1,20 @@
export async function migrate(sql: any) { export async function migrate(sql: any) {
await sql`CREATE EXTENSION IF NOT EXISTS pgcrypto`; await sql`CREATE EXTENSION IF NOT EXISTS pgcrypto`;
// ── accounts ──────────────────────────────────────────────────────────
await sql` await sql`
CREATE TABLE IF NOT EXISTS accounts ( CREATE TABLE IF NOT EXISTS accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL UNIQUE, key TEXT NOT NULL UNIQUE,
email_hash TEXT, email_hash TEXT,
created_at TIMESTAMPTZ DEFAULT now() plan TEXT NOT NULL DEFAULT 'free',
plan_expires_at TIMESTAMPTZ,
plan_stack JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ DEFAULT now()
) )
`; `;
// ── monitors ──────────────────────────────────────────────────────────
await sql` await sql`
CREATE TABLE IF NOT EXISTS monitors ( CREATE TABLE IF NOT EXISTS monitors (
id TEXT PRIMARY KEY DEFAULT encode(gen_random_bytes(8), 'hex'), id TEXT PRIMARY KEY DEFAULT encode(gen_random_bytes(8), 'hex'),
@ -23,25 +28,44 @@ export async function migrate(sql: any) {
interval_s INTEGER NOT NULL DEFAULT 60, interval_s INTEGER NOT NULL DEFAULT 60,
query JSONB, query JSONB,
enabled BOOLEAN NOT NULL DEFAULT true, enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now() regions TEXT[] NOT NULL DEFAULT '{}',
max_retries INTEGER NOT NULL DEFAULT 0,
retry_interval_s INTEGER NOT NULL DEFAULT 30,
resend_interval INTEGER NOT NULL DEFAULT 0,
cert_alert_days INTEGER NOT NULL DEFAULT 0,
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` await sql`
CREATE TABLE IF NOT EXISTS pings ( CREATE TABLE IF NOT EXISTS pings (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(), checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
scheduled_at TIMESTAMPTZ, scheduled_at TIMESTAMPTZ,
jitter_ms INTEGER, jitter_ms INTEGER,
status_code INTEGER, status_code INTEGER,
latency_ms INTEGER, latency_ms INTEGER,
up BOOLEAN NOT NULL, up BOOLEAN NOT NULL,
error TEXT, important BOOLEAN NOT NULL DEFAULT false,
meta JSONB error TEXT,
meta JSONB,
region TEXT NOT NULL DEFAULT 'default',
run_id TEXT,
cert_expiry_days INTEGER
) )
`; `;
await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`;
await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`;
// ── ping_bodies ───────────────────────────────────────────────────────
await sql` await sql`
CREATE TABLE IF NOT EXISTS ping_bodies ( CREATE TABLE IF NOT EXISTS ping_bodies (
ping_id BIGINT PRIMARY KEY REFERENCES pings(id) ON DELETE CASCADE, ping_id BIGINT PRIMARY KEY REFERENCES pings(id) ON DELETE CASCADE,
@ -49,6 +73,7 @@ export async function migrate(sql: any) {
) )
`; `;
// ── api_keys ──────────────────────────────────────────────────────────
await sql` await sql`
CREATE TABLE IF NOT EXISTS api_keys ( CREATE TABLE IF NOT EXISTS api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@ -59,24 +84,9 @@ export async function migrate(sql: any) {
last_used_at TIMESTAMPTZ last_used_at TIMESTAMPTZ
) )
`; `;
await sql`CREATE INDEX IF NOT EXISTS idx_api_keys_account ON api_keys(account_id)`;
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ`; // ── monitor_region_state ──────────────────────────────────────────────
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS jitter_ms INTEGER`;
await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS regions TEXT[] NOT NULL DEFAULT '{}'`;
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS region TEXT`;
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS run_id TEXT`;
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan TEXT NOT NULL DEFAULT 'free'`;
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_expires_at TIMESTAMPTZ`;
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_stack JSONB NOT NULL DEFAULT '[]'`;
await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS max_retries INTEGER NOT NULL DEFAULT 0`;
await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS retry_interval_s INTEGER NOT NULL DEFAULT 30`;
await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS resend_interval INTEGER NOT NULL DEFAULT 0`;
await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS cert_alert_days INTEGER NOT NULL DEFAULT 0`;
await sql`ALTER TABLE monitors ALTER COLUMN cert_alert_days SET DEFAULT 0`;
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS important BOOLEAN NOT NULL DEFAULT false`;
// Per-region transition state. region='' for unspecified/single-region monitors.
await sql` await sql`
CREATE TABLE IF NOT EXISTS monitor_region_state ( CREATE TABLE IF NOT EXISTS monitor_region_state (
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
@ -88,11 +98,8 @@ export async function migrate(sql: any) {
PRIMARY KEY (monitor_id, region) PRIMARY KEY (monitor_id, region)
) )
`; `;
// Drop the now-stale per-monitor state columns from the previous slice.
await sql`ALTER TABLE monitors DROP COLUMN IF EXISTS last_state`;
await sql`ALTER TABLE monitors DROP COLUMN IF EXISTS consecutive_down`;
// Notifications: modular providers, webhook only for now. // ── notification_channels ─────────────────────────────────────────────
await sql` await sql`
CREATE TABLE IF NOT EXISTS notification_channels ( CREATE TABLE IF NOT EXISTS notification_channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@ -106,28 +113,7 @@ export async function migrate(sql: any) {
`; `;
await sql`CREATE INDEX IF NOT EXISTS idx_notification_channels_account ON notification_channels(account_id)`; await sql`CREATE INDEX IF NOT EXISTS idx_notification_channels_account ON notification_channels(account_id)`;
await sql` // ── status_pages ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS monitor_notifications (
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
channel_id UUID NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
PRIMARY KEY (monitor_id, channel_id)
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_monitor_notifications_channel ON monitor_notifications(channel_id)`;
// Tier 3: monitor tags. One row per (monitor, tag). Used by the dashboard
// home filter and the status page builder's "all monitors with tag X" picker.
await sql`
CREATE TABLE IF NOT EXISTS monitor_tags (
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (monitor_id, tag)
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_monitor_tags_tag ON monitor_tags(tag)`;
// Tier 3: public status pages. The whole subgraph below is read by the
// standalone apps/status service; writes happen via apps/api admin routes.
await sql` await sql`
CREATE TABLE IF NOT EXISTS status_pages ( CREATE TABLE IF NOT EXISTS status_pages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@ -142,6 +128,9 @@ export async function migrate(sql: any) {
show_response_time BOOLEAN NOT NULL DEFAULT true, show_response_time BOOLEAN NOT NULL DEFAULT true,
show_cert_expiry BOOLEAN NOT NULL DEFAULT false, show_cert_expiry BOOLEAN NOT NULL DEFAULT false,
default_window TEXT NOT NULL DEFAULT '24h', default_window TEXT NOT NULL DEFAULT '24h',
display_mode TEXT NOT NULL DEFAULT 'expanded',
bar_frequency TEXT NOT NULL DEFAULT 'daily',
bar_count INTEGER NOT NULL DEFAULT 90,
custom_css TEXT, custom_css TEXT,
footer_text TEXT, footer_text TEXT,
og_image_url TEXT, og_image_url TEXT,
@ -152,14 +141,8 @@ export async function migrate(sql: any) {
) )
`; `;
await sql`CREATE INDEX IF NOT EXISTS idx_status_pages_account ON status_pages(account_id)`; await sql`CREATE INDEX IF NOT EXISTS idx_status_pages_account ON status_pages(account_id)`;
// Display mode: 'compact' = one-line rows with click-to-expand details,
// 'expanded' = full detail card always visible (default).
await sql`ALTER TABLE status_pages ADD COLUMN IF NOT EXISTS display_mode TEXT NOT NULL DEFAULT 'expanded'`;
// Heartbeat bar settings: granularity + how many bars. Independent from
// default_window (which still drives the primary uptime % + multi-window cells).
await sql`ALTER TABLE status_pages ADD COLUMN IF NOT EXISTS bar_frequency TEXT NOT NULL DEFAULT 'daily'`;
await sql`ALTER TABLE status_pages ADD COLUMN IF NOT EXISTS bar_count INTEGER NOT NULL DEFAULT 90`;
// ── status_page_groups ────────────────────────────────────────────────
await sql` await sql`
CREATE TABLE IF NOT EXISTS status_page_groups ( CREATE TABLE IF NOT EXISTS status_page_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@ -170,20 +153,21 @@ export async function migrate(sql: any) {
`; `;
await sql`CREATE INDEX IF NOT EXISTS idx_status_page_groups_page ON status_page_groups(status_page_id)`; await sql`CREATE INDEX IF NOT EXISTS idx_status_page_groups_page ON status_page_groups(status_page_id)`;
// ── status_page_monitors ──────────────────────────────────────────────
await sql` await sql`
CREATE TABLE IF NOT EXISTS status_page_monitors ( CREATE TABLE IF NOT EXISTS status_page_monitors (
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE, status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
group_id UUID REFERENCES status_page_groups(id) ON DELETE SET NULL, group_id UUID REFERENCES status_page_groups(id) ON DELETE SET NULL,
display_name TEXT, display_name TEXT,
display_mode TEXT,
position INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (status_page_id, monitor_id) PRIMARY KEY (status_page_id, monitor_id)
) )
`; `;
await sql`CREATE INDEX IF NOT EXISTS idx_status_page_monitors_monitor ON status_page_monitors(monitor_id)`; await sql`CREATE INDEX IF NOT EXISTS idx_status_page_monitors_monitor ON status_page_monitors(monitor_id)`;
// Per-monitor display mode override. NULL = inherit status_pages.display_mode.
await sql`ALTER TABLE status_page_monitors ADD COLUMN IF NOT EXISTS display_mode TEXT`;
// ── incidents ─────────────────────────────────────────────────────────
await sql` await sql`
CREATE TABLE IF NOT EXISTS incidents ( CREATE TABLE IF NOT EXISTS incidents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@ -199,6 +183,7 @@ export async function migrate(sql: any) {
`; `;
await sql`CREATE INDEX IF NOT EXISTS idx_incidents_account ON incidents(account_id, started_at DESC)`; await sql`CREATE INDEX IF NOT EXISTS idx_incidents_account ON incidents(account_id, started_at DESC)`;
// ── incident_updates ──────────────────────────────────────────────────
await sql` await sql`
CREATE TABLE IF NOT EXISTS incident_updates ( CREATE TABLE IF NOT EXISTS incident_updates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@ -211,6 +196,7 @@ export async function migrate(sql: any) {
`; `;
await sql`CREATE INDEX IF NOT EXISTS idx_incident_updates_incident ON incident_updates(incident_id, created_at)`; await sql`CREATE INDEX IF NOT EXISTS idx_incident_updates_incident ON incident_updates(incident_id, created_at)`;
// ── incident_monitors ─────────────────────────────────────────────────
await sql` await sql`
CREATE TABLE IF NOT EXISTS incident_monitors ( CREATE TABLE IF NOT EXISTS incident_monitors (
incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE, incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
@ -218,6 +204,9 @@ export async function migrate(sql: any) {
PRIMARY KEY (incident_id, monitor_id) PRIMARY KEY (incident_id, monitor_id)
) )
`; `;
await sql`CREATE INDEX IF NOT EXISTS idx_incident_monitors_monitor ON incident_monitors(monitor_id)`;
// ── incident_status_pages ─────────────────────────────────────────────
await sql` await sql`
CREATE TABLE IF NOT EXISTS incident_status_pages ( CREATE TABLE IF NOT EXISTS incident_status_pages (
incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE, incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
@ -227,8 +216,7 @@ export async function migrate(sql: any) {
`; `;
await sql`CREATE INDEX IF NOT EXISTS idx_incident_status_pages_page ON incident_status_pages(status_page_id)`; await sql`CREATE INDEX IF NOT EXISTS idx_incident_status_pages_page ON incident_status_pages(status_page_id)`;
// Shared uptime rollup. One row per (monitor, region, bucket_type, bucket_start). // ── monitor_uptime_rollup ─────────────────────────────────────────────
// Powers status page uptime windows AND any future dashboard widgets.
await sql` await sql`
CREATE TABLE IF NOT EXISTS monitor_uptime_rollup ( CREATE TABLE IF NOT EXISTS monitor_uptime_rollup (
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
@ -243,10 +231,7 @@ export async function migrate(sql: any) {
`; `;
await sql`CREATE INDEX IF NOT EXISTS idx_uptime_rollup_lookup ON monitor_uptime_rollup(monitor_id, bucket_type, bucket_start DESC)`; await sql`CREATE INDEX IF NOT EXISTS idx_uptime_rollup_lookup ON monitor_uptime_rollup(monitor_id, bucket_type, bucket_start DESC)`;
// Watermark table for the rollup job. Each row records the most recent // ── rollup_watermarks ─────────────────────────────────────────────────
// pings.checked_at that has been folded into a given bucket_type's rollup
// rows. Lets the periodic rollup pass scan only NEW pings since the last
// pass instead of re-aggregating the entire current bucket on every tick.
await sql` await sql`
CREATE TABLE IF NOT EXISTS rollup_watermarks ( CREATE TABLE IF NOT EXISTS rollup_watermarks (
bucket_type TEXT PRIMARY KEY, bucket_type TEXT PRIMARY KEY,
@ -254,9 +239,7 @@ export async function migrate(sql: any) {
) )
`; `;
await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`; // ── payments ──────────────────────────────────────────────────────────
await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`;
await sql` await sql`
CREATE TABLE IF NOT EXISTS payments ( CREATE TABLE IF NOT EXISTS payments (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
@ -269,16 +252,19 @@ export async function migrate(sql: any) {
address TEXT NOT NULL, address TEXT NOT NULL,
derivation_index INTEGER NOT NULL, derivation_index INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
amount_received TEXT NOT NULL DEFAULT '0',
receipt_html TEXT,
created_at TIMESTAMPTZ DEFAULT now(), created_at TIMESTAMPTZ DEFAULT now(),
paid_at TIMESTAMPTZ, paid_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
txid TEXT txid TEXT
) )
`; `;
await sql`CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status)`;
await sql`CREATE INDEX IF NOT EXISTS idx_payments_account ON payments(account_id)`;
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_coin_derivation ON payments(coin, derivation_index)`;
await sql`ALTER TABLE payments ADD COLUMN IF NOT EXISTS amount_received TEXT NOT NULL DEFAULT '0'`; // ── payment_txs ───────────────────────────────────────────────────────
await sql`ALTER TABLE payments ADD COLUMN IF NOT EXISTS receipt_html TEXT`;
await sql` await sql`
CREATE TABLE IF NOT EXISTS payment_txs ( CREATE TABLE IF NOT EXISTS payment_txs (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
@ -290,13 +276,8 @@ export async function migrate(sql: any) {
UNIQUE(payment_id, txid) UNIQUE(payment_id, txid)
) )
`; `;
await sql`CREATE INDEX IF NOT EXISTS idx_payment_txs_payment ON payment_txs(payment_id)`; await sql`CREATE INDEX IF NOT EXISTS idx_payment_txs_payment ON payment_txs(payment_id)`;
await sql`CREATE INDEX IF NOT EXISTS idx_payment_txs_txid ON payment_txs(txid)`; await sql`CREATE INDEX IF NOT EXISTS idx_payment_txs_txid ON payment_txs(txid)`;
await sql`CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status)`;
await sql`CREATE INDEX IF NOT EXISTS idx_payments_account ON payments(account_id)`;
await sql`DROP INDEX IF EXISTS payments_derivation_index_key`;
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_coin_derivation ON payments(coin, derivation_index)`;
console.log("DB ready"); console.log("DB ready");
} }

View File

@ -517,10 +517,7 @@ export const dashboard = new Elysia()
WHERE account_id = ${accountId} AND enabled = true WHERE account_id = ${accountId} AND enabled = true
ORDER BY created_at DESC ORDER BY created_at DESC
`; `;
const attached = await sql<{ channel_id: string }[]>` // channel_ids is already on the monitor row as a UUID[] column
SELECT channel_id FROM monitor_notifications WHERE monitor_id = ${params.id}
`;
monitor.channel_ids = attached.map((a) => a.channel_id);
return html("detail", { nav: "monitors", monitor, pings, plan: resolved?.plan || "free", channels }); return html("detail", { nav: "monitors", monitor, pings, plan: resolved?.plan || "free", channels });
}) })

View File

@ -370,7 +370,7 @@
if (ping.jitter_ms != null) html += `<div class="text-gray-500">Jitter</div><div class="text-gray-300">${ping.jitter_ms}ms</div>`; if (ping.jitter_ms != null) html += `<div class="text-gray-500">Jitter</div><div class="text-gray-300">${ping.jitter_ms}ms</div>`;
if (ping.region) html += `<div class="text-gray-500">Region</div><div class="text-gray-300">${escapeHtml(ping.region)}</div>`; if (ping.region) html += `<div class="text-gray-500">Region</div><div class="text-gray-300">${escapeHtml(ping.region)}</div>`;
if (ping.run_id) html += `<div class="text-gray-500">Run ID</div><div class="text-gray-300 font-mono break-all">${escapeHtml(ping.run_id)}</div>`; if (ping.run_id) html += `<div class="text-gray-500">Run ID</div><div class="text-gray-300 font-mono break-all">${escapeHtml(ping.run_id)}</div>`;
if (meta.cert_expiry_days != null) html += `<div class="text-gray-500">Cert expiry</div><div class="text-gray-300">${meta.cert_expiry_days} days</div>`; if (ping.cert_expiry_days != null) html += `<div class="text-gray-500">Cert expiry</div><div class="text-gray-300">${ping.cert_expiry_days} days</div>`;
html += '</div>'; html += '</div>';
// Error // Error