refactor tier 3

This commit is contained in:
2026-04-08 15:26:17 +04:00
parent c74ee9856e
commit 5bf02b47d5
30 changed files with 2263 additions and 58 deletions
+119
View File
@@ -115,6 +115,125 @@ export async function migrate(sql: any) {
`;
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`
CREATE TABLE IF NOT EXISTS status_pages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
description TEXT,
theme TEXT NOT NULL DEFAULT 'auto',
password_hash TEXT,
index_search BOOLEAN NOT NULL DEFAULT true,
show_powered_by BOOLEAN NOT NULL DEFAULT true,
show_response_time BOOLEAN NOT NULL DEFAULT true,
show_cert_expiry BOOLEAN NOT NULL DEFAULT false,
default_window TEXT NOT NULL DEFAULT '24h',
custom_css TEXT,
footer_text TEXT,
og_image_url TEXT,
analytics_html TEXT,
auto_refresh_s INTEGER NOT NULL DEFAULT 60,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_status_pages_account ON status_pages(account_id)`;
await sql`
CREATE TABLE IF NOT EXISTS status_page_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_status_page_groups_page ON status_page_groups(status_page_id)`;
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,
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)`;
await sql`
CREATE TABLE IF NOT EXISTS incidents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
title TEXT NOT NULL,
status TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT 'minor',
pinned BOOLEAN NOT NULL DEFAULT true,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
resolved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_incidents_account ON incidents(account_id, started_at DESC)`;
await sql`
CREATE TABLE IF NOT EXISTS incident_updates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
status TEXT NOT NULL,
body TEXT NOT NULL,
body_html TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_incident_updates_incident ON incident_updates(incident_id, created_at)`;
await sql`
CREATE TABLE IF NOT EXISTS incident_monitors (
incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
PRIMARY KEY (incident_id, monitor_id)
)
`;
await sql`
CREATE TABLE IF NOT EXISTS incident_status_pages (
incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
PRIMARY KEY (incident_id, 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).
// Powers status page uptime windows AND any future dashboard widgets.
await sql`
CREATE TABLE IF NOT EXISTS monitor_uptime_rollup (
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
region TEXT NOT NULL,
bucket_type TEXT NOT NULL,
bucket_start TIMESTAMPTZ NOT NULL,
total INTEGER NOT NULL,
up_count INTEGER NOT NULL,
avg_latency REAL,
PRIMARY KEY (monitor_id, region, bucket_type, bucket_start)
)
`;
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_pings_monitor ON pings(monitor_id, checked_at DESC)`;
await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`;
+52
View File
@@ -0,0 +1,52 @@
// Shared sparkline utilities used by both apps/web (dashboard) and apps/status
// (public status pages). Pure HTML/SVG output, no client JS required for the
// first paint.
import { REGION_COLORS } from "../plans";
export function sparkline(values: number[], width = 120, height = 32, color = '#60a5fa', region = 'default'): string {
if (!values.length) return '';
const max = Math.max(...values, 1);
const min = Math.min(...values, 0);
const range = max - min || 1;
const step = width / Math.max(values.length - 1, 1);
const points = values.map((v, i) => {
const x = i * step;
const y = height - ((v - min) / range) * (height - 4) - 2;
return `${x},${y}`;
}).join(' ');
return `<svg width="${width}" height="${height}" class="inline-block" data-vals="${values.join(',')}" data-region="${region}"><polyline points="${points}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
}
export function pickBestRegion(pings: Array<{ latency_ms?: number | null; region?: string | null }>): { region: string; values: number[]; latest: number | null } {
const withLatency = pings.filter((p) => p.latency_ms != null);
if (!withLatency.length) return { region: 'default', values: [], latest: null };
const byRegion: Record<string, number[]> = {};
for (const p of withLatency) {
const key = p.region || 'default';
if (!byRegion[key]) byRegion[key] = [];
byRegion[key].push(p.latency_ms!);
}
const recentRegions = new Set(withLatency.slice(-3).map((p) => p.region || 'default'));
let bestRegion = 'default';
let bestAvg = Infinity;
for (const [region, vals] of Object.entries(byRegion)) {
if (!recentRegions.has(region)) continue;
const recent = vals.slice(-3);
const avg = recent.reduce((a, b) => a + b, 0) / recent.length;
if (avg < bestAvg) { bestAvg = avg; bestRegion = region; }
}
const values = byRegion[bestRegion] || [];
return { region: bestRegion, values, latest: values.length ? values[values.length - 1]! : null };
}
export function sparklineFromPings(pings: Array<{ latency_ms?: number | null; region?: string | null }>, width = 120, height = 32): string {
const { region, values } = pickBestRegion(pings);
if (!values.length) return '';
const color = REGION_COLORS[region] || '#60a5fa';
return sparkline(values, width, height, color, region);
}
+13
View File
@@ -0,0 +1,13 @@
// Server-rendered "X ago" timestamp. Returns an HTML span carrying the original
// epoch ms in a data attribute so a tiny client script can refresh it without a
// re-render. Reused by the dashboard and the public status pages.
export function timeAgoSSR(date: string | Date): string {
const ts = new Date(date).getTime();
const s = Math.ceil((Date.now() - ts) / 1000) || 1;
const text =
s < 60 ? `${s}s ago`
: s < 3600 ? `${Math.floor(s / 60)}m ago`
: s < 86400 ? `${Math.floor(s / 3600)}h ago`
: `${Math.floor(s / 86400)}d ago`;
return `<span class="timestamp" data-ts="${ts}">${text}</span>`;
}