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[]>`
SELECT c.id, c.account_id, c.name, c.kind, c.config, c.enabled
FROM notification_channels c
JOIN monitor_notifications mn ON mn.channel_id = c.id
WHERE mn.monitor_id = ${monitorId} AND c.enabled = true
WHERE c.id = ANY((SELECT channel_ids FROM monitors WHERE id = ${monitorId}))
AND c.enabled = true
`;
if (channels.length === 0) return;
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
`;
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"] } })

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." })),
});
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")}`;
function dedupeTags(tags: string[]): string[] {
return Array.from(new Set(tags.map((t) => t.trim()).filter(Boolean)));
}
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.
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[])
`;
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")}`;
return owned.map((o) => o.id);
}
export const monitors = new Elysia({ prefix: "/monitors" })
@ -54,10 +44,9 @@ export const monitors = new Elysia({ prefix: "/monitors" })
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
SELECT * FROM monitors
WHERE account_id = ${accountId} AND ${tag} = ANY(tags)
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);
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, 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 (
${accountId}, ${body.name}, ${body.url},
${(body.method ?? 'GET').toUpperCase()},
@ -106,12 +97,12 @@ export const monitors = new Elysia({ prefix: "/monitors" })
${body.resend_interval ?? 0},
${body.cert_alert_days ?? 0},
${body.query ? sql.json(body.query) : null},
${sql.array(regions)}
${sql.array(regions)},
${sql.array(tags)},
${sql.array(channelIds)}::uuid[]
)
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"] } })
@ -126,13 +117,7 @@ export const monitors = new Elysia({ prefix: "/monitors" })
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) };
return { ...monitor, results };
}, { detail: { summary: "Get monitor with results", tags: ["monitors"] } })
.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 }; }
}
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),
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),
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)
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),
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 *
`;
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"] } })

View File

@ -81,7 +81,6 @@ export const ingest = new Elysia()
if (!monitor_check) { set.status = 404; return { error: "Monitor not found" }; }
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;
delete meta.body_preview;
@ -133,7 +132,7 @@ export const ingest = new Elysia()
`;
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 (
${body.monitor_id},
${checkedAt ?? sql`now()`},
@ -146,7 +145,8 @@ export const ingest = new Elysia()
${body.error ?? null},
${Object.keys(meta).length > 0 ? sql.json(meta) : null},
${region},
${body.run_id ?? null}
${body.run_id ?? null},
${body.cert_expiry_days ?? null}
)
RETURNING *
`;

View File

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

View File

@ -1,15 +1,20 @@
export async function migrate(sql: any) {
await sql`CREATE EXTENSION IF NOT EXISTS pgcrypto`;
// ── accounts ──────────────────────────────────────────────────────────
await sql`
CREATE TABLE IF NOT EXISTS accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL UNIQUE,
email_hash TEXT,
created_at TIMESTAMPTZ DEFAULT now()
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL UNIQUE,
email_hash TEXT,
plan TEXT NOT NULL DEFAULT 'free',
plan_expires_at TIMESTAMPTZ,
plan_stack JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ DEFAULT now()
)
`;
// ── monitors ──────────────────────────────────────────────────────────
await sql`
CREATE TABLE IF NOT EXISTS monitors (
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,
query JSONB,
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`
CREATE TABLE IF NOT EXISTS pings (
id BIGSERIAL PRIMARY KEY,
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
scheduled_at TIMESTAMPTZ,
jitter_ms INTEGER,
status_code INTEGER,
latency_ms INTEGER,
up BOOLEAN NOT NULL,
error TEXT,
meta JSONB
id BIGSERIAL PRIMARY KEY,
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
scheduled_at TIMESTAMPTZ,
jitter_ms INTEGER,
status_code INTEGER,
latency_ms INTEGER,
up BOOLEAN NOT NULL,
important BOOLEAN NOT NULL DEFAULT false,
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`
CREATE TABLE IF NOT EXISTS ping_bodies (
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`
CREATE TABLE IF NOT EXISTS api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@ -59,24 +84,9 @@ export async function migrate(sql: any) {
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`;
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.
// ── monitor_region_state ──────────────────────────────────────────────
await sql`
CREATE TABLE IF NOT EXISTS monitor_region_state (
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)
)
`;
// 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`
CREATE TABLE IF NOT EXISTS notification_channels (
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 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.
// ── status_pages ──────────────────────────────────────────────────────
await sql`
CREATE TABLE IF NOT EXISTS status_pages (
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_cert_expiry BOOLEAN NOT NULL DEFAULT false,
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,
footer_text 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)`;
// 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`
CREATE TABLE IF NOT EXISTS status_page_groups (
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)`;
// ── status_page_monitors ──────────────────────────────────────────────
await sql`
CREATE TABLE IF NOT EXISTS status_page_monitors (
status_page_id UUID NOT NULL REFERENCES status_pages(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,
display_name TEXT,
display_mode TEXT,
position INTEGER NOT NULL DEFAULT 0,
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)`;
// 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`
CREATE TABLE IF NOT EXISTS incidents (
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)`;
// ── incident_updates ──────────────────────────────────────────────────
await sql`
CREATE TABLE IF NOT EXISTS incident_updates (
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)`;
// ── incident_monitors ─────────────────────────────────────────────────
await sql`
CREATE TABLE IF NOT EXISTS incident_monitors (
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)
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_incident_monitors_monitor ON incident_monitors(monitor_id)`;
// ── incident_status_pages ─────────────────────────────────────────────
await sql`
CREATE TABLE IF NOT EXISTS incident_status_pages (
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)`;
// Shared uptime rollup. One row per (monitor, region, bucket_type, bucket_start).
// Powers status page uptime windows AND any future dashboard widgets.
// ── monitor_uptime_rollup ─────────────────────────────────────────────
await sql`
CREATE TABLE IF NOT EXISTS monitor_uptime_rollup (
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)`;
// Watermark table for the rollup job. Each row records the most recent
// 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.
// ── rollup_watermarks ─────────────────────────────────────────────────
await sql`
CREATE TABLE IF NOT EXISTS rollup_watermarks (
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)`;
await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`;
// ── payments ──────────────────────────────────────────────────────────
await sql`
CREATE TABLE IF NOT EXISTS payments (
id BIGSERIAL PRIMARY KEY,
@ -269,16 +252,19 @@ export async function migrate(sql: any) {
address TEXT NOT NULL,
derivation_index INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
amount_received TEXT NOT NULL DEFAULT '0',
receipt_html TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
paid_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
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'`;
await sql`ALTER TABLE payments ADD COLUMN IF NOT EXISTS receipt_html TEXT`;
// ── payment_txs ───────────────────────────────────────────────────────
await sql`
CREATE TABLE IF NOT EXISTS payment_txs (
id BIGSERIAL PRIMARY KEY,
@ -290,13 +276,8 @@ export async function migrate(sql: any) {
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_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");
}

View File

@ -517,10 +517,7 @@ export const dashboard = new Elysia()
WHERE account_id = ${accountId} AND enabled = true
ORDER BY created_at DESC
`;
const attached = await sql<{ channel_id: string }[]>`
SELECT channel_id FROM monitor_notifications WHERE monitor_id = ${params.id}
`;
monitor.channel_ids = attached.map((a) => a.channel_id);
// channel_ids is already on the monitor row as a UUID[] column
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.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 (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>';
// Error