The Diagnostic Log already records every outbound call's success or failure, but nothing acted on it — you'd only notice an integration was down by happening to open its page. Adds a per-source consecutive- failure counter (in-memory, reset on restart, same durability tier as the diag log's own ring buffer) hooked into recordDiagEntry: crossing the configurable threshold (default 3) sends one "down" notification on every configured channel, and a "recovered" notification fires once it succeeds again — no repeat spam while it stays down. New "Integration/DNS provider failing repeatedly" toggle and threshold field under Settings -> Notifications. Verified end-to-end against an isolated scratch database with a real local HTTP server standing in for the webhook channel: 5 consecutive failures produced exactly one "Down" notification (at the 3rd failure, correctly naming "3 calls"), a subsequent success produced exactly one "Recovered" notification, and two more failures on a fresh streak triggered nothing (below threshold) — confirmed the real dev database's mtime was untouched throughout. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
158 lines
4.6 KiB
TypeScript
158 lines
4.6 KiB
TypeScript
import { sql } from "drizzle-orm";
|
|
import { db } from "../db/client.js";
|
|
import { settings } from "../db/schema.js";
|
|
|
|
export interface GotifySettings {
|
|
enabled: boolean;
|
|
url: string;
|
|
token: string;
|
|
priority: number;
|
|
}
|
|
|
|
export interface NtfySettings {
|
|
enabled: boolean;
|
|
url: string;
|
|
topic: string;
|
|
token: string;
|
|
priority: number;
|
|
}
|
|
|
|
export interface SmtpSettings {
|
|
enabled: boolean;
|
|
host: string;
|
|
port: number;
|
|
secure: boolean;
|
|
username: string;
|
|
password: string;
|
|
from: string;
|
|
to: string;
|
|
}
|
|
|
|
export interface WebhookSettings {
|
|
enabled: boolean;
|
|
url: string;
|
|
secret: string;
|
|
}
|
|
|
|
export interface NotificationEvents {
|
|
dnsAdd: boolean;
|
|
dnsUpdate: boolean;
|
|
dnsDelete: boolean;
|
|
secretCheck: boolean;
|
|
tailscaleKeyCheck: boolean;
|
|
secretCheckTime: string; // "HH:MM" — shared by the secret-expiry and Tailscale key-expiry checks
|
|
timezone: string;
|
|
integrationFailureAlerts: boolean;
|
|
/** Consecutive failed calls (from the Diagnostic Log) before an integration/DNS provider is considered down. */
|
|
integrationFailureThreshold: number;
|
|
}
|
|
|
|
export type ProviderColors = Record<string, string>;
|
|
export type IntegrationColors = Record<string, string>;
|
|
|
|
export type DateFormat = "ymd" | "dmy" | "mdy";
|
|
export type TimeFormat = "24h" | "12h";
|
|
|
|
export interface DisplaySettings {
|
|
dateFormat: DateFormat;
|
|
timeFormat: TimeFormat;
|
|
/** Rows per page for every paginated table in the app. */
|
|
pageSize: number;
|
|
}
|
|
|
|
export interface LogRetentionSettings {
|
|
enabled: boolean;
|
|
/** Diagnostic log and audit log entries older than this are deleted. */
|
|
retentionDays: number;
|
|
/** How often the purge job runs, in hours. */
|
|
intervalHours: number;
|
|
}
|
|
|
|
export interface AppSettings {
|
|
gotify: GotifySettings;
|
|
ntfy: NtfySettings;
|
|
smtp: SmtpSettings;
|
|
webhook: WebhookSettings;
|
|
notifications: NotificationEvents;
|
|
providerColors: ProviderColors;
|
|
integrationColors: IntegrationColors;
|
|
display: DisplaySettings;
|
|
logRetention: LogRetentionSettings;
|
|
}
|
|
|
|
const DEFAULTS: AppSettings = {
|
|
gotify: { enabled: false, url: "", token: "", priority: 5 },
|
|
ntfy: { enabled: false, url: "https://ntfy.sh", topic: "", token: "", priority: 3 },
|
|
smtp: { enabled: false, host: "", port: 587, secure: false, username: "", password: "", from: "", to: "" },
|
|
webhook: { enabled: false, url: "", secret: "" },
|
|
notifications: {
|
|
dnsAdd: true,
|
|
dnsUpdate: true,
|
|
dnsDelete: true,
|
|
secretCheck: true,
|
|
tailscaleKeyCheck: true,
|
|
secretCheckTime: "08:00",
|
|
timezone: "UTC",
|
|
integrationFailureAlerts: true,
|
|
integrationFailureThreshold: 3,
|
|
},
|
|
providerColors: {},
|
|
integrationColors: {},
|
|
display: { dateFormat: "ymd", timeFormat: "24h", pageSize: 20 },
|
|
logRetention: { enabled: false, retentionDays: 90, intervalHours: 24 },
|
|
};
|
|
|
|
const KEYS = Object.keys(DEFAULTS) as (keyof AppSettings)[];
|
|
|
|
export async function getSettings(): Promise<AppSettings> {
|
|
const rows = await db.select().from(settings);
|
|
const byKey = new Map(rows.map((r) => [r.key, r.value]));
|
|
|
|
const result = {} as AppSettings;
|
|
for (const key of KEYS) {
|
|
const raw = byKey.get(key);
|
|
let parsed: Record<string, unknown> = {};
|
|
if (raw) {
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
parsed = {};
|
|
}
|
|
}
|
|
(result[key] as Record<string, unknown>) = { ...(DEFAULTS[key] as object), ...parsed };
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial<AppSettings[K]> };
|
|
|
|
export async function updateSettings(partial: AppSettingsPatch): Promise<AppSettings> {
|
|
const current = await getSettings();
|
|
|
|
for (const key of Object.keys(partial) as (keyof AppSettings)[]) {
|
|
if (!KEYS.includes(key)) continue;
|
|
const merged = { ...(current[key] as object), ...(partial[key] as object) };
|
|
const value = JSON.stringify(merged);
|
|
await db
|
|
.insert(settings)
|
|
.values({ key, value })
|
|
.onConflictDoUpdate({ target: settings.key, set: { value, updatedAt: sql`(current_timestamp)` } });
|
|
}
|
|
|
|
return getSettings();
|
|
}
|
|
|
|
/** Small internal key/value slot for scheduler bookkeeping, outside the AppSettings shape. */
|
|
export async function getInternalFlag(key: string): Promise<string | null> {
|
|
const [row] = await db.select().from(settings).where(sql`${settings.key} = ${`_internal:${key}`}`).limit(1);
|
|
return row?.value ?? null;
|
|
}
|
|
|
|
export async function setInternalFlag(key: string, value: string): Promise<void> {
|
|
const fullKey = `_internal:${key}`;
|
|
await db
|
|
.insert(settings)
|
|
.values({ key: fullKey, value })
|
|
.onConflictDoUpdate({ target: settings.key, set: { value, updatedAt: sql`(current_timestamp)` } });
|
|
}
|