refactor tier 3
This commit is contained in:
@@ -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)`;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user