Merge remote-tracking branch 'origin/develop' into feat/pagespeed-mobile-strat

This commit is contained in:
Alex committed 2026-05-11 11:11:13 -07:00
commit 78fdc548bb
395 files changed
+60597 -29063

No files matched your search

-3
View File
@@ -1,7 +1,4 @@
import { Pool } from "pg";
export interface IDb {
connect(): Promise<void>;
disconnect(): Promise<void>;
getPool(): Pool | null;
}
+6 -7
View File
@@ -5,7 +5,6 @@ import { ILogger } from "@/utils/logger.js";
import { EnvConfig } from "@/service/system/settingsService.js";
const SERVICE_NAME = "MongoDB";
import { IDb } from "@/db/IDb.js";
import { Pool } from "pg";
class MongoDB implements IDb {
static SERVICE_NAME = SERVICE_NAME;
@@ -35,6 +34,12 @@ class MongoDB implements IDb {
setDefaultsOnInsert: true,
}
);
// Run migrations BEFORE syncIndexes. The 0006 dedupe migration must run before
// the unique index on MonitorStats.monitorId is enforced, otherwise databases
// with stray duplicates (caused by the pre-fix race in updateRunningStats)
// will crash at startup when the unique index fails to build.
await runMigrations();
// Sync indexes
const models = mongoose.modelNames();
for (const modelName of models) {
@@ -47,8 +52,6 @@ class MongoDB implements IDb {
service: SERVICE_NAME,
method: "connect",
});
await runMigrations();
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
@@ -75,10 +78,6 @@ class MongoDB implements IDb {
});
}
};
getPool = () => {
return null;
};
}
export default MongoDB;
-105
View File
@@ -1,105 +0,0 @@
import { IDb } from "@/db/IDb.js";
import { ILogger } from "@/utils/logger.js";
import { EnvConfig } from "@/service/system/settingsService.js";
import { Client, Pool } from "pg";
import { runTimescaleDBMigrations } from "@/db/migration/timescaledb/index.js";
const SERVICE_NAME = "TimescaleDB";
class TimescaleDB implements IDb {
private pool: Pool | null = null;
constructor(
private logger: ILogger,
private envSettings: EnvConfig
) {}
private ensureDatabaseExists = async (connectionString: string) => {
const url = new URL(connectionString);
const dbName = url.pathname.slice(1); // Remove leading "/"
// Connect to the default "postgres" database
url.pathname = "/postgres";
const client = new Client({ connectionString: url.toString() });
await client.connect();
const result = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [dbName]);
if (result.rowCount === 0) {
await client.query(`CREATE DATABASE "${dbName}"`);
this.logger.info({
message: `Created database "${dbName}"`,
service: SERVICE_NAME,
method: "ensureDatabaseExists",
});
}
await client.end();
};
connect = async () => {
try {
const connectionString = this.envSettings.dbConnectionString || "postgresql://postgres:password@localhost:5432/checkmate";
await this.ensureDatabaseExists(connectionString);
this.pool = new Pool({ connectionString });
const client = await this.pool.connect();
client.release();
// Ensure TimescaleDB extension is enabled
await this.pool.query("CREATE EXTENSION IF NOT EXISTS timescaledb;");
this.logger.info({
message: "Connected to TimescaleDB",
service: SERVICE_NAME,
method: "connect",
});
await runTimescaleDBMigrations(this.pool, this.logger);
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "connect",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
};
disconnect = async () => {
try {
if (this.pool === null) {
return;
}
this.logger.info({
message: "Disconnecting from TimescaleDB",
service: SERVICE_NAME,
method: "disconnect",
});
await this.pool.end();
this.pool = null;
this.logger.info({
message: "Disconnected from TimescaleDB",
service: SERVICE_NAME,
method: "disconnect",
});
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "disconnect",
stack: error instanceof Error ? error.stack : undefined,
});
}
};
getPool = (): Pool => {
if (this.pool === null) {
throw new Error("TimescaleDB is not connected. Call connect() first.");
}
return this.pool;
};
}
export default TimescaleDB;
@@ -0,0 +1,78 @@
import { MonitorStatsModel } from "../models/MonitorStats.js";
import { logger } from "@/utils/logger.js";
/**
* Second-pass dedupe of MonitorStats before the unique index on monitorId is enforced.
*
* 0003 ran the same dedupe, but the pre-fix race in StatusService.updateRunningStats
* could create new duplicates after 0003 completed. This migration re-runs the dedupe so
* the unique index added in the same PR can be built without failing.
*
* Keeps the document with the highest totalChecks for each monitorId (most data) and
* deletes the rest. Falls back to most recent updatedAt when totalChecks tie.
*/
export async function cleanupDuplicateMonitorStatsForUniqueIndex(): Promise<void> {
const SERVICE_NAME = "Migration:CleanupDuplicateMonitorStatsForUniqueIndex";
try {
logger.info({ service: SERVICE_NAME, message: "Starting pre-unique-index dedupe of MonitorStats" });
const duplicates = await MonitorStatsModel.aggregate([
{
$group: {
_id: "$monitorId",
docs: { $push: { id: "$_id", totalChecks: "$totalChecks", updatedAt: "$updatedAt" } },
count: { $sum: 1 },
},
},
{ $match: { count: { $gt: 1 } } },
]);
if (duplicates.length === 0) {
logger.info({ service: SERVICE_NAME, message: "No duplicate MonitorStats found" });
return;
}
logger.info({
service: SERVICE_NAME,
message: `Found ${duplicates.length} monitors with duplicate stats`,
});
let totalDeleted = 0;
type DocInfo = { id: string; totalChecks: number; updatedAt: Date };
for (const duplicate of duplicates) {
const monitorId = duplicate._id;
const docs: DocInfo[] = duplicate.docs;
// Sort by totalChecks desc, then updatedAt desc — keep the winner, delete the rest.
docs.sort((a, b) => {
const checksDiff = (b.totalChecks ?? 0) - (a.totalChecks ?? 0);
if (checksDiff !== 0) return checksDiff;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
const toDelete = docs.slice(1).map((doc) => doc.id);
if (toDelete.length > 0) {
const result = await MonitorStatsModel.deleteMany({ _id: { $in: toDelete } });
totalDeleted += result.deletedCount ?? 0;
logger.debug({
service: SERVICE_NAME,
message: `Deleted ${result.deletedCount} duplicate stats for monitor ${monitorId}`,
});
}
}
logger.info({
service: SERVICE_NAME,
message: `Cleanup complete. Deleted ${totalDeleted} duplicate MonitorStats documents`,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({ service: SERVICE_NAME, message: `Error during MonitorStats dedupe: ${errorMessage}` });
throw error;
}
}
+2
View File
@@ -4,6 +4,7 @@ import { cleanupDuplicateMonitorStats } from "./0003_cleanupDuplicateMonitorStat
import { fixInfrastructureThresholds } from "./0004_fixInfrastructureThresholds.js";
import MigrationModel from "../models/Migration.js";
import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArray.js";
import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js";
import type { ILogger } from "@/utils/logger.js";
type MigrationEntry = {
@@ -17,6 +18,7 @@ const migrations: MigrationEntry[] = [
{ name: "0003_cleanupDuplicateMonitorStats", execute: cleanupDuplicateMonitorStats },
{ name: "0004_fixInfrastructureThresholds", execute: fixInfrastructureThresholds },
{ name: "0005_migrateStatusPageTypeToArray", execute: migrateStatusPageTypeToArray },
{ name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex },
];
const runMigrations = async (logger?: ILogger) => {
@@ -1,50 +0,0 @@
import type { Pool } from "pg";
export const createEnums = async (pool: Pool) => {
await pool.query(`
DO $$ BEGIN
CREATE TYPE monitor_type AS ENUM ('http', 'ping', 'pagespeed', 'hardware', 'docker', 'port', 'game', 'grpc', 'websocket', 'unknown');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE monitor_status AS ENUM ('up', 'down', 'paused', 'initializing', 'maintenance', 'breached');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE notification_type AS ENUM ('email', 'slack', 'discord', 'webhook', 'pager_duty', 'matrix', 'teams');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE user_role AS ENUM ('user', 'admin', 'superadmin', 'demo');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE incident_resolution_type AS ENUM ('automatic', 'manual');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE duration_unit AS ENUM ('seconds', 'minutes', 'hours', 'days');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE status_page_type AS ENUM ('uptime', 'infrastructure');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE match_method AS ENUM ('equal', 'include', 'regex');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
`);
};
export const dropEnums = async (pool: Pool) => {
await pool.query(`
DROP TYPE IF EXISTS match_method;
DROP TYPE IF EXISTS status_page_type;
DROP TYPE IF EXISTS duration_unit;
DROP TYPE IF EXISTS incident_resolution_type;
DROP TYPE IF EXISTS user_role;
DROP TYPE IF EXISTS notification_type;
DROP TYPE IF EXISTS monitor_status;
DROP TYPE IF EXISTS monitor_type;
`);
};
@@ -1,18 +0,0 @@
import type { Pool } from "pg";
export const createTeams = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_teams_email ON teams (email);
`);
};
export const dropTeams = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS teams;`);
};
@@ -1,30 +0,0 @@
import type { Pool } from "pg";
export const createUsers = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
avatar_image TEXT,
profile_image BYTEA,
profile_image_content_type TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
roles user_role[] NOT NULL DEFAULT ARRAY['user']::user_role[],
check_ttl INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_users_team_id ON users (team_id);
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
`);
};
export const dropUsers = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS users;`);
};
@@ -1,69 +0,0 @@
import type { Pool } from "pg";
export const createMonitors = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS monitors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
team_id UUID NOT NULL REFERENCES teams(id),
name TEXT NOT NULL,
description TEXT,
type monitor_type NOT NULL,
status monitor_status NOT NULL DEFAULT 'initializing',
-- HTTP / Website monitoring
url TEXT,
port INTEGER,
ignore_tls_errors BOOLEAN DEFAULT FALSE,
use_advanced_matching BOOLEAN DEFAULT FALSE,
json_path TEXT,
expected_value TEXT,
match_method match_method,
secret TEXT,
-- Scheduling
interval_ms INTEGER NOT NULL DEFAULT 60000,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
-- Uptime tracking
status_window BOOLEAN[],
status_window_size INTEGER DEFAULT 5,
status_window_threshold INTEGER DEFAULT 60,
uptime_percentage DOUBLE PRECISION,
-- Infrastructure alert thresholds
cpu_alert_threshold INTEGER,
cpu_alert_counter INTEGER,
memory_alert_threshold INTEGER,
memory_alert_counter INTEGER,
disk_alert_threshold INTEGER,
disk_alert_counter INTEGER,
temp_alert_threshold INTEGER,
temp_alert_counter INTEGER,
selected_disks TEXT[],
-- Game monitoring
game_id TEXT,
grpc_service_name TEXT,
-- Grouping
monitor_group TEXT,
-- Geo checks
geo_check_enabled BOOLEAN DEFAULT FALSE,
geo_check_locations TEXT[],
geo_check_interval_ms INTEGER DEFAULT 300000,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_monitors_team_type ON monitors (team_id, type);
CREATE INDEX IF NOT EXISTS idx_monitors_user_id ON monitors (user_id);
CREATE INDEX IF NOT EXISTS idx_monitors_status ON monitors (status);
`);
};
export const dropMonitors = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS monitors;`);
};
@@ -1,30 +0,0 @@
import type { Pool } from "pg";
export const createNotifications = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
team_id UUID NOT NULL REFERENCES teams(id),
type notification_type NOT NULL,
notification_name TEXT,
address TEXT,
phone TEXT,
-- Matrix-specific fields
homeserver_url TEXT,
room_id TEXT,
access_token TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_notifications_team ON notifications (team_id);
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id);
`);
};
export const dropNotifications = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS notifications;`);
};
@@ -1,18 +0,0 @@
import type { Pool } from "pg";
export const createMonitorNotifications = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS monitor_notifications (
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
notification_id UUID NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
PRIMARY KEY (monitor_id, notification_id)
);
CREATE INDEX IF NOT EXISTS idx_monitor_notifications_notification
ON monitor_notifications (notification_id);
`);
};
export const dropMonitorNotifications = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS monitor_notifications;`);
};
@@ -1,34 +0,0 @@
import type { Pool } from "pg";
export const createIncidents = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS incidents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ,
status BOOLEAN,
message TEXT,
status_code INTEGER,
resolution_type incident_resolution_type,
resolved_by UUID REFERENCES users(id) ON DELETE SET NULL,
resolved_by_email TEXT,
comment TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_incidents_monitor_status ON incidents (monitor_id, status);
CREATE INDEX IF NOT EXISTS idx_incidents_team_status ON incidents (team_id, status);
CREATE INDEX IF NOT EXISTS idx_incidents_team_start ON incidents (team_id, start_time DESC);
CREATE INDEX IF NOT EXISTS idx_incidents_status_start ON incidents (status, start_time DESC);
CREATE INDEX IF NOT EXISTS idx_incidents_resolution ON incidents (resolution_type, status);
CREATE INDEX IF NOT EXISTS idx_incidents_resolved_by ON incidents (resolved_by, status);
CREATE INDEX IF NOT EXISTS idx_incidents_created ON incidents (created_at DESC);
`);
};
export const dropIncidents = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS incidents;`);
};
@@ -1,25 +0,0 @@
import type { Pool } from "pg";
export const createMonitorStats = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS monitor_stats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL UNIQUE REFERENCES monitors(id) ON DELETE CASCADE,
avg_response_time DOUBLE PRECISION DEFAULT 0,
max_response_time DOUBLE PRECISION DEFAULT 0,
total_checks BIGINT DEFAULT 0,
total_up_checks BIGINT DEFAULT 0,
total_down_checks BIGINT DEFAULT 0,
uptime_percentage DOUBLE PRECISION DEFAULT 0,
last_check_timestamp TIMESTAMPTZ,
last_response_time DOUBLE PRECISION DEFAULT 0,
time_of_last_failure TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
};
export const dropMonitorStats = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS monitor_stats;`);
};
@@ -1,105 +0,0 @@
import type { Pool } from "pg";
export const createChecks = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS checks (
id UUID NOT NULL DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL,
team_id UUID NOT NULL,
monitor_type monitor_type NOT NULL,
status BOOLEAN,
response_time INTEGER,
status_code INTEGER,
message TEXT,
-- HTTP request timings
timing_start DOUBLE PRECISION,
timing_socket DOUBLE PRECISION,
timing_lookup DOUBLE PRECISION,
timing_connect DOUBLE PRECISION,
timing_secure_connect DOUBLE PRECISION,
timing_upload DOUBLE PRECISION,
timing_response DOUBLE PRECISION,
timing_end DOUBLE PRECISION,
phase_wait DOUBLE PRECISION,
phase_dns DOUBLE PRECISION,
phase_tcp DOUBLE PRECISION,
phase_tls DOUBLE PRECISION,
phase_request DOUBLE PRECISION,
phase_first_byte DOUBLE PRECISION,
phase_download DOUBLE PRECISION,
phase_total DOUBLE PRECISION,
-- CPU metrics
cpu_physical_core INTEGER,
cpu_logical_core INTEGER,
cpu_frequency DOUBLE PRECISION,
cpu_current_frequency DOUBLE PRECISION,
cpu_temperature DOUBLE PRECISION[],
cpu_free_percent DOUBLE PRECISION,
cpu_usage_percent DOUBLE PRECISION,
-- Memory metrics
mem_total_bytes BIGINT,
mem_available_bytes BIGINT,
mem_used_bytes BIGINT,
mem_usage_percent DOUBLE PRECISION,
-- Host info
host_os TEXT,
host_platform TEXT,
host_kernel_version TEXT,
host_pretty_name TEXT,
-- Capture agent info
capture_version TEXT,
capture_mode TEXT,
-- Lighthouse / PageSpeed scores
lighthouse_performance DOUBLE PRECISION,
lighthouse_accessibility DOUBLE PRECISION,
lighthouse_best_practices DOUBLE PRECISION,
lighthouse_seo DOUBLE PRECISION,
-- Lighthouse audit details
audit_cls_score DOUBLE PRECISION,
audit_cls_value DOUBLE PRECISION,
audit_cls_display TEXT,
audit_si_score DOUBLE PRECISION,
audit_si_value DOUBLE PRECISION,
audit_si_display TEXT,
audit_fcp_score DOUBLE PRECISION,
audit_fcp_value DOUBLE PRECISION,
audit_fcp_display TEXT,
audit_lcp_score DOUBLE PRECISION,
audit_lcp_value DOUBLE PRECISION,
audit_lcp_display TEXT,
audit_tbt_score DOUBLE PRECISION,
audit_tbt_value DOUBLE PRECISION,
audit_tbt_display TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
SELECT create_hypertable('checks', 'created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_checks_monitor_time
ON checks (monitor_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_checks_team_time
ON checks (team_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_checks_monitor_type_time
ON checks (monitor_id, monitor_type, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_checks_team_status_time
ON checks (team_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_checks_status_code
ON checks (status_code, created_at DESC);
`);
};
export const dropChecks = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS checks;`);
};
@@ -1,37 +0,0 @@
import type { Pool } from "pg";
export const createCheckDisks = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS check_disks (
id UUID NOT NULL DEFAULT gen_random_uuid(),
check_id UUID NOT NULL,
check_created_at TIMESTAMPTZ NOT NULL,
device TEXT,
mountpoint TEXT,
total_bytes BIGINT,
free_bytes BIGINT,
used_bytes BIGINT,
usage_percent DOUBLE PRECISION,
total_inodes BIGINT,
free_inodes BIGINT,
used_inodes BIGINT,
inodes_usage_percent DOUBLE PRECISION,
read_bytes BIGINT,
write_bytes BIGINT,
read_time BIGINT,
write_time BIGINT
);
SELECT create_hypertable('check_disks', 'check_created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_check_disks_check
ON check_disks (check_id, check_created_at);
`);
};
export const dropCheckDisks = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS check_disks;`);
};
@@ -1,34 +0,0 @@
import type { Pool } from "pg";
export const createCheckNetworkInterfaces = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS check_network_interfaces (
id UUID NOT NULL DEFAULT gen_random_uuid(),
check_id UUID NOT NULL,
check_created_at TIMESTAMPTZ NOT NULL,
name TEXT,
bytes_sent BIGINT,
bytes_recv BIGINT,
packets_sent BIGINT,
packets_recv BIGINT,
err_in BIGINT,
err_out BIGINT,
drop_in BIGINT,
drop_out BIGINT,
fifo_in BIGINT,
fifo_out BIGINT
);
SELECT create_hypertable('check_network_interfaces', 'check_created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_check_net_check
ON check_network_interfaces (check_id, check_created_at);
`);
};
export const dropCheckNetworkInterfaces = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS check_network_interfaces;`);
};
@@ -1,25 +0,0 @@
import type { Pool } from "pg";
export const createCheckErrors = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS check_errors (
id UUID NOT NULL DEFAULT gen_random_uuid(),
check_id UUID NOT NULL,
check_created_at TIMESTAMPTZ NOT NULL,
metrics TEXT[],
error TEXT
);
SELECT create_hypertable('check_errors', 'check_created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_check_errors_check
ON check_errors (check_id, check_created_at);
`);
};
export const dropCheckErrors = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS check_errors;`);
};
@@ -1,29 +0,0 @@
import type { Pool } from "pg";
export const createGeoChecks = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS geo_checks (
id UUID NOT NULL DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL,
team_id UUID NOT NULL,
monitor_type monitor_type NOT NULL,
expiry TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
SELECT create_hypertable('geo_checks', 'created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_geo_checks_monitor_time
ON geo_checks (monitor_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_geo_checks_team_time
ON geo_checks (team_id, created_at DESC);
`);
};
export const dropGeoChecks = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS geo_checks;`);
};
@@ -1,46 +0,0 @@
import type { Pool } from "pg";
export const createGeoCheckResults = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS geo_check_results (
id UUID NOT NULL DEFAULT gen_random_uuid(),
geo_check_id UUID NOT NULL,
geo_check_created_at TIMESTAMPTZ NOT NULL,
-- Status
status BOOLEAN,
status_code INTEGER,
-- Location
location_continent TEXT,
location_region TEXT,
location_country TEXT,
location_state TEXT,
location_city TEXT,
location_longitude DOUBLE PRECISION,
location_latitude DOUBLE PRECISION,
-- Timings
timing_total DOUBLE PRECISION,
timing_dns DOUBLE PRECISION,
timing_tcp DOUBLE PRECISION,
timing_tls DOUBLE PRECISION,
timing_first_byte DOUBLE PRECISION,
timing_download DOUBLE PRECISION
);
SELECT create_hypertable('geo_check_results', 'geo_check_created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_geo_results_check
ON geo_check_results (geo_check_id, geo_check_created_at);
CREATE INDEX IF NOT EXISTS idx_geo_results_country
ON geo_check_results (location_country, geo_check_created_at DESC);
`);
};
export const dropGeoCheckResults = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS geo_check_results;`);
};
@@ -1,22 +0,0 @@
import type { Pool } from "pg";
export const createInvites = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
roles user_role[] NOT NULL DEFAULT ARRAY['user']::user_role[],
token TEXT NOT NULL,
expiry TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_invites_expiry ON invites (expiry);
`);
};
export const dropInvites = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS invites;`);
};
@@ -1,20 +0,0 @@
import type { Pool } from "pg";
export const createRecoveryTokens = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS recovery_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
token TEXT NOT NULL,
expiry TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_recovery_expiry ON recovery_tokens (expiry);
`);
};
export const dropRecoveryTokens = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS recovery_tokens;`);
};
@@ -1,29 +0,0 @@
import type { Pool } from "pg";
export const createMaintenanceWindows = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS maintenance_windows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
active BOOLEAN NOT NULL DEFAULT TRUE,
name TEXT,
duration INTEGER,
duration_unit duration_unit,
repeat INTEGER,
start_time TIMESTAMPTZ,
end_time TIMESTAMPTZ,
expiry TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_maint_monitor ON maintenance_windows (monitor_id);
CREATE INDEX IF NOT EXISTS idx_maint_team ON maintenance_windows (team_id);
CREATE INDEX IF NOT EXISTS idx_maint_expiry ON maintenance_windows (expiry);
`);
};
export const dropMaintenanceWindows = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS maintenance_windows;`);
};
@@ -1,51 +0,0 @@
import type { Pool } from "pg";
export const createStatusPages = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS status_pages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
team_id UUID NOT NULL REFERENCES teams(id),
types status_page_type[] NOT NULL DEFAULT ARRAY['uptime']::status_page_type[],
company_name TEXT,
url TEXT NOT NULL UNIQUE,
timezone TEXT,
color TEXT DEFAULT '#4169E1',
logo_data BYTEA,
logo_content_type TEXT,
is_published BOOLEAN DEFAULT FALSE,
show_charts BOOLEAN DEFAULT FALSE,
show_uptime_percentage BOOLEAN DEFAULT FALSE,
show_admin_login_link BOOLEAN DEFAULT FALSE,
show_infrastructure BOOLEAN DEFAULT FALSE,
custom_css TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_status_pages_team ON status_pages (team_id);
CREATE INDEX IF NOT EXISTS idx_status_pages_url ON status_pages (url);
CREATE TABLE IF NOT EXISTS status_page_monitors (
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
sort_order INTEGER DEFAULT 0,
PRIMARY KEY (status_page_id, monitor_id)
);
CREATE TABLE IF NOT EXISTS status_page_sub_monitors (
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
sort_order INTEGER DEFAULT 0,
PRIMARY KEY (status_page_id, monitor_id)
);
`);
};
export const dropStatusPages = async (pool: Pool) => {
await pool.query(`
DROP TABLE IF EXISTS status_page_sub_monitors;
DROP TABLE IF EXISTS status_page_monitors;
DROP TABLE IF EXISTS status_pages;
`);
};
@@ -1,62 +0,0 @@
import type { Pool } from "pg";
export const createAppSettings = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS app_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
check_ttl INTEGER DEFAULT 30,
language TEXT,
jwt_secret TEXT,
pagespeed_api_key TEXT,
-- SMTP / Email settings
system_email_host TEXT,
system_email_port INTEGER,
system_email_address TEXT,
system_email_password TEXT,
system_email_user TEXT,
system_email_connection_host TEXT,
system_email_tls_servername TEXT,
system_email_secure BOOLEAN,
system_email_pool BOOLEAN,
system_email_ignore_tls BOOLEAN,
system_email_require_tls BOOLEAN,
system_email_reject_unauthorized BOOLEAN,
show_url BOOLEAN,
version TEXT,
-- Global infrastructure thresholds
threshold_cpu_usage INTEGER,
threshold_memory_usage INTEGER,
threshold_disk_usage INTEGER,
threshold_temperature INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Enforce singleton: only one row allowed
CREATE OR REPLACE FUNCTION enforce_singleton_app_settings()
RETURNS TRIGGER AS $$
BEGIN
IF (SELECT COUNT(*) FROM app_settings) >= 1 THEN
RAISE EXCEPTION 'app_settings allows only one row';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_singleton_app_settings ON app_settings;
CREATE TRIGGER trg_singleton_app_settings
BEFORE INSERT ON app_settings
FOR EACH ROW EXECUTE FUNCTION enforce_singleton_app_settings();
`);
};
export const dropAppSettings = async (pool: Pool) => {
await pool.query(`
DROP TABLE IF EXISTS app_settings;
DROP FUNCTION IF EXISTS enforce_singleton_app_settings();
`);
};
@@ -1,127 +0,0 @@
import type { Pool } from "pg";
export const createContinuousAggregates = async (pool: Pool) => {
// Uptime & Response Time (Hourly)
await pool.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS checks_hourly
WITH (timescaledb.continuous) AS
SELECT
monitor_id,
time_bucket('1 hour', created_at) AS bucket,
COUNT(*) AS total_checks,
COUNT(*) FILTER (WHERE status = TRUE) AS up_checks,
COUNT(*) FILTER (WHERE status = FALSE) AS down_checks,
AVG(response_time) AS avg_response_time,
AVG(response_time) FILTER (WHERE status = TRUE) AS avg_up_response_time,
AVG(response_time) FILTER (WHERE status = FALSE) AS avg_down_response_time,
MAX(response_time) AS max_response_time,
MIN(response_time) AS min_response_time
FROM checks
GROUP BY monitor_id, bucket
WITH NO DATA;
`);
await pool.query(`ALTER MATERIALIZED VIEW checks_hourly SET (timescaledb.materialized_only = false);`);
await pool.query(`
SELECT add_continuous_aggregate_policy('checks_hourly',
start_offset => INTERVAL '3 hours',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour',
if_not_exists => TRUE
);
`);
await pool.query(`CALL refresh_continuous_aggregate('checks_hourly', NULL, NULL);`);
// Uptime & Response Time (Daily)
await pool.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS checks_daily
WITH (timescaledb.continuous) AS
SELECT
monitor_id,
time_bucket('1 day', created_at) AS bucket,
COUNT(*) AS total_checks,
COUNT(*) FILTER (WHERE status = TRUE) AS up_checks,
COUNT(*) FILTER (WHERE status = FALSE) AS down_checks,
AVG(response_time) AS avg_response_time,
AVG(response_time) FILTER (WHERE status = TRUE) AS avg_up_response_time,
AVG(response_time) FILTER (WHERE status = FALSE) AS avg_down_response_time,
MAX(response_time) AS max_response_time
FROM checks
GROUP BY monitor_id, bucket
WITH NO DATA;
`);
await pool.query(`ALTER MATERIALIZED VIEW checks_daily SET (timescaledb.materialized_only = false);`);
await pool.query(`
SELECT add_continuous_aggregate_policy('checks_daily',
start_offset => INTERVAL '3 days',
end_offset => INTERVAL '1 day',
schedule_interval => INTERVAL '1 day',
if_not_exists => TRUE
);
`);
await pool.query(`CALL refresh_continuous_aggregate('checks_daily', NULL, NULL);`);
// Infrastructure Metrics (Hourly)
await pool.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS hardware_hourly
WITH (timescaledb.continuous) AS
SELECT
monitor_id,
time_bucket('1 hour', created_at) AS bucket,
AVG(cpu_usage_percent) AS avg_cpu,
MAX(cpu_usage_percent) AS max_cpu,
AVG(mem_usage_percent) AS avg_memory,
MAX(mem_usage_percent) AS max_memory,
COUNT(*) AS sample_count
FROM checks
WHERE monitor_type = 'hardware'
GROUP BY monitor_id, bucket
WITH NO DATA;
`);
await pool.query(`ALTER MATERIALIZED VIEW hardware_hourly SET (timescaledb.materialized_only = false);`);
await pool.query(`
SELECT add_continuous_aggregate_policy('hardware_hourly',
start_offset => INTERVAL '3 hours',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour',
if_not_exists => TRUE
);
`);
await pool.query(`CALL refresh_continuous_aggregate('hardware_hourly', NULL, NULL);`);
// PageSpeed Scores (Daily)
await pool.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS pagespeed_daily
WITH (timescaledb.continuous) AS
SELECT
monitor_id,
time_bucket('1 day', created_at) AS bucket,
AVG(lighthouse_performance) AS avg_performance,
AVG(lighthouse_accessibility) AS avg_accessibility,
AVG(lighthouse_best_practices) AS avg_best_practices,
AVG(lighthouse_seo) AS avg_seo,
COUNT(*) AS sample_count
FROM checks
WHERE monitor_type = 'pagespeed'
GROUP BY monitor_id, bucket
WITH NO DATA;
`);
await pool.query(`ALTER MATERIALIZED VIEW pagespeed_daily SET (timescaledb.materialized_only = false);`);
await pool.query(`
SELECT add_continuous_aggregate_policy('pagespeed_daily',
start_offset => INTERVAL '3 days',
end_offset => INTERVAL '1 day',
schedule_interval => INTERVAL '1 day',
if_not_exists => TRUE
);
`);
await pool.query(`CALL refresh_continuous_aggregate('pagespeed_daily', NULL, NULL);`);
};
export const dropContinuousAggregates = async (pool: Pool) => {
await pool.query(`
DROP MATERIALIZED VIEW IF EXISTS pagespeed_daily CASCADE;
DROP MATERIALIZED VIEW IF EXISTS hardware_hourly CASCADE;
DROP MATERIALIZED VIEW IF EXISTS checks_daily CASCADE;
DROP MATERIALIZED VIEW IF EXISTS checks_hourly CASCADE;
`);
};
@@ -1,135 +0,0 @@
import type { Pool } from "pg";
export const createRetentionCompression = async (pool: Pool) => {
// -- Compression policies --
// Checks
await pool.query(`
ALTER TABLE checks SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'monitor_id',
timescaledb.compress_orderby = 'created_at DESC'
);
SELECT add_compression_policy('checks', INTERVAL '2 days', if_not_exists => TRUE);
`);
// Check child tables
await pool.query(`
ALTER TABLE check_disks SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'check_id',
timescaledb.compress_orderby = 'check_created_at DESC'
);
SELECT add_compression_policy('check_disks', INTERVAL '2 days', if_not_exists => TRUE);
`);
await pool.query(`
ALTER TABLE check_network_interfaces SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'check_id',
timescaledb.compress_orderby = 'check_created_at DESC'
);
SELECT add_compression_policy('check_network_interfaces', INTERVAL '2 days', if_not_exists => TRUE);
`);
await pool.query(`
ALTER TABLE check_errors SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'check_id',
timescaledb.compress_orderby = 'check_created_at DESC'
);
SELECT add_compression_policy('check_errors', INTERVAL '2 days', if_not_exists => TRUE);
`);
// Geo checks
await pool.query(`
ALTER TABLE geo_checks SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'monitor_id',
timescaledb.compress_orderby = 'created_at DESC'
);
SELECT add_compression_policy('geo_checks', INTERVAL '2 days', if_not_exists => TRUE);
`);
await pool.query(`
ALTER TABLE geo_check_results SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'geo_check_id',
timescaledb.compress_orderby = 'geo_check_created_at DESC'
);
SELECT add_compression_policy('geo_check_results', INTERVAL '2 days', if_not_exists => TRUE);
`);
// -- Retention policies (default 30 days) --
await pool.query(`
SELECT add_retention_policy('checks', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_retention_policy('check_disks', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_retention_policy('check_network_interfaces', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_retention_policy('check_errors', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_retention_policy('geo_checks', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_retention_policy('geo_check_results', INTERVAL '30 days', if_not_exists => TRUE);
`);
// -- Dynamic retention helper function --
await pool.query(`
CREATE OR REPLACE FUNCTION update_check_retention(new_ttl_days INTEGER)
RETURNS VOID AS $$
DECLARE
ttl_interval INTERVAL;
BEGIN
ttl_interval := (new_ttl_days || ' days')::INTERVAL;
PERFORM remove_retention_policy('checks', if_exists => true);
PERFORM remove_retention_policy('check_disks', if_exists => true);
PERFORM remove_retention_policy('check_network_interfaces', if_exists => true);
PERFORM remove_retention_policy('check_errors', if_exists => true);
PERFORM remove_retention_policy('geo_checks', if_exists => true);
PERFORM remove_retention_policy('geo_check_results', if_exists => true);
PERFORM add_retention_policy('checks', ttl_interval);
PERFORM add_retention_policy('check_disks', ttl_interval);
PERFORM add_retention_policy('check_network_interfaces', ttl_interval);
PERFORM add_retention_policy('check_errors', ttl_interval);
PERFORM add_retention_policy('geo_checks', ttl_interval);
PERFORM add_retention_policy('geo_check_results', ttl_interval);
END;
$$ LANGUAGE plpgsql;
`);
};
export const dropRetentionCompression = async (pool: Pool) => {
// Remove retention policies
await pool.query(`
SELECT remove_retention_policy('checks', if_exists => true);
SELECT remove_retention_policy('check_disks', if_exists => true);
SELECT remove_retention_policy('check_network_interfaces', if_exists => true);
SELECT remove_retention_policy('check_errors', if_exists => true);
SELECT remove_retention_policy('geo_checks', if_exists => true);
SELECT remove_retention_policy('geo_check_results', if_exists => true);
`);
// Remove compression policies
await pool.query(`
SELECT remove_compression_policy('checks', if_exists => true);
SELECT remove_compression_policy('check_disks', if_exists => true);
SELECT remove_compression_policy('check_network_interfaces', if_exists => true);
SELECT remove_compression_policy('check_errors', if_exists => true);
SELECT remove_compression_policy('geo_checks', if_exists => true);
SELECT remove_compression_policy('geo_check_results', if_exists => true);
`);
// Disable compression on tables
await pool.query(`
ALTER TABLE checks SET (timescaledb.compress = false);
ALTER TABLE check_disks SET (timescaledb.compress = false);
ALTER TABLE check_network_interfaces SET (timescaledb.compress = false);
ALTER TABLE check_errors SET (timescaledb.compress = false);
ALTER TABLE geo_checks SET (timescaledb.compress = false);
ALTER TABLE geo_check_results SET (timescaledb.compress = false);
`);
// Drop dynamic retention function
await pool.query(`DROP FUNCTION IF EXISTS update_check_retention(INTEGER);`);
};
@@ -1,150 +0,0 @@
import type { Pool } from "pg";
import type { ILogger } from "@/utils/logger.js";
import { createEnums, dropEnums } from "./0001_create_enums.js";
import { createTeams, dropTeams } from "./0002_create_teams.js";
import { createUsers, dropUsers } from "./0003_create_users.js";
import { createMonitors, dropMonitors } from "./0004_create_monitors.js";
import { createNotifications, dropNotifications } from "./0005_create_notifications.js";
import { createMonitorNotifications, dropMonitorNotifications } from "./0006_create_monitor_notifications.js";
import { createIncidents, dropIncidents } from "./0007_create_incidents.js";
import { createMonitorStats, dropMonitorStats } from "./0008_create_monitor_stats.js";
import { createChecks, dropChecks } from "./0009_create_checks.js";
import { createCheckDisks, dropCheckDisks } from "./0010_create_check_disks.js";
import { createCheckNetworkInterfaces, dropCheckNetworkInterfaces } from "./0011_create_check_network_interfaces.js";
import { createCheckErrors, dropCheckErrors } from "./0012_create_check_errors.js";
import { createGeoChecks, dropGeoChecks } from "./0013_create_geo_checks.js";
import { createGeoCheckResults, dropGeoCheckResults } from "./0014_create_geo_check_results.js";
import { createInvites, dropInvites } from "./0015_create_invites.js";
import { createRecoveryTokens, dropRecoveryTokens } from "./0016_create_recovery_tokens.js";
import { createMaintenanceWindows, dropMaintenanceWindows } from "./0017_create_maintenance_windows.js";
import { createStatusPages, dropStatusPages } from "./0018_create_status_pages.js";
import { createAppSettings, dropAppSettings } from "./0019_create_app_settings.js";
import { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.js";
import { createRetentionCompression, dropRetentionCompression } from "./0021_create_retention_compression.js";
import { addStrategyToMonitors, dropStrategyFromMonitors } from "./0022_add_strategy_to_monitors.js";
const SERVICE_NAME = "TimescaleDB Migrations";
type MigrationEntry = {
name: string;
up: (pool: Pool) => Promise<void>;
down: (pool: Pool) => Promise<void>;
};
const migrations: MigrationEntry[] = [
{ name: "0001_create_enums", up: createEnums, down: dropEnums },
{ name: "0002_create_teams", up: createTeams, down: dropTeams },
{ name: "0003_create_users", up: createUsers, down: dropUsers },
{ name: "0004_create_monitors", up: createMonitors, down: dropMonitors },
{ name: "0005_create_notifications", up: createNotifications, down: dropNotifications },
{ name: "0006_create_monitor_notifications", up: createMonitorNotifications, down: dropMonitorNotifications },
{ name: "0007_create_incidents", up: createIncidents, down: dropIncidents },
{ name: "0008_create_monitor_stats", up: createMonitorStats, down: dropMonitorStats },
{ name: "0009_create_checks", up: createChecks, down: dropChecks },
{ name: "0010_create_check_disks", up: createCheckDisks, down: dropCheckDisks },
{ name: "0011_create_check_network_interfaces", up: createCheckNetworkInterfaces, down: dropCheckNetworkInterfaces },
{ name: "0012_create_check_errors", up: createCheckErrors, down: dropCheckErrors },
{ name: "0013_create_geo_checks", up: createGeoChecks, down: dropGeoChecks },
{ name: "0014_create_geo_check_results", up: createGeoCheckResults, down: dropGeoCheckResults },
{ name: "0015_create_invites", up: createInvites, down: dropInvites },
{ name: "0016_create_recovery_tokens", up: createRecoveryTokens, down: dropRecoveryTokens },
{ name: "0017_create_maintenance_windows", up: createMaintenanceWindows, down: dropMaintenanceWindows },
{ name: "0018_create_status_pages", up: createStatusPages, down: dropStatusPages },
{ name: "0019_create_app_settings", up: createAppSettings, down: dropAppSettings },
{ name: "0020_create_continuous_aggregates", up: createContinuousAggregates, down: dropContinuousAggregates },
{ name: "0021_create_retention_compression", up: createRetentionCompression, down: dropRetentionCompression },
{ name: "0022_add_strategy_to_monitors", up: addStrategyToMonitors, down: dropStrategyFromMonitors },
];
const ensureMigrationsTable = async (pool: Pool) => {
await pool.query(`
DO $$ BEGIN
CREATE TYPE migration_status AS ENUM ('completed', 'failed');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS migrations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
status migration_status NOT NULL,
completed_at TIMESTAMPTZ,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
};
const runTimescaleDBMigrations = async (pool: Pool, logger?: ILogger) => {
try {
logger?.info({ message: "Running migrations", service: SERVICE_NAME });
await ensureMigrationsTable(pool);
for (const migration of migrations) {
const result = await pool.query("SELECT 1 FROM migrations WHERE name = $1 AND status = 'completed'", [migration.name]);
if (result.rowCount !== null && result.rowCount > 0) {
logger?.info({ message: `Skipping ${migration.name}`, service: SERVICE_NAME });
continue;
}
try {
await migration.up(pool);
await pool.query(
`INSERT INTO migrations (name, status, completed_at)
VALUES ($1, 'completed', NOW())
ON CONFLICT (name) DO UPDATE SET status = 'completed', completed_at = NOW(), error = NULL, updated_at = NOW()`,
[migration.name]
);
logger?.info({ message: `Completed ${migration.name}`, service: SERVICE_NAME });
} catch (error) {
const err = error as Error;
await pool.query(
`INSERT INTO migrations (name, status, error)
VALUES ($1, 'failed', $2)
ON CONFLICT (name) DO UPDATE SET status = 'failed', error = $2, updated_at = NOW()`,
[migration.name, err?.message]
);
throw error;
}
}
logger?.info({ message: "Migrations completed", service: SERVICE_NAME });
} catch (error) {
const err = error as Error;
logger?.error({ message: "Migration failed", service: SERVICE_NAME, details: { error: err?.message }, stack: err?.stack });
throw error;
}
};
const rollbackTimescaleDBMigration = async (pool: Pool, logger?: ILogger) => {
try {
await ensureMigrationsTable(pool);
// Find the last completed migration
const result = await pool.query("SELECT name FROM migrations WHERE status = 'completed' ORDER BY completed_at DESC LIMIT 1");
if (result.rowCount === 0) {
logger?.info({ message: "No migrations to roll back", service: SERVICE_NAME });
return;
}
const lastMigrationName = result.rows[0].name;
const migration = migrations.find((m) => m.name === lastMigrationName);
if (!migration) {
throw new Error(`Migration "${lastMigrationName}" not found in migration list`);
}
logger?.info({ message: `Rolling back ${migration.name}`, service: SERVICE_NAME });
await migration.down(pool);
await pool.query("DELETE FROM migrations WHERE name = $1", [migration.name]);
logger?.info({ message: `Rolled back ${migration.name}`, service: SERVICE_NAME });
} catch (error) {
const err = error as Error;
logger?.error({ message: "Rollback failed", service: SERVICE_NAME, details: { error: err?.message }, stack: err?.stack });
throw error;
}
};
export { runTimescaleDBMigrations, rollbackTimescaleDBMigration };
+1
View File
@@ -26,6 +26,7 @@ const AppSettingsSchema = new Schema<AppSettingsDocument>(
systemEmailHost: { type: String },
systemEmailPort: { type: Number },
systemEmailAddress: { type: String },
systemEmailDisplayName: { type: String },
systemEmailPassword: { type: String },
systemEmailUser: { type: String },
systemEmailConnectionHost: { type: String, default: "localhost" },
+1
View File
@@ -271,6 +271,7 @@ const CheckSchema = new Schema<CheckDocument>(
{
timestamps: true,
strict: false,
suppressReservedKeysWarning: true,
timeseries: {
timeField: "createdAt",
metaField: "metadata",
+2 -2
View File
@@ -1,5 +1,5 @@
import { Schema, model, type Types } from "mongoose";
import type { MaintenanceWindow } from "@/types/maintenanceWindow.js";
import { DurationUnits, type MaintenanceWindow } from "@/types/maintenanceWindow.js";
type MaintenanceWindowDocumentBase = Omit<MaintenanceWindow, "id" | "monitorId" | "teamId" | "start" | "end" | "createdAt" | "updatedAt"> & {
monitorId: Types.ObjectId;
@@ -39,7 +39,7 @@ const MaintenanceWindowSchema = new Schema<MaintenanceWindowDocument>(
},
durationUnit: {
type: String,
enum: ["seconds", "minutes", "hours", "days"],
enum: DurationUnits,
},
repeat: {
type: Number,
+8 -1
View File
@@ -1,6 +1,6 @@
import { Schema, model, Types } from "mongoose";
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js";
import { MonitorTypes, MonitorStatuses, PageSpeedStrategies } from "@/types/monitor.js";
import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies } from "@/types/monitor.js";
import type {
CheckAudits,
CheckCaptureInfo,
@@ -355,6 +355,13 @@ const MonitorSchema = new Schema<MonitorDocument>(
type: Number,
default: 300000,
},
dnsServer: {
type: String,
},
dnsRecordType: {
type: String,
enum: DnsRecordTypes,
},
recentChecks: {
type: [checkSnapshotSchema],
default: [],
+1 -1
View File
@@ -16,7 +16,7 @@ const MonitorStatsSchema = new Schema<MonitorStatsDocument>(
monitorId: {
type: Schema.Types.ObjectId,
ref: "Monitor",
index: true,
unique: true,
required: true,
},
avgResponseTime: {
+3 -1
View File
@@ -25,7 +25,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
},
type: {
type: String,
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram"] as NotificationChannel[],
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover", "twilio"] as NotificationChannel[],
required: true,
},
notificationName: {
@@ -37,6 +37,8 @@ const NotificationSchema = new Schema<NotificationDocument>(
homeserverUrl: { type: String },
roomId: { type: String },
accessToken: { type: String },
accountSid: { type: String },
twilioPhoneNumber: { type: String },
},
{
timestamps: true,
+17 -1
View File
@@ -1,6 +1,12 @@
import { Schema, model, type Types } from "mongoose";
import type { StatusPage, StatusPageLogoDocument } from "@/types/statusPage.js";
import { StatusPageTypes } from "@/types/statusPage.js";
import {
DEFAULT_STATUS_PAGE_THEME,
DEFAULT_STATUS_PAGE_THEME_MODE,
StatusPageThemeModes,
StatusPageThemes,
StatusPageTypes,
} from "@/types/statusPage.js";
type StatusPageDocumentBase = Omit<
StatusPage,
@@ -108,6 +114,16 @@ const StatusPageSchema = new Schema<StatusPageDocument>(
type: String,
default: "",
},
theme: {
type: String,
enum: StatusPageThemes,
default: DEFAULT_STATUS_PAGE_THEME,
},
themeMode: {
type: String,
enum: StatusPageThemeModes,
default: DEFAULT_STATUS_PAGE_THEME_MODE,
},
},
{ timestamps: true }
);