Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/alerts/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,59 @@ function defaultChannels(): Record<string, AlertChannel> {
return Object.fromEntries(listAlertChannels().map((def) => [def.name, def.channel]));
}

/**
* Returns the current HH:MM (24-hour) in the given IANA timezone.
* Falls back to UTC if the timezone string is invalid.
*/
function currentHHMMInTz(timezone: string): string {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(new Date());
const hour = parts.find((p) => p.type === "hour")!.value;
const minute = parts.find((p) => p.type === "minute")!.value;
// Intl may return "24" for midnight in some environments — normalise to "00".
const normHour = hour === "24" ? "00" : hour;
return `${normHour}:${minute}`;
} catch {
// Unknown timezone — fall back to UTC.
return new Date().toISOString().slice(11, 16);
}
}

/**
* Convert an HH:MM string to total minutes since midnight.
*/
function toMinutes(hhmm: string): number {
const [hh, mm] = hhmm.split(":").map(Number) as [number, number];
return hh * 60 + mm;
}

/**
* Returns true if `current` (HH:MM) falls within the [start, end) window.
* Handles overnight windows where start > end (e.g. 22:00–06:00).
*/
export function isInQuietHours(
current: string,
start: string,
end: string,
): boolean {
const c = toMinutes(current);
const s = toMinutes(start);
const e = toMinutes(end);

if (s <= e) {
// Same-day window: e.g. 09:00–17:00
return c >= s && c < e;
} else {
// Overnight window: e.g. 22:00–06:00
return c >= s || c < e;
}
}

export interface DeliveryResult {
attempted: number;
delivered: number;
Expand Down Expand Up @@ -50,6 +103,28 @@ export async function deliverPendingAlerts(
for (const alert of pending) {
result.attempted++;

// ── Quiet-hours check ───────────────────────────────────────────────
// If the alert's config has a fully-configured quiet window AND the
// current wall-clock time in the configured timezone falls within it,
// skip delivery for this cycle. The alert stays pending (delivered=0,
// retry_count unchanged) and will be retried on the next daemon cycle.
if (
alert.quietHoursStart !== null &&
alert.quietHoursEnd !== null &&
alert.quietHoursTimezone !== null
) {
const tz = alert.quietHoursTimezone;
const currentHHMM = currentHHMMInTz(tz);
if (isInQuietHours(currentHHMM, alert.quietHoursStart, alert.quietHoursEnd)) {
logger.debug(
`Alert skipped (quiet hours ${alert.quietHoursStart}–${alert.quietHoursEnd} ${tz}) — id: ${alert.alertFiredId}, contract: ${alert.contractId}`,
);
// Do NOT mark delivered, do NOT increment retry_count.
// attempted is still incremented (we did process this alert, just chose to defer it).
continue;
}
}

const event = buildAlertEvent({
type: "threshold_crossed",
contractId: alert.contractId,
Expand Down
54 changes: 49 additions & 5 deletions src/commands/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ export function registerAlertsCommand(program: Command): void {
.option("--threshold <ledgers>", "Threshold in number of ledgers (for TTL-based alerts)", (val) => parseInt(val, 10))
.option("--cpu-limit <instructions>", "CPU instruction limit for resource alerts (default: 100,000,000)", (val) => parseInt(val, 10))
.option("--mem-limit <bytes>", "Memory byte limit for resource alerts (default: 50,000,000)", (val) => parseInt(val, 10))
.option(
"--quiet-hours <start-end>",
"Suppress alerts during a maintenance window. Format: HH:MM-HH:MM (24-hour), e.g. '22:00-06:00'. Requires --timezone.",
)
.option(
"--timezone <tz>",
"IANA timezone name for --quiet-hours interpretation, e.g. 'America/New_York' or 'UTC'.",
)
Comment thread
temi-Dee marked this conversation as resolved.
.action((options) => {
const contractId = options.contract;

Expand Down Expand Up @@ -97,19 +105,55 @@ export function registerAlertsCommand(program: Command): void {
process.exit(1);
}

// ── Quiet-hours / timezone validation ──────────────────────────
let quietHoursStart: string | undefined;
let quietHoursEnd: string | undefined;
let quietHoursTimezone: string | undefined;

if (options.quietHours || options.timezone) {
if (!options.quietHours || !options.timezone) {
console.error(chalk.red("Error: --quiet-hours and --timezone must be used together."));
process.exit(1);
}

// Validate HH:MM-HH:MM format.
const qhMatch = (options.quietHours as string).match(
/^(\d{2}:\d{2})-(\d{2}:\d{2})$/,
);
if (!qhMatch) {
console.error(chalk.red("Error: --quiet-hours must be in HH:MM-HH:MM format, e.g. '22:00-06:00'."));
process.exit(1);
}
Comment thread
temi-Dee marked this conversation as resolved.

// Validate IANA timezone.
try {
new Intl.DateTimeFormat("en-US", { timeZone: options.timezone });
} catch {
console.error(chalk.red(`Error: --timezone '${options.timezone}' is not a valid IANA timezone name.`));
process.exit(1);
}

quietHoursStart = qhMatch[1];
quietHoursEnd = qhMatch[2];
quietHoursTimezone = options.timezone as string;
}

insertAlertConfig(db, {
contract_id: contractId,
channel_type: options.type,
channel_target: target,
threshold_ledgers: threshold,
webhook_secret: webhookSecret,
quiet_hours_start: quietHoursStart,
quiet_hours_end: quietHoursEnd,
quiet_hours_timezone: quietHoursTimezone,
});

console.log(
chalk.green(
`Successfully added alert config: type=${options.type}, target=${target}, threshold=${threshold} ledgers`
)
);
let successMsg = `Successfully added alert config: type=${options.type}, target=${target}, threshold=${threshold} ledgers`;
if (quietHoursStart) {
successMsg += `, quiet hours=${quietHoursStart}-${quietHoursEnd} (${quietHoursTimezone})`;
}
console.log(chalk.green(successMsg));

if (webhookSecret) {
console.log(` ${chalk.bold("Webhook secret:")} ${webhookSecret}`);
Expand Down
10 changes: 10 additions & 0 deletions src/db/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ export function getDatabase(customPath?: string): Database.Database {
)`,
`ALTER TABLE contracts ADD COLUMN last_introspected_at DATETIME`,
`ALTER TABLE contracts ADD COLUMN active INTEGER NOT NULL DEFAULT 1`,
// issue #325 — quiet-hours / maintenance-window columns
`ALTER TABLE alert_configs ADD COLUMN quiet_hours_start TEXT`,
`ALTER TABLE alert_configs ADD COLUMN quiet_hours_end TEXT`,
`ALTER TABLE alert_configs ADD COLUMN quiet_hours_timezone TEXT`,
];
for (const sql of migrations) {
try { db.exec(sql); } catch { /* column already exists — no-op */ }
Expand Down Expand Up @@ -106,6 +110,9 @@ function migrateAlertConfigsChannelTypeCheck(db: Database.Database): void {
channel_target TEXT NOT NULL,
threshold_ledgers INTEGER NOT NULL,
webhook_secret TEXT,
quiet_hours_start TEXT,
quiet_hours_end TEXT,
quiet_hours_timezone TEXT,
Comment thread
temi-Dee marked this conversation as resolved.
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
Expand Down Expand Up @@ -149,6 +156,9 @@ function relaxChannelTypeChecks(db: Database.Database): void {
channel_target TEXT NOT NULL,
threshold_ledgers INTEGER NOT NULL,
webhook_secret TEXT,
quiet_hours_start TEXT,
quiet_hours_end TEXT,
quiet_hours_timezone TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
Expand Down
16 changes: 16 additions & 0 deletions src/db/migrations/002_quiet_hours.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- Migration 002: add quiet-hours / maintenance-window columns to alert_configs (issue #325)
--
-- Allows operators to suppress alert delivery during a known maintenance window
-- (e.g. a planned deployment or nightly backup) without deleting and re-creating
-- the alert config.
--
-- NULL in all three columns means "no quiet window" — existing rows are unaffected.
-- HH:MM text format (24-hour) keeps storage simple and portable.
--
-- On databases initialised from schema.sql 002+ these columns already exist;
-- the Migrator handles the "duplicate column name" error gracefully and still
-- records this migration as applied (see src/db/migrator.ts).

ALTER TABLE alert_configs ADD COLUMN quiet_hours_start TEXT;
ALTER TABLE alert_configs ADD COLUMN quiet_hours_end TEXT;
ALTER TABLE alert_configs ADD COLUMN quiet_hours_timezone TEXT;
31 changes: 29 additions & 2 deletions src/db/migrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export class Migrator {
/**
* Executes all pending migrations sequentially.
* Each migration script is executed in its own transaction.
*
* If a migration consists solely of ADD COLUMN statements and the column
* already exists (i.e. the schema was created from a schema.sql that already
* includes the column), the "duplicate column name" error is swallowed and
* the migration is still recorded as applied — the intent is fulfilled.
*/
public run(): void {
this.init();
Expand All @@ -80,8 +85,30 @@ export class Migrator {
this.db.prepare("INSERT INTO schema_migrations (version) VALUES (?);").run(migration.version);
});

// Execute migration transaction
runMigrationTx();
try {
// Execute migration transaction
runMigrationTx();
} catch (err: unknown) {
// If every statement in this migration is an ALTER TABLE ADD COLUMN
// and the column already exists (schema.sql was applied first on a
// fresh / test DB), swallow the error and record the migration as
// applied — the column is already there, so the intent is fulfilled.
const message = err instanceof Error ? err.message : String(err);
const isAddColumnMigration = sql
.split(";")
.map((s) => s.trim())
.filter((s) => s.length > 0 && !s.startsWith("--"))
.every((s) => /^ALTER\s+TABLE\s+\S+\s+ADD\s+COLUMN/i.test(s));

if (isAddColumnMigration && /duplicate column name/i.test(message)) {
// All statements add columns that already exist — mark as applied.
this.db
.prepare("INSERT OR IGNORE INTO schema_migrations (version) VALUES (?);")
.run(migration.version);
} else {
throw err;
}
}
Comment thread
temi-Dee marked this conversation as resolved.
}
}
}
27 changes: 24 additions & 3 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ export interface AlertConfig {
channel_target: string;
threshold_ledgers: number;
webhook_secret: string | null;
/** HH:MM (24-hour) start of the quiet / maintenance window, or null if not configured. */
quiet_hours_start: string | null;
/** HH:MM (24-hour) end of the quiet / maintenance window, or null if not configured. */
quiet_hours_end: string | null;
/** IANA timezone name used to interpret quiet_hours_start / quiet_hours_end, or null. */
quiet_hours_timezone: string | null;
created_at: Date;
}

Expand Down Expand Up @@ -273,13 +279,19 @@ export function insertAlertConfig(db: Database.Database, config: {
channel_target: string;
threshold_ledgers: number;
webhook_secret?: string;
quiet_hours_start?: string | null;
quiet_hours_end?: string | null;
quiet_hours_timezone?: string | null;
}): void {
db.prepare(`
INSERT INTO alert_configs (contract_id, channel_type, channel_target, threshold_ledgers, webhook_secret)
VALUES (@contract_id, @channel_type, @channel_target, @threshold_ledgers, @webhook_secret)
INSERT INTO alert_configs (contract_id, channel_type, channel_target, threshold_ledgers, webhook_secret, quiet_hours_start, quiet_hours_end, quiet_hours_timezone)
VALUES (@contract_id, @channel_type, @channel_target, @threshold_ledgers, @webhook_secret, @quiet_hours_start, @quiet_hours_end, @quiet_hours_timezone)
`).run({
...config,
webhook_secret: config.webhook_secret ?? null,
quiet_hours_start: config.quiet_hours_start ?? null,
quiet_hours_end: config.quiet_hours_end ?? null,
quiet_hours_timezone: config.quiet_hours_timezone ?? null,
});
}

Expand Down Expand Up @@ -644,6 +656,12 @@ export interface UndeliveredAlert {
firedAtLedger: number;
firedAt: string;
retryCount: number;
/** HH:MM (24-hour) start of the quiet window, or null if not configured. */
quietHoursStart: string | null;
/** HH:MM (24-hour) end of the quiet window, or null if not configured. */
quietHoursEnd: string | null;
/** IANA timezone for the quiet window, or null if not configured. */
quietHoursTimezone: string | null;
}

/** Maximum number of delivery attempts before giving up on an alert. */
Expand Down Expand Up @@ -676,7 +694,10 @@ export function getUndeliveredAlerts(
af.ttl_at_fire AS remainingTTL,
af.fired_at_ledger AS firedAtLedger,
af.fired_at AS firedAt,
af.retry_count AS retryCount
af.retry_count AS retryCount,
ac.quiet_hours_start AS quietHoursStart,
ac.quiet_hours_end AS quietHoursEnd,
ac.quiet_hours_timezone AS quietHoursTimezone
FROM alerts_fired af
JOIN alert_configs ac ON ac.id = af.alert_config_id
JOIN contract_entries ce ON ce.id = af.contract_entry_id
Expand Down
6 changes: 6 additions & 0 deletions src/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ CREATE TABLE IF NOT EXISTS alert_configs (
channel_target TEXT NOT NULL,
threshold_ledgers INTEGER NOT NULL,
webhook_secret TEXT,
-- Quiet-hours / maintenance-window support (issue #325).
-- All three columns are nullable: NULL means no quiet window is configured.
-- HH:MM 24-hour format. quiet_hours_timezone must be a valid IANA tz name.
quiet_hours_start TEXT,
quiet_hours_end TEXT,
quiet_hours_timezone TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Expand Down
Loading
Loading