mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: remove legacy alert rules, superseded by automations
Alert rules already auto-migrate into automations on boot and the old engine stands down after. Deletes the rule/firing tables, routes, UI, and AI tools; keeps notification channels since automations still use them. Also fixes automations' notify step, which still called the deleted repository.
This commit is contained in:
1 parent
e0f14f2ecf
commit
e372b0eda7
101 files changed
+29118
-5539
No files matched your search
@@ -31,7 +31,7 @@ import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
|
||||
import vaultRoutes from "./routes/vault.js";
|
||||
import secretSourceRoutes from "./routes/secret-sources.js";
|
||||
import alertRulesRoutes from "./routes/alert-rules-routes.js";
|
||||
import notificationChannelsRoutes from "./routes/notification-channels-routes.js";
|
||||
import aiDispatch from "./routes/ai-dispatch.js";
|
||||
import automationsDispatch from "./routes/automation-dispatch.js";
|
||||
import syncRoutes from "./routes/sync.js";
|
||||
@@ -1774,11 +1774,9 @@ registerAuditLogRoutes(app, authenticateJWT);
|
||||
registerTailscaleRoutes(app, authenticateJWT);
|
||||
app.use("/vault", vaultRoutes);
|
||||
app.use("/secret-sources", secretSourceRoutes);
|
||||
// Before the alert routes, which are mounted at the root and would otherwise
|
||||
// have first claim on the path.
|
||||
app.use("/automations", automationsDispatch);
|
||||
app.use("/ai", aiDispatch);
|
||||
app.use("/", alertRulesRoutes);
|
||||
app.use("/", notificationChannelsRoutes);
|
||||
app.use("/sync", syncRoutes);
|
||||
app.use("/plugins", pluginRoutes);
|
||||
app.use("/plugin-api", authenticateJWT, pluginApiRoutes);
|
||||
|
||||
@@ -207,18 +207,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
|
||||
columns: "user_id",
|
||||
},
|
||||
|
||||
// Alerting.
|
||||
{
|
||||
name: "idx_alert_firings_rule",
|
||||
table: "alert_firings",
|
||||
columns: "rule_id, fired_at",
|
||||
},
|
||||
{
|
||||
name: "idx_alert_firings_host",
|
||||
table: "alert_firings",
|
||||
columns: "host_id",
|
||||
},
|
||||
|
||||
// Automations.
|
||||
{
|
||||
name: "idx_automations_user",
|
||||
|
||||
@@ -1440,26 +1440,6 @@ export const proxmoxNodeHistory = mysqlTable("proxmox_node_history", {
|
||||
// --- proxmox-node-history end ---
|
||||
|
||||
// --- alerts begin ---
|
||||
export const alertRules = mysqlTable("alert_rules", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: int("host_id").references(() => hosts.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
triggerType: text("trigger_type").notNull(),
|
||||
thresholdValue: double("threshold_value"),
|
||||
thresholdDurationSeconds: int("threshold_duration_seconds"),
|
||||
cooldownMinutes: int("cooldown_minutes").notNull().default(15),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
|
||||
export const notificationChannels = mysqlTable("notification_channels", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
@@ -1473,46 +1453,6 @@ export const notificationChannels = mysqlTable("notification_channels", {
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
|
||||
export const alertRuleChannels = mysqlTable("alert_rule_channels", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
ruleId: int("rule_id")
|
||||
.notNull()
|
||||
.references(() => alertRules.id, { onDelete: "cascade" }),
|
||||
channelId: int("channel_id")
|
||||
.notNull()
|
||||
.references(() => notificationChannels.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const alertFirings = mysqlTable(
|
||||
"alert_firings",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
ruleId: int("rule_id")
|
||||
.notNull()
|
||||
.references(() => alertRules.id, { onDelete: "cascade" }),
|
||||
hostId: int("host_id").notNull(),
|
||||
hostName: text("host_name").notNull(),
|
||||
firedAt: varchar("fired_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
resolvedAt: text("resolved_at"),
|
||||
value: double("value"),
|
||||
message: text("message").notNull(),
|
||||
severity: text("severity").notNull().default("warning"),
|
||||
acknowledged: boolean("acknowledged")
|
||||
.notNull()
|
||||
.default(false),
|
||||
},
|
||||
// A rule's history is read newest-first; host_id is filtered on its own.
|
||||
(table) => [
|
||||
index("idx_alert_firings_rule").on(table.ruleId, table.firedAt),
|
||||
index("idx_alert_firings_host").on(table.hostId),
|
||||
],
|
||||
);
|
||||
// --- alerts end ---
|
||||
|
||||
// --- automations begin ---
|
||||
|
||||
@@ -1441,26 +1441,6 @@ export const proxmoxNodeHistory = pgTable("proxmox_node_history", {
|
||||
// --- proxmox-node-history end ---
|
||||
|
||||
// --- alerts begin ---
|
||||
export const alertRules = pgTable("alert_rules", {
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id").references(() => hosts.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
triggerType: text("trigger_type").notNull(),
|
||||
thresholdValue: doublePrecision("threshold_value"),
|
||||
thresholdDurationSeconds: integer("threshold_duration_seconds"),
|
||||
cooldownMinutes: integer("cooldown_minutes").notNull().default(15),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const notificationChannels = pgTable("notification_channels", {
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
@@ -1474,46 +1454,6 @@ export const notificationChannels = pgTable("notification_channels", {
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const alertRuleChannels = pgTable("alert_rule_channels", {
|
||||
id: serial("id").primaryKey(),
|
||||
ruleId: integer("rule_id")
|
||||
.notNull()
|
||||
.references(() => alertRules.id, { onDelete: "cascade" }),
|
||||
channelId: integer("channel_id")
|
||||
.notNull()
|
||||
.references(() => notificationChannels.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const alertFirings = pgTable(
|
||||
"alert_firings",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
ruleId: integer("rule_id")
|
||||
.notNull()
|
||||
.references(() => alertRules.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id").notNull(),
|
||||
hostName: text("host_name").notNull(),
|
||||
firedAt: varchar("fired_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
resolvedAt: text("resolved_at"),
|
||||
value: doublePrecision("value"),
|
||||
message: text("message").notNull(),
|
||||
severity: text("severity").notNull().default("warning"),
|
||||
acknowledged: boolean("acknowledged")
|
||||
.notNull()
|
||||
.default(false),
|
||||
},
|
||||
// A rule's history is read newest-first; host_id is filtered on its own.
|
||||
(table) => [
|
||||
index("idx_alert_firings_rule").on(table.ruleId, table.firedAt),
|
||||
index("idx_alert_firings_host").on(table.hostId),
|
||||
],
|
||||
);
|
||||
// --- alerts end ---
|
||||
|
||||
// --- automations begin ---
|
||||
|
||||
@@ -1439,26 +1439,6 @@ export const proxmoxNodeHistory = sqliteTable("proxmox_node_history", {
|
||||
// --- proxmox-node-history end ---
|
||||
|
||||
// --- alerts begin ---
|
||||
export const alertRules = sqliteTable("alert_rules", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id").references(() => hosts.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
triggerType: text("trigger_type").notNull(),
|
||||
thresholdValue: real("threshold_value"),
|
||||
thresholdDurationSeconds: integer("threshold_duration_seconds"),
|
||||
cooldownMinutes: integer("cooldown_minutes").notNull().default(15),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const notificationChannels = sqliteTable("notification_channels", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
@@ -1472,46 +1452,6 @@ export const notificationChannels = sqliteTable("notification_channels", {
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const alertRuleChannels = sqliteTable("alert_rule_channels", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
ruleId: integer("rule_id")
|
||||
.notNull()
|
||||
.references(() => alertRules.id, { onDelete: "cascade" }),
|
||||
channelId: integer("channel_id")
|
||||
.notNull()
|
||||
.references(() => notificationChannels.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const alertFirings = sqliteTable(
|
||||
"alert_firings",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
ruleId: integer("rule_id")
|
||||
.notNull()
|
||||
.references(() => alertRules.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id").notNull(),
|
||||
hostName: text("host_name").notNull(),
|
||||
firedAt: text("fired_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
resolvedAt: text("resolved_at"),
|
||||
value: real("value"),
|
||||
message: text("message").notNull(),
|
||||
severity: text("severity").notNull().default("warning"),
|
||||
acknowledged: integer("acknowledged", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
},
|
||||
// A rule's history is read newest-first; host_id is filtered on its own.
|
||||
(table) => [
|
||||
index("idx_alert_firings_rule").on(table.ruleId, table.firedAt),
|
||||
index("idx_alert_firings_host").on(table.hostId),
|
||||
],
|
||||
);
|
||||
// --- alerts end ---
|
||||
|
||||
// --- automations begin ---
|
||||
|
||||
@@ -47,7 +47,7 @@ export class AiRepository {
|
||||
* Provider API keys are field-encrypted like any other credential. The key is
|
||||
* derived from the row id, which does not exist until after the insert, so a
|
||||
* new provider is written once and then re-encrypted in place with its real
|
||||
* id -- the same approach AlertRepository uses for channel configs.
|
||||
* id -- the same approach NotificationChannelRepository uses for channel configs.
|
||||
*/
|
||||
private userDataKey(userId: string): Buffer | null {
|
||||
try {
|
||||
|
||||
@@ -1,712 +0,0 @@
|
||||
import { and, count, desc, eq, inArray, isNull, lt, or } from "drizzle-orm";
|
||||
import {
|
||||
alertFirings,
|
||||
alertRuleChannels,
|
||||
alertRules,
|
||||
hosts,
|
||||
notificationChannels,
|
||||
} from "../db/schema.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { sqlTimestampDaysAgo } from "./sql-timestamp.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
type AlertRuleRecord = typeof alertRules.$inferSelect;
|
||||
type NotificationChannelRecord = typeof notificationChannels.$inferSelect;
|
||||
type AlertFiringRecord = typeof alertFirings.$inferSelect;
|
||||
|
||||
export interface NotificationChannelRow {
|
||||
id: number;
|
||||
user_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AlertRuleRow {
|
||||
id: number;
|
||||
user_id: string;
|
||||
host_id: number | null;
|
||||
name: string;
|
||||
enabled: number;
|
||||
trigger_type: string;
|
||||
threshold_value: number | null;
|
||||
threshold_duration_seconds: number | null;
|
||||
cooldown_minutes: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AlertRuleWithChannelsRow extends AlertRuleRow {
|
||||
channels: number[];
|
||||
}
|
||||
|
||||
export interface AlertFiringRow {
|
||||
id: number;
|
||||
user_id: string;
|
||||
rule_id: number;
|
||||
host_id: number;
|
||||
host_name: string;
|
||||
fired_at: string;
|
||||
resolved_at: string | null;
|
||||
value: number | null;
|
||||
message: string;
|
||||
severity: string;
|
||||
acknowledged: number;
|
||||
rule_name: string | null;
|
||||
}
|
||||
|
||||
export interface AlertEngineRule {
|
||||
id: number;
|
||||
userId: string;
|
||||
hostId: number | null;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
triggerType: string;
|
||||
thresholdValue: number | null;
|
||||
thresholdDurationSeconds: number | null;
|
||||
cooldownMinutes: number;
|
||||
}
|
||||
|
||||
export interface AlertEngineChannel {
|
||||
id: number;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export class AlertRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Channel configs carry ntfy tokens and webhook auth headers, so they are
|
||||
* field-encrypted like any other secret. The key is derived from the row id,
|
||||
* which does not exist until after the insert, so a new channel is written
|
||||
* once and then re-encrypted in place with its real id.
|
||||
*/
|
||||
private userDataKey(userId: string): Buffer | null {
|
||||
try {
|
||||
return DataCrypto.getUserDataKey(userId);
|
||||
} catch {
|
||||
// Crypto is not initialized (tests, early boot); leave the value as is.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private encryptConfig(
|
||||
config: string,
|
||||
userId: string,
|
||||
recordId: number | string,
|
||||
): string {
|
||||
const userDataKey = this.userDataKey(userId);
|
||||
if (!userDataKey) return config;
|
||||
return DataCrypto.encryptRecord(
|
||||
"notification_channels",
|
||||
{ id: recordId, config },
|
||||
userId,
|
||||
userDataKey,
|
||||
).config;
|
||||
}
|
||||
|
||||
private decryptConfig(
|
||||
config: string,
|
||||
userId: string,
|
||||
recordId: number | string,
|
||||
): string {
|
||||
const userDataKey = this.userDataKey(userId);
|
||||
if (!userDataKey) return config;
|
||||
try {
|
||||
return DataCrypto.decryptRecord(
|
||||
"notification_channels",
|
||||
{ id: recordId, config },
|
||||
userId,
|
||||
userDataKey,
|
||||
).config;
|
||||
} catch {
|
||||
// Rows written before channel configs were encrypted are still plaintext.
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
async listNotificationChannels(
|
||||
userId: string,
|
||||
): Promise<NotificationChannelRow[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(notificationChannels)
|
||||
.where(eq(notificationChannels.userId, userId))
|
||||
.orderBy(notificationChannels.id);
|
||||
|
||||
return rows.map((row) => {
|
||||
const mapped = mapChannelRow(row);
|
||||
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
|
||||
return mapped;
|
||||
});
|
||||
}
|
||||
|
||||
async findNotificationChannelForUser(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<NotificationChannelRow | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(notificationChannels)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationChannels.id, id),
|
||||
eq(notificationChannels.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!rows[0]) return null;
|
||||
const mapped = mapChannelRow(rows[0]);
|
||||
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async createNotificationChannel(input: {
|
||||
userId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: boolean;
|
||||
}): Promise<NotificationChannelRow> {
|
||||
const [created] = await insertReturning(
|
||||
this.context,
|
||||
notificationChannels,
|
||||
{
|
||||
userId: input.userId,
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
config: input.config,
|
||||
enabled: input.enabled,
|
||||
},
|
||||
);
|
||||
|
||||
const encrypted = this.encryptConfig(
|
||||
input.config,
|
||||
input.userId,
|
||||
created.id,
|
||||
);
|
||||
if (encrypted !== input.config) {
|
||||
await this.context.drizzle
|
||||
.update(notificationChannels)
|
||||
.set({ config: encrypted })
|
||||
.where(eq(notificationChannels.id, created.id));
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
const mapped = mapChannelRow(created);
|
||||
mapped.config = input.config;
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async updateNotificationChannel(
|
||||
id: number,
|
||||
userId: string,
|
||||
input: {
|
||||
name?: string;
|
||||
type?: string;
|
||||
config?: string;
|
||||
enabled?: boolean;
|
||||
},
|
||||
): Promise<NotificationChannelRow | null> {
|
||||
if (Object.keys(input).length === 0) {
|
||||
return this.findNotificationChannelForUser(id, userId);
|
||||
}
|
||||
|
||||
const values =
|
||||
input.config !== undefined
|
||||
? { ...input, config: this.encryptConfig(input.config, userId, id) }
|
||||
: input;
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
notificationChannels,
|
||||
values,
|
||||
and(
|
||||
eq(notificationChannels.id, id),
|
||||
eq(notificationChannels.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (!updated) return null;
|
||||
await this.afterWrite();
|
||||
const mapped = mapChannelRow(updated);
|
||||
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async deleteNotificationChannel(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(notificationChannels)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationChannels.id, id),
|
||||
eq(notificationChannels.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) === 0) return false;
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async listAlertRules(userId: string): Promise<AlertRuleWithChannelsRow[]> {
|
||||
const rules = await this.context.drizzle
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.where(eq(alertRules.userId, userId))
|
||||
.orderBy(alertRules.id);
|
||||
|
||||
const result: AlertRuleWithChannelsRow[] = [];
|
||||
for (const rule of rules) {
|
||||
result.push({
|
||||
...mapRuleRow(rule),
|
||||
channels: await this.listChannelIdsForRule(rule.id),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async createAlertRule(input: {
|
||||
userId: string;
|
||||
hostId: number | null;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
triggerType: string;
|
||||
thresholdValue: number | null;
|
||||
thresholdDurationSeconds: number | null;
|
||||
cooldownMinutes: number;
|
||||
channels: number[];
|
||||
now: string;
|
||||
}): Promise<AlertRuleWithChannelsRow> {
|
||||
const [created] = await insertReturning(this.context, alertRules, {
|
||||
userId: input.userId,
|
||||
hostId: input.hostId,
|
||||
name: input.name,
|
||||
enabled: input.enabled,
|
||||
triggerType: input.triggerType,
|
||||
thresholdValue: input.thresholdValue,
|
||||
thresholdDurationSeconds: input.thresholdDurationSeconds,
|
||||
cooldownMinutes: input.cooldownMinutes,
|
||||
createdAt: input.now,
|
||||
updatedAt: input.now,
|
||||
});
|
||||
|
||||
const channels = await this.replaceRuleChannels(
|
||||
created.id,
|
||||
input.userId,
|
||||
input.channels,
|
||||
);
|
||||
await this.afterWrite();
|
||||
return { ...mapRuleRow(created), channels };
|
||||
}
|
||||
|
||||
async findAlertRuleForUser(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<AlertRuleRow | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ? mapRuleRow(rows[0]) : null;
|
||||
}
|
||||
|
||||
async updateAlertRule(
|
||||
id: number,
|
||||
userId: string,
|
||||
input: {
|
||||
name?: string;
|
||||
hostId?: number | null;
|
||||
enabled?: boolean;
|
||||
triggerType?: string;
|
||||
thresholdValue?: number | null;
|
||||
thresholdDurationSeconds?: number | null;
|
||||
cooldownMinutes?: number;
|
||||
channels?: number[];
|
||||
now: string;
|
||||
},
|
||||
): Promise<AlertRuleWithChannelsRow | null> {
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
alertRules,
|
||||
{
|
||||
...(input.name !== undefined ? { name: input.name } : {}),
|
||||
...(input.hostId !== undefined ? { hostId: input.hostId } : {}),
|
||||
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
|
||||
...(input.triggerType !== undefined
|
||||
? { triggerType: input.triggerType }
|
||||
: {}),
|
||||
...(input.thresholdValue !== undefined
|
||||
? { thresholdValue: input.thresholdValue }
|
||||
: {}),
|
||||
...(input.thresholdDurationSeconds !== undefined
|
||||
? { thresholdDurationSeconds: input.thresholdDurationSeconds }
|
||||
: {}),
|
||||
...(input.cooldownMinutes !== undefined
|
||||
? { cooldownMinutes: input.cooldownMinutes }
|
||||
: {}),
|
||||
updatedAt: input.now,
|
||||
},
|
||||
and(eq(alertRules.id, id), eq(alertRules.userId, userId)),
|
||||
);
|
||||
|
||||
if (!updated) return null;
|
||||
|
||||
const channels =
|
||||
input.channels === undefined
|
||||
? await this.listChannelIdsForRule(id)
|
||||
: await this.replaceRuleChannels(id, userId, input.channels);
|
||||
|
||||
await this.afterWrite();
|
||||
return { ...mapRuleRow(updated), channels };
|
||||
}
|
||||
|
||||
async deleteAlertRule(id: number, userId: string): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(alertRules)
|
||||
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)));
|
||||
|
||||
if (rowsAffected(result) === 0) return false;
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async listAlertFirings(input: {
|
||||
userId: string;
|
||||
acknowledged?: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}): Promise<{ firings: AlertFiringRow[]; total: number }> {
|
||||
const filters = [eq(alertFirings.userId, input.userId)];
|
||||
if (input.acknowledged !== undefined) {
|
||||
filters.push(eq(alertFirings.acknowledged, input.acknowledged));
|
||||
}
|
||||
|
||||
const where = and(...filters);
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
firing: alertFirings,
|
||||
ruleName: alertRules.name,
|
||||
})
|
||||
.from(alertFirings)
|
||||
.leftJoin(alertRules, eq(alertRules.id, alertFirings.ruleId))
|
||||
.where(where)
|
||||
.orderBy(desc(alertFirings.firedAt))
|
||||
.limit(input.limit)
|
||||
.offset(input.offset);
|
||||
|
||||
const totalRows = await this.context.drizzle
|
||||
.select({ total: count() })
|
||||
.from(alertFirings)
|
||||
.where(where);
|
||||
|
||||
return {
|
||||
firings: rows.map((row) => mapFiringRow(row.firing, row.ruleName)),
|
||||
total: totalRows[0]?.total ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async acknowledgeFiring(id: number, userId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(alertFirings)
|
||||
.set({ acknowledged: true })
|
||||
.where(and(eq(alertFirings.id, id), eq(alertFirings.userId, userId)));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async acknowledgeAllFirings(userId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(alertFirings)
|
||||
.set({ acknowledged: true })
|
||||
.where(eq(alertFirings.userId, userId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
// A rule with host_id IS NULL means "all my hosts", not "all hosts on the
|
||||
// server". Without joining the host back to its owner, every user's wildcard
|
||||
// rule fired for every polled host and leaked other users' host names into
|
||||
// their alerts.
|
||||
async listEnabledRulesForHost(hostId: number): Promise<AlertEngineRule[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ rule: alertRules })
|
||||
.from(alertRules)
|
||||
.innerJoin(hosts, eq(hosts.id, hostId))
|
||||
.where(
|
||||
and(
|
||||
eq(alertRules.enabled, true),
|
||||
or(
|
||||
eq(alertRules.hostId, hostId),
|
||||
and(isNull(alertRules.hostId), eq(alertRules.userId, hosts.userId)),
|
||||
),
|
||||
),
|
||||
);
|
||||
return rows.map((row) => mapEngineRule(row.rule));
|
||||
}
|
||||
|
||||
async listEnabledRulesForHostUser(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<AlertEngineRule[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.where(
|
||||
and(
|
||||
eq(alertRules.enabled, true),
|
||||
eq(alertRules.userId, userId),
|
||||
or(eq(alertRules.hostId, hostId), isNull(alertRules.hostId)),
|
||||
),
|
||||
);
|
||||
return rows.map(mapEngineRule);
|
||||
}
|
||||
|
||||
async findRuleById(id: number): Promise<AlertEngineRule | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.where(eq(alertRules.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ? mapEngineRule(rows[0]) : null;
|
||||
}
|
||||
|
||||
async createFiring(input: {
|
||||
userId: string;
|
||||
ruleId: number;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
value: number | null;
|
||||
message: string;
|
||||
severity: string;
|
||||
}): Promise<void> {
|
||||
await this.context.drizzle.insert(alertFirings).values(input);
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async pruneFiringsOlderThan(userId: string, days: number): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(alertFirings)
|
||||
.where(
|
||||
and(
|
||||
eq(alertFirings.userId, userId),
|
||||
lt(alertFirings.firedAt, sqlTimestampDaysAgo(days)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<{
|
||||
firingsDeleted: number;
|
||||
ruleLinksDeleted: number;
|
||||
rulesDeleted: number;
|
||||
channelsDeleted: number;
|
||||
}> {
|
||||
const ruleIds = (
|
||||
await this.context.drizzle
|
||||
.select({ id: alertRules.id })
|
||||
.from(alertRules)
|
||||
.where(eq(alertRules.userId, userId))
|
||||
).map((row) => row.id);
|
||||
const channelIds = (
|
||||
await this.context.drizzle
|
||||
.select({ id: notificationChannels.id })
|
||||
.from(notificationChannels)
|
||||
.where(eq(notificationChannels.userId, userId))
|
||||
).map((row) => row.id);
|
||||
|
||||
const firingResult = await this.context.drizzle
|
||||
.delete(alertFirings)
|
||||
.where(eq(alertFirings.userId, userId));
|
||||
|
||||
const linkFilters = [
|
||||
...(ruleIds.length > 0
|
||||
? [inArray(alertRuleChannels.ruleId, ruleIds)]
|
||||
: []),
|
||||
...(channelIds.length > 0
|
||||
? [inArray(alertRuleChannels.channelId, channelIds)]
|
||||
: []),
|
||||
];
|
||||
const linkResult =
|
||||
linkFilters.length === 0
|
||||
? null
|
||||
: await this.context.drizzle
|
||||
.delete(alertRuleChannels)
|
||||
.where(or(...linkFilters));
|
||||
|
||||
const ruleResult = await this.context.drizzle
|
||||
.delete(alertRules)
|
||||
.where(eq(alertRules.userId, userId));
|
||||
const result = await this.context.drizzle
|
||||
.delete(notificationChannels)
|
||||
.where(eq(notificationChannels.userId, userId));
|
||||
|
||||
if (
|
||||
rowsAffected(firingResult) > 0 ||
|
||||
rowsAffected(linkResult) > 0 ||
|
||||
rowsAffected(ruleResult) > 0 ||
|
||||
rowsAffected(result) > 0
|
||||
) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return {
|
||||
firingsDeleted: rowsAffected(firingResult),
|
||||
ruleLinksDeleted: rowsAffected(linkResult),
|
||||
rulesDeleted: rowsAffected(ruleResult),
|
||||
channelsDeleted: rowsAffected(result),
|
||||
};
|
||||
}
|
||||
|
||||
async listEnabledChannelsForRule(
|
||||
ruleId: number,
|
||||
): Promise<AlertEngineChannel[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
id: notificationChannels.id,
|
||||
userId: notificationChannels.userId,
|
||||
type: notificationChannels.type,
|
||||
config: notificationChannels.config,
|
||||
enabled: notificationChannels.enabled,
|
||||
})
|
||||
.from(notificationChannels)
|
||||
.innerJoin(
|
||||
alertRuleChannels,
|
||||
eq(alertRuleChannels.channelId, notificationChannels.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(alertRuleChannels.ruleId, ruleId),
|
||||
eq(notificationChannels.enabled, true),
|
||||
),
|
||||
);
|
||||
|
||||
// The engine sends without a user in scope, so decrypt against the owner.
|
||||
return rows.map(({ userId, ...channel }) => ({
|
||||
...channel,
|
||||
config: this.decryptConfig(channel.config, userId, channel.id),
|
||||
}));
|
||||
}
|
||||
|
||||
async getHostDisplayName(hostId: number): Promise<string | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ name: hosts.name, ip: hosts.ip })
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, hostId))
|
||||
.limit(1);
|
||||
|
||||
const row = rows[0];
|
||||
return row ? row.name || row.ip : null;
|
||||
}
|
||||
|
||||
private async replaceRuleChannels(
|
||||
ruleId: number,
|
||||
userId: string,
|
||||
channelIds: number[],
|
||||
): Promise<number[]> {
|
||||
await this.context.drizzle
|
||||
.delete(alertRuleChannels)
|
||||
.where(eq(alertRuleChannels.ruleId, ruleId));
|
||||
|
||||
const linked: number[] = [];
|
||||
for (const channelId of channelIds) {
|
||||
const channel = await this.findNotificationChannelForUser(
|
||||
channelId,
|
||||
userId,
|
||||
);
|
||||
if (!channel) continue;
|
||||
await this.context.drizzle
|
||||
.insert(alertRuleChannels)
|
||||
.values({ ruleId, channelId });
|
||||
linked.push(channelId);
|
||||
}
|
||||
return linked;
|
||||
}
|
||||
|
||||
private async listChannelIdsForRule(ruleId: number): Promise<number[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ channelId: alertRuleChannels.channelId })
|
||||
.from(alertRuleChannels)
|
||||
.where(eq(alertRuleChannels.ruleId, ruleId));
|
||||
|
||||
return rows.map((row) => row.channelId);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
|
||||
function mapChannelRow(row: NotificationChannelRecord): NotificationChannelRow {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.userId,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
config: row.config,
|
||||
enabled: row.enabled ? 1 : 0,
|
||||
created_at: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function mapRuleRow(row: AlertRuleRecord): AlertRuleRow {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.userId,
|
||||
host_id: row.hostId,
|
||||
name: row.name,
|
||||
enabled: row.enabled ? 1 : 0,
|
||||
trigger_type: row.triggerType,
|
||||
threshold_value: row.thresholdValue,
|
||||
threshold_duration_seconds: row.thresholdDurationSeconds,
|
||||
cooldown_minutes: row.cooldownMinutes,
|
||||
created_at: row.createdAt,
|
||||
updated_at: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function mapFiringRow(
|
||||
row: AlertFiringRecord,
|
||||
ruleName: string | null,
|
||||
): AlertFiringRow {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.userId,
|
||||
rule_id: row.ruleId,
|
||||
host_id: row.hostId,
|
||||
host_name: row.hostName,
|
||||
fired_at: row.firedAt,
|
||||
resolved_at: row.resolvedAt,
|
||||
value: row.value,
|
||||
message: row.message,
|
||||
severity: row.severity,
|
||||
acknowledged: row.acknowledged ? 1 : 0,
|
||||
rule_name: ruleName,
|
||||
};
|
||||
}
|
||||
|
||||
function mapEngineRule(row: AlertRuleRecord): AlertEngineRule {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
hostId: row.hostId,
|
||||
name: row.name,
|
||||
enabled: row.enabled,
|
||||
triggerType: row.triggerType,
|
||||
thresholdValue: row.thresholdValue,
|
||||
thresholdDurationSeconds: row.thresholdDurationSeconds,
|
||||
cooldownMinutes: row.cooldownMinutes,
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { primeSettingsCache, readCachedSetting } from "./settings-cache.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { WebauthnCredentialRepository } from "./webauthn-credential-repository.js";
|
||||
import { AiRepository } from "./ai-repository.js";
|
||||
import { AlertRepository } from "./alert-repository.js";
|
||||
import { NotificationChannelRepository } from "./notification-channel-repository.js";
|
||||
import { AutomationRepository } from "./automation-repository.js";
|
||||
import { ApiKeyRepository } from "./api-key-repository.js";
|
||||
import { AuditLogRepository } from "./audit-log-repository.js";
|
||||
@@ -161,10 +161,10 @@ export function createCurrentAiRepository(): AiRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentAlertRepository(): AlertRepository {
|
||||
return new AlertRepository(
|
||||
export function createCurrentNotificationChannelRepository(): NotificationChannelRepository {
|
||||
return new NotificationChannelRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("alert_repository_write"),
|
||||
createCurrentRepositoryWriteHook("notification_channel_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { notificationChannels } from "../db/schema.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
type NotificationChannelRecord = typeof notificationChannels.$inferSelect;
|
||||
|
||||
export interface NotificationChannelRow {
|
||||
id: number;
|
||||
user_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export class NotificationChannelRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Channel configs carry ntfy tokens and webhook auth headers, so they are
|
||||
* field-encrypted like any other secret. The key is derived from the row id,
|
||||
* which does not exist until after the insert, so a new channel is written
|
||||
* once and then re-encrypted in place with its real id.
|
||||
*/
|
||||
private userDataKey(userId: string): Buffer | null {
|
||||
try {
|
||||
return DataCrypto.getUserDataKey(userId);
|
||||
} catch {
|
||||
// Crypto is not initialized (tests, early boot); leave the value as is.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private encryptConfig(
|
||||
config: string,
|
||||
userId: string,
|
||||
recordId: number | string,
|
||||
): string {
|
||||
const userDataKey = this.userDataKey(userId);
|
||||
if (!userDataKey) return config;
|
||||
return DataCrypto.encryptRecord(
|
||||
"notification_channels",
|
||||
{ id: recordId, config },
|
||||
userId,
|
||||
userDataKey,
|
||||
).config;
|
||||
}
|
||||
|
||||
private decryptConfig(
|
||||
config: string,
|
||||
userId: string,
|
||||
recordId: number | string,
|
||||
): string {
|
||||
const userDataKey = this.userDataKey(userId);
|
||||
if (!userDataKey) return config;
|
||||
try {
|
||||
return DataCrypto.decryptRecord(
|
||||
"notification_channels",
|
||||
{ id: recordId, config },
|
||||
userId,
|
||||
userDataKey,
|
||||
).config;
|
||||
} catch {
|
||||
// Rows written before channel configs were encrypted are still plaintext.
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
async listNotificationChannels(
|
||||
userId: string,
|
||||
): Promise<NotificationChannelRow[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(notificationChannels)
|
||||
.where(eq(notificationChannels.userId, userId))
|
||||
.orderBy(notificationChannels.id);
|
||||
|
||||
return rows.map((row) => {
|
||||
const mapped = mapChannelRow(row);
|
||||
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
|
||||
return mapped;
|
||||
});
|
||||
}
|
||||
|
||||
async findNotificationChannelForUser(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<NotificationChannelRow | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(notificationChannels)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationChannels.id, id),
|
||||
eq(notificationChannels.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!rows[0]) return null;
|
||||
const mapped = mapChannelRow(rows[0]);
|
||||
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async createNotificationChannel(input: {
|
||||
userId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: boolean;
|
||||
}): Promise<NotificationChannelRow> {
|
||||
const [created] = await insertReturning(
|
||||
this.context,
|
||||
notificationChannels,
|
||||
{
|
||||
userId: input.userId,
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
config: input.config,
|
||||
enabled: input.enabled,
|
||||
},
|
||||
);
|
||||
|
||||
const encrypted = this.encryptConfig(
|
||||
input.config,
|
||||
input.userId,
|
||||
created.id,
|
||||
);
|
||||
if (encrypted !== input.config) {
|
||||
await this.context.drizzle
|
||||
.update(notificationChannels)
|
||||
.set({ config: encrypted })
|
||||
.where(eq(notificationChannels.id, created.id));
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
const mapped = mapChannelRow(created);
|
||||
mapped.config = input.config;
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async updateNotificationChannel(
|
||||
id: number,
|
||||
userId: string,
|
||||
input: {
|
||||
name?: string;
|
||||
type?: string;
|
||||
config?: string;
|
||||
enabled?: boolean;
|
||||
},
|
||||
): Promise<NotificationChannelRow | null> {
|
||||
if (Object.keys(input).length === 0) {
|
||||
return this.findNotificationChannelForUser(id, userId);
|
||||
}
|
||||
|
||||
const values =
|
||||
input.config !== undefined
|
||||
? { ...input, config: this.encryptConfig(input.config, userId, id) }
|
||||
: input;
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
notificationChannels,
|
||||
values,
|
||||
and(
|
||||
eq(notificationChannels.id, id),
|
||||
eq(notificationChannels.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (!updated) return null;
|
||||
await this.afterWrite();
|
||||
const mapped = mapChannelRow(updated);
|
||||
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async deleteNotificationChannel(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(notificationChannels)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationChannels.id, id),
|
||||
eq(notificationChannels.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) === 0) return false;
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<{ channelsDeleted: number }> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(notificationChannels)
|
||||
.where(eq(notificationChannels.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return { channelsDeleted: rowsAffected(result) };
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
|
||||
function mapChannelRow(row: NotificationChannelRecord): NotificationChannelRow {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.userId,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
config: row.config,
|
||||
enabled: row.enabled ? 1 : 0,
|
||||
created_at: row.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -1,534 +0,0 @@
|
||||
import express, { type Request, type Response } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { createCurrentAlertRepository } from "../repositories/factory.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { sendWebhook, sendNtfy } from "../../utils/notification-sender.js";
|
||||
import { sendDiscord } from "../../utils/discord-sender.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
const VALID_TRIGGER_TYPES = new Set([
|
||||
"host_offline",
|
||||
"host_online",
|
||||
"cpu_threshold",
|
||||
"memory_threshold",
|
||||
"disk_threshold",
|
||||
"health_check_failure",
|
||||
"health_check_recovery",
|
||||
"user_login",
|
||||
]);
|
||||
|
||||
router.use(authenticateJWT);
|
||||
|
||||
// ---- Notification Channels ----
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels:
|
||||
* get:
|
||||
* summary: List notification channels for the current user
|
||||
* tags:
|
||||
* - Alerts
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of notification channels.
|
||||
*/
|
||||
router.get("/notification-channels", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const rows =
|
||||
await createCurrentAlertRepository().listNotificationChannels(userId);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to list notification channels", {
|
||||
operation: "list_channels",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list channels" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels:
|
||||
* post:
|
||||
* summary: Create a notification channel
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.post("/notification-channels", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, type, config, enabled } = req.body as {
|
||||
name: string;
|
||||
type: string;
|
||||
config: unknown;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
return res.status(400).json({ error: "name is required" });
|
||||
}
|
||||
if (type !== "webhook" && type !== "ntfy" && type !== "discord") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "type must be 'webhook', 'ntfy' or 'discord'" });
|
||||
}
|
||||
if (!config || typeof config !== "object") {
|
||||
return res.status(400).json({ error: "config is required" });
|
||||
}
|
||||
if (type === "ntfy") {
|
||||
const c = config as Record<string, unknown>;
|
||||
if (!c.url || typeof c.url !== "string")
|
||||
return res.status(400).json({ error: "ntfy config requires url" });
|
||||
if (!c.topic || typeof c.topic !== "string")
|
||||
return res.status(400).json({ error: "ntfy config requires topic" });
|
||||
}
|
||||
if (type === "webhook") {
|
||||
const c = config as Record<string, unknown>;
|
||||
if (!c.url || typeof c.url !== "string")
|
||||
return res.status(400).json({ error: "webhook config requires url" });
|
||||
}
|
||||
if (type === "discord") {
|
||||
const c = config as Record<string, unknown>;
|
||||
if (!c.url || typeof c.url !== "string")
|
||||
return res.status(400).json({ error: "discord config requires url" });
|
||||
if (
|
||||
!/^https:\/\/(?:canary\.|ptb\.)?(?:discord\.com|discordapp\.com)\/api\/webhooks\/.+/i.test(
|
||||
c.url,
|
||||
)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"discord config requires a valid Discord webhook URL (https://discord.com/api/webhooks/...)",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const row = await createCurrentAlertRepository().createNotificationChannel({
|
||||
userId,
|
||||
name: name.trim(),
|
||||
type,
|
||||
config: JSON.stringify(config),
|
||||
enabled: enabled !== false,
|
||||
});
|
||||
res.status(201).json(row);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to create notification channel", {
|
||||
operation: "create_channel",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to create channel" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels/{id}:
|
||||
* put:
|
||||
* summary: Update a notification channel
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.put(
|
||||
"/notification-channels/:id",
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const channelId = Number(req.params.id);
|
||||
const { name, type, config, enabled } = req.body as {
|
||||
name?: string;
|
||||
type?: string;
|
||||
config?: unknown;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
const repository = createCurrentAlertRepository();
|
||||
const existing = await repository.findNotificationChannelForUser(
|
||||
channelId,
|
||||
userId,
|
||||
);
|
||||
if (!existing) return res.status(404).json({ error: "Channel not found" });
|
||||
|
||||
if (type && type !== "webhook" && type !== "ntfy" && type !== "discord") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "type must be 'webhook', 'ntfy' or 'discord'" });
|
||||
}
|
||||
if (
|
||||
name === undefined &&
|
||||
type === undefined &&
|
||||
config === undefined &&
|
||||
enabled === undefined
|
||||
) {
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const row = await repository.updateNotificationChannel(
|
||||
channelId,
|
||||
userId,
|
||||
{
|
||||
...(name !== undefined ? { name: name.trim() } : {}),
|
||||
...(type !== undefined ? { type } : {}),
|
||||
...(config !== undefined ? { config: JSON.stringify(config) } : {}),
|
||||
...(enabled !== undefined ? { enabled } : {}),
|
||||
},
|
||||
);
|
||||
if (!row) return res.status(404).json({ error: "Channel not found" });
|
||||
res.json(row);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to update notification channel", {
|
||||
operation: "update_channel",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update channel" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels/{id}:
|
||||
* delete:
|
||||
* summary: Delete a notification channel
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.delete(
|
||||
"/notification-channels/:id",
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const channelId = Number(req.params.id);
|
||||
const deleted =
|
||||
await createCurrentAlertRepository().deleteNotificationChannel(
|
||||
channelId,
|
||||
userId,
|
||||
);
|
||||
if (!deleted) return res.status(404).json({ error: "Channel not found" });
|
||||
res.json({ success: true });
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels/{id}/test:
|
||||
* post:
|
||||
* summary: Send a test notification
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.post(
|
||||
"/notification-channels/:id/test",
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const channelId = Number(req.params.id);
|
||||
const row =
|
||||
await createCurrentAlertRepository().findNotificationChannelForUser(
|
||||
channelId,
|
||||
userId,
|
||||
);
|
||||
if (!row) return res.status(404).json({ error: "Channel not found" });
|
||||
|
||||
const testPayload = {
|
||||
hostName: "Test Host",
|
||||
hostId: 0,
|
||||
triggerType: "test",
|
||||
message: "This is a test notification from Termix",
|
||||
severity: "info" as const,
|
||||
timestamp: new Date().toISOString(),
|
||||
ruleId: 0,
|
||||
ruleName: "Test",
|
||||
};
|
||||
|
||||
try {
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = JSON.parse(row.config) as Record<string, unknown>;
|
||||
} catch {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "Invalid channel config" });
|
||||
}
|
||||
|
||||
if (row.type === "webhook") {
|
||||
await sendWebhook(
|
||||
config as unknown as Parameters<typeof sendWebhook>[0],
|
||||
testPayload,
|
||||
);
|
||||
} else if (row.type === "ntfy") {
|
||||
await sendNtfy(
|
||||
config as unknown as Parameters<typeof sendNtfy>[0],
|
||||
testPayload,
|
||||
);
|
||||
} else if (row.type === "discord") {
|
||||
await sendDiscord(
|
||||
config as unknown as Parameters<typeof sendDiscord>[0],
|
||||
testPayload,
|
||||
);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.json({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- Alert Rules ----
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-rules:
|
||||
* get:
|
||||
* summary: List alert rules for the current user
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.get("/alert-rules", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
res.json(await createCurrentAlertRepository().listAlertRules(userId));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to list alert rules", {
|
||||
operation: "list_alert_rules",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list alert rules" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-rules:
|
||||
* post:
|
||||
* summary: Create an alert rule
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.post("/alert-rules", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const {
|
||||
name,
|
||||
hostId,
|
||||
enabled,
|
||||
triggerType,
|
||||
thresholdValue,
|
||||
thresholdDurationSeconds,
|
||||
cooldownMinutes,
|
||||
channels = [],
|
||||
} = req.body as {
|
||||
name: string;
|
||||
hostId?: number | null;
|
||||
enabled?: boolean;
|
||||
triggerType: string;
|
||||
thresholdValue?: number | null;
|
||||
thresholdDurationSeconds?: number | null;
|
||||
cooldownMinutes?: number;
|
||||
channels?: number[];
|
||||
};
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
return res.status(400).json({ error: "name is required" });
|
||||
}
|
||||
if (!VALID_TRIGGER_TYPES.has(triggerType)) {
|
||||
return res.status(400).json({ error: "Invalid triggerType" });
|
||||
}
|
||||
if (thresholdValue != null && (thresholdValue < 0 || thresholdValue > 100)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "thresholdValue must be between 0 and 100" });
|
||||
}
|
||||
if (thresholdDurationSeconds != null && thresholdDurationSeconds < 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "thresholdDurationSeconds must be >= 0" });
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const row = await createCurrentAlertRepository().createAlertRule({
|
||||
userId,
|
||||
hostId: hostId ?? null,
|
||||
name: name.trim(),
|
||||
enabled: enabled !== false,
|
||||
triggerType,
|
||||
thresholdValue: thresholdValue ?? null,
|
||||
thresholdDurationSeconds: thresholdDurationSeconds ?? null,
|
||||
cooldownMinutes: cooldownMinutes ?? 15,
|
||||
channels,
|
||||
now,
|
||||
});
|
||||
res.status(201).json({ ...row, channels });
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to create alert rule", {
|
||||
operation: "create_alert_rule",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to create alert rule" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-rules/{id}:
|
||||
* put:
|
||||
* summary: Update an alert rule
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.put("/alert-rules/:id", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const ruleId = Number(req.params.id);
|
||||
|
||||
const repository = createCurrentAlertRepository();
|
||||
const existing = await repository.findAlertRuleForUser(ruleId, userId);
|
||||
if (!existing) return res.status(404).json({ error: "Alert rule not found" });
|
||||
|
||||
const {
|
||||
name,
|
||||
hostId,
|
||||
enabled,
|
||||
triggerType,
|
||||
thresholdValue,
|
||||
thresholdDurationSeconds,
|
||||
cooldownMinutes,
|
||||
channels,
|
||||
} = req.body as {
|
||||
name?: string;
|
||||
hostId?: number | null;
|
||||
enabled?: boolean;
|
||||
triggerType?: string;
|
||||
thresholdValue?: number | null;
|
||||
thresholdDurationSeconds?: number | null;
|
||||
cooldownMinutes?: number;
|
||||
channels?: number[];
|
||||
};
|
||||
|
||||
if (triggerType && !VALID_TRIGGER_TYPES.has(triggerType)) {
|
||||
return res.status(400).json({ error: "Invalid triggerType" });
|
||||
}
|
||||
|
||||
try {
|
||||
const row = await repository.updateAlertRule(ruleId, userId, {
|
||||
...(name !== undefined ? { name: name.trim() } : {}),
|
||||
...(hostId !== undefined ? { hostId: hostId ?? null } : {}),
|
||||
...(enabled !== undefined ? { enabled } : {}),
|
||||
...(triggerType !== undefined ? { triggerType } : {}),
|
||||
...(thresholdValue !== undefined
|
||||
? { thresholdValue: thresholdValue ?? null }
|
||||
: {}),
|
||||
...(thresholdDurationSeconds !== undefined
|
||||
? { thresholdDurationSeconds: thresholdDurationSeconds ?? null }
|
||||
: {}),
|
||||
...(cooldownMinutes !== undefined ? { cooldownMinutes } : {}),
|
||||
...(channels !== undefined ? { channels } : {}),
|
||||
now: new Date().toISOString(),
|
||||
});
|
||||
if (!row) return res.status(404).json({ error: "Alert rule not found" });
|
||||
res.json(row);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to update alert rule", {
|
||||
operation: "update_alert_rule",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update alert rule" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-rules/{id}:
|
||||
* delete:
|
||||
* summary: Delete an alert rule
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.delete("/alert-rules/:id", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const ruleId = Number(req.params.id);
|
||||
const deleted = await createCurrentAlertRepository().deleteAlertRule(
|
||||
ruleId,
|
||||
userId,
|
||||
);
|
||||
if (!deleted) return res.status(404).json({ error: "Alert rule not found" });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ---- Alert Firings ----
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-firings:
|
||||
* get:
|
||||
* summary: List alert firings for the current user
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.get("/alert-firings", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const limit = Math.min(Number(req.query.limit) || 50, 200);
|
||||
const offset = Number(req.query.offset) || 0;
|
||||
const acknowledgedParam = req.query.acknowledged;
|
||||
|
||||
try {
|
||||
const acknowledged =
|
||||
acknowledgedParam === "true"
|
||||
? true
|
||||
: acknowledgedParam === "false"
|
||||
? false
|
||||
: undefined;
|
||||
res.json(
|
||||
await createCurrentAlertRepository().listAlertFirings({
|
||||
userId,
|
||||
acknowledged,
|
||||
limit,
|
||||
offset,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to list alert firings", {
|
||||
operation: "list_alert_firings",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list alert firings" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-firings/{id}/acknowledge:
|
||||
* post:
|
||||
* summary: Acknowledge an alert firing
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.post(
|
||||
"/alert-firings/:id/acknowledge",
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const firingId = Number(req.params.id);
|
||||
await createCurrentAlertRepository().acknowledgeFiring(firingId, userId);
|
||||
res.json({ success: true });
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-firings/acknowledge-all:
|
||||
* post:
|
||||
* summary: Acknowledge all alert firings for the current user
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.post("/alert-firings/acknowledge-all", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
await createCurrentAlertRepository().acknowledgeAllFirings(userId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
createCurrentAiRepository,
|
||||
createCurrentAlertRepository,
|
||||
createCurrentNotificationChannelRepository,
|
||||
createCurrentApiKeyRepository,
|
||||
createCurrentAuditLogRepository,
|
||||
createCurrentC2sTunnelPresetRepository,
|
||||
@@ -82,7 +82,7 @@ export async function deleteUserAndRelatedData(
|
||||
|
||||
await createCurrentRoleRepository().removeAllRolesFromUser(userId);
|
||||
await createCurrentAiRepository().deleteByUserId(userId);
|
||||
await createCurrentAlertRepository().deleteByUserId(userId);
|
||||
await createCurrentNotificationChannelRepository().deleteByUserId(userId);
|
||||
await createCurrentAuditLogRepository().anonymizeByUserId(userId);
|
||||
|
||||
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import express, { type Request, type Response } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { createCurrentNotificationChannelRepository } from "../repositories/factory.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { sendWebhook, sendNtfy } from "../../utils/notification-sender.js";
|
||||
import { sendDiscord } from "../../utils/discord-sender.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
router.use(authenticateJWT);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels:
|
||||
* get:
|
||||
* summary: List notification channels for the current user
|
||||
* tags:
|
||||
* - Notifications
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of notification channels.
|
||||
*/
|
||||
router.get("/notification-channels", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const rows =
|
||||
await createCurrentNotificationChannelRepository().listNotificationChannels(
|
||||
userId,
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to list notification channels", {
|
||||
operation: "list_channels",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list channels" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels:
|
||||
* post:
|
||||
* summary: Create a notification channel
|
||||
* tags:
|
||||
* - Notifications
|
||||
*/
|
||||
router.post("/notification-channels", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, type, config, enabled } = req.body as {
|
||||
name: string;
|
||||
type: string;
|
||||
config: unknown;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
return res.status(400).json({ error: "name is required" });
|
||||
}
|
||||
if (type !== "webhook" && type !== "ntfy" && type !== "discord") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "type must be 'webhook', 'ntfy' or 'discord'" });
|
||||
}
|
||||
if (!config || typeof config !== "object") {
|
||||
return res.status(400).json({ error: "config is required" });
|
||||
}
|
||||
if (type === "ntfy") {
|
||||
const c = config as Record<string, unknown>;
|
||||
if (!c.url || typeof c.url !== "string")
|
||||
return res.status(400).json({ error: "ntfy config requires url" });
|
||||
if (!c.topic || typeof c.topic !== "string")
|
||||
return res.status(400).json({ error: "ntfy config requires topic" });
|
||||
}
|
||||
if (type === "webhook") {
|
||||
const c = config as Record<string, unknown>;
|
||||
if (!c.url || typeof c.url !== "string")
|
||||
return res.status(400).json({ error: "webhook config requires url" });
|
||||
}
|
||||
if (type === "discord") {
|
||||
const c = config as Record<string, unknown>;
|
||||
if (!c.url || typeof c.url !== "string")
|
||||
return res.status(400).json({ error: "discord config requires url" });
|
||||
if (
|
||||
!/^https:\/\/(?:canary\.|ptb\.)?(?:discord\.com|discordapp\.com)\/api\/webhooks\/.+/i.test(
|
||||
c.url,
|
||||
)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"discord config requires a valid Discord webhook URL (https://discord.com/api/webhooks/...)",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const row =
|
||||
await createCurrentNotificationChannelRepository().createNotificationChannel(
|
||||
{
|
||||
userId,
|
||||
name: name.trim(),
|
||||
type,
|
||||
config: JSON.stringify(config),
|
||||
enabled: enabled !== false,
|
||||
},
|
||||
);
|
||||
res.status(201).json(row);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to create notification channel", {
|
||||
operation: "create_channel",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to create channel" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels/{id}:
|
||||
* put:
|
||||
* summary: Update a notification channel
|
||||
* tags:
|
||||
* - Notifications
|
||||
*/
|
||||
router.put(
|
||||
"/notification-channels/:id",
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const channelId = Number(req.params.id);
|
||||
const { name, type, config, enabled } = req.body as {
|
||||
name?: string;
|
||||
type?: string;
|
||||
config?: unknown;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
const repository = createCurrentNotificationChannelRepository();
|
||||
const existing = await repository.findNotificationChannelForUser(
|
||||
channelId,
|
||||
userId,
|
||||
);
|
||||
if (!existing) return res.status(404).json({ error: "Channel not found" });
|
||||
|
||||
if (type && type !== "webhook" && type !== "ntfy" && type !== "discord") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "type must be 'webhook', 'ntfy' or 'discord'" });
|
||||
}
|
||||
if (
|
||||
name === undefined &&
|
||||
type === undefined &&
|
||||
config === undefined &&
|
||||
enabled === undefined
|
||||
) {
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const row = await repository.updateNotificationChannel(
|
||||
channelId,
|
||||
userId,
|
||||
{
|
||||
...(name !== undefined ? { name: name.trim() } : {}),
|
||||
...(type !== undefined ? { type } : {}),
|
||||
...(config !== undefined ? { config: JSON.stringify(config) } : {}),
|
||||
...(enabled !== undefined ? { enabled } : {}),
|
||||
},
|
||||
);
|
||||
if (!row) return res.status(404).json({ error: "Channel not found" });
|
||||
res.json(row);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to update notification channel", {
|
||||
operation: "update_channel",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update channel" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels/{id}:
|
||||
* delete:
|
||||
* summary: Delete a notification channel
|
||||
* tags:
|
||||
* - Notifications
|
||||
*/
|
||||
router.delete(
|
||||
"/notification-channels/:id",
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const channelId = Number(req.params.id);
|
||||
const deleted =
|
||||
await createCurrentNotificationChannelRepository().deleteNotificationChannel(
|
||||
channelId,
|
||||
userId,
|
||||
);
|
||||
if (!deleted) return res.status(404).json({ error: "Channel not found" });
|
||||
res.json({ success: true });
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels/{id}/test:
|
||||
* post:
|
||||
* summary: Send a test notification
|
||||
* tags:
|
||||
* - Notifications
|
||||
*/
|
||||
router.post(
|
||||
"/notification-channels/:id/test",
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const channelId = Number(req.params.id);
|
||||
const row =
|
||||
await createCurrentNotificationChannelRepository().findNotificationChannelForUser(
|
||||
channelId,
|
||||
userId,
|
||||
);
|
||||
if (!row) return res.status(404).json({ error: "Channel not found" });
|
||||
|
||||
const testPayload = {
|
||||
hostName: "Test Host",
|
||||
hostId: 0,
|
||||
triggerType: "test",
|
||||
message: "This is a test notification from Termix",
|
||||
severity: "info" as const,
|
||||
timestamp: new Date().toISOString(),
|
||||
ruleId: 0,
|
||||
ruleName: "Test",
|
||||
};
|
||||
|
||||
try {
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = JSON.parse(row.config) as Record<string, unknown>;
|
||||
} catch {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "Invalid channel config" });
|
||||
}
|
||||
|
||||
if (row.type === "webhook") {
|
||||
await sendWebhook(
|
||||
config as unknown as Parameters<typeof sendWebhook>[0],
|
||||
testPayload,
|
||||
);
|
||||
} else if (row.type === "ntfy") {
|
||||
await sendNtfy(
|
||||
config as unknown as Parameters<typeof sendNtfy>[0],
|
||||
testPayload,
|
||||
);
|
||||
} else if (row.type === "discord") {
|
||||
await sendDiscord(
|
||||
config as unknown as Parameters<typeof sendDiscord>[0],
|
||||
testPayload,
|
||||
);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.json({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
} from "../terminal-auth-helpers.js";
|
||||
import { isWindowsSftpPath, sftpPathToLocalPath } from "../transfer-paths.js";
|
||||
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
|
||||
import { triggerLoginAlert } from "../../utils/alert-trigger.js";
|
||||
import { dispatchLoginEvent } from "../../utils/login-event-dispatch.js";
|
||||
import { getClientIp } from "../../utils/request-origin.js";
|
||||
import { isRetriableDnsError, resolveHostForSshConnect } from "../ssh-dns.js";
|
||||
import { resolveSshKeepalive } from "../ssh-keepalive.js";
|
||||
@@ -2483,7 +2483,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
);
|
||||
|
||||
if (id && hostConfig.userId) {
|
||||
triggerLoginAlert(
|
||||
dispatchLoginEvent(
|
||||
id,
|
||||
hostConfig.userId,
|
||||
username,
|
||||
|
||||
@@ -265,10 +265,6 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
await import("./utils/crypto-migration/channel-config-encryption.js");
|
||||
await runChannelConfigEncryptionMigration();
|
||||
|
||||
const { runAutomationsMigration } =
|
||||
await import("./utils/crypto-migration/automations-migration.js");
|
||||
await runAutomationsMigration();
|
||||
|
||||
if (process.env.ELECTRON_EMBEDDED === "true") {
|
||||
await provisionLocalDesktopUserIfNeeded();
|
||||
}
|
||||
|
||||
@@ -1,471 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { AlertRepository } from "../../../database/repositories/alert-repository.js";
|
||||
import { DataCrypto } from "../../../utils/data-crypto.js";
|
||||
|
||||
describe("AlertRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<AlertRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES (1, 'user-1', 'alpha', '127.0.0.1', 22, 'root', 'password');
|
||||
`);
|
||||
|
||||
return new AlertRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("manages notification channels", async () => {
|
||||
let writes = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
const created = await repo.createNotificationChannel({
|
||||
userId: "user-1",
|
||||
name: "Ops",
|
||||
type: "webhook",
|
||||
config: '{"url":"https://example.test"}',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({
|
||||
user_id: "user-1",
|
||||
name: "Ops",
|
||||
type: "webhook",
|
||||
enabled: 1,
|
||||
});
|
||||
|
||||
const updated = await repo.updateNotificationChannel(created.id, "user-1", {
|
||||
name: "Ops disabled",
|
||||
enabled: false,
|
||||
});
|
||||
expect(updated).toMatchObject({ name: "Ops disabled", enabled: 0 });
|
||||
|
||||
expect(await repo.listNotificationChannels("user-1")).toHaveLength(1);
|
||||
expect(await repo.deleteNotificationChannel(created.id, "user-2")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(await repo.deleteNotificationChannel(created.id, "user-1")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(await repo.listNotificationChannels("user-1")).toHaveLength(0);
|
||||
expect(writes).toBe(3);
|
||||
});
|
||||
|
||||
it("manages alert rules and linked channels", async () => {
|
||||
const repo = await createRepository();
|
||||
const ownedChannel = await repo.createNotificationChannel({
|
||||
userId: "user-1",
|
||||
name: "Owned",
|
||||
type: "ntfy",
|
||||
config: '{"url":"https://ntfy.test","topic":"termix"}',
|
||||
enabled: true,
|
||||
});
|
||||
const foreignChannel = await repo.createNotificationChannel({
|
||||
userId: "user-2",
|
||||
name: "Foreign",
|
||||
type: "webhook",
|
||||
config: '{"url":"https://example.test"}',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const created = await repo.createAlertRule({
|
||||
userId: "user-1",
|
||||
hostId: null,
|
||||
name: "CPU high",
|
||||
enabled: true,
|
||||
triggerType: "cpu_threshold",
|
||||
thresholdValue: 80,
|
||||
thresholdDurationSeconds: 30,
|
||||
cooldownMinutes: 5,
|
||||
channels: [ownedChannel.id, foreignChannel.id],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({
|
||||
user_id: "user-1",
|
||||
host_id: null,
|
||||
name: "CPU high",
|
||||
trigger_type: "cpu_threshold",
|
||||
threshold_value: 80,
|
||||
channels: [ownedChannel.id],
|
||||
});
|
||||
|
||||
const updated = await repo.updateAlertRule(created.id, "user-1", {
|
||||
name: "CPU very high",
|
||||
hostId: 1,
|
||||
channels: [],
|
||||
now: "2026-01-02T00:00:00.000Z",
|
||||
});
|
||||
expect(updated).toMatchObject({
|
||||
name: "CPU very high",
|
||||
host_id: 1,
|
||||
channels: [],
|
||||
updated_at: "2026-01-02T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const rules = await repo.listAlertRules("user-1");
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(rules[0].channels).toEqual([]);
|
||||
expect(await repo.deleteAlertRule(created.id, "user-2")).toBe(false);
|
||||
expect(await repo.deleteAlertRule(created.id, "user-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("lists, acknowledges, and prunes firings", async () => {
|
||||
const repo = await createRepository();
|
||||
const rule = await repo.createAlertRule({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
name: "Host offline",
|
||||
enabled: true,
|
||||
triggerType: "host_offline",
|
||||
thresholdValue: null,
|
||||
thresholdDurationSeconds: null,
|
||||
cooldownMinutes: 15,
|
||||
channels: [],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
await repo.createFiring({
|
||||
userId: "user-1",
|
||||
ruleId: rule.id,
|
||||
hostId: 1,
|
||||
hostName: "alpha",
|
||||
value: null,
|
||||
message: "down",
|
||||
severity: "critical",
|
||||
});
|
||||
|
||||
const listed = await repo.listAlertFirings({
|
||||
userId: "user-1",
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
expect(listed.total).toBe(1);
|
||||
expect(listed.firings[0]).toMatchObject({
|
||||
rule_id: rule.id,
|
||||
host_name: "alpha",
|
||||
acknowledged: 0,
|
||||
rule_name: "Host offline",
|
||||
});
|
||||
|
||||
await repo.acknowledgeFiring(listed.firings[0].id, "user-1");
|
||||
const unacknowledged = await repo.listAlertFirings({
|
||||
userId: "user-1",
|
||||
acknowledged: false,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
expect(unacknowledged.total).toBe(0);
|
||||
|
||||
await repo.acknowledgeAllFirings("user-1");
|
||||
await repo.pruneFiringsOlderThan("user-1", 0);
|
||||
});
|
||||
|
||||
it("loads enabled rules and notification channels for the alert engine", async () => {
|
||||
const repo = await createRepository();
|
||||
const channel = await repo.createNotificationChannel({
|
||||
userId: "user-1",
|
||||
name: "Ops",
|
||||
type: "webhook",
|
||||
config: '{"url":"https://example.test"}',
|
||||
enabled: true,
|
||||
});
|
||||
const disabledChannel = await repo.createNotificationChannel({
|
||||
userId: "user-1",
|
||||
name: "Disabled",
|
||||
type: "webhook",
|
||||
config: '{"url":"https://disabled.test"}',
|
||||
enabled: false,
|
||||
});
|
||||
const rule = await repo.createAlertRule({
|
||||
userId: "user-1",
|
||||
hostId: null,
|
||||
name: "CPU high",
|
||||
enabled: true,
|
||||
triggerType: "cpu_threshold",
|
||||
thresholdValue: 90,
|
||||
thresholdDurationSeconds: 0,
|
||||
cooldownMinutes: 15,
|
||||
channels: [channel.id, disabledChannel.id],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(await repo.listEnabledRulesForHost(1)).toMatchObject([
|
||||
{
|
||||
id: rule.id,
|
||||
userId: "user-1",
|
||||
triggerType: "cpu_threshold",
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
expect(await repo.findRuleById(rule.id)).toMatchObject({
|
||||
id: rule.id,
|
||||
cooldownMinutes: 15,
|
||||
});
|
||||
expect(await repo.listEnabledChannelsForRule(rule.id)).toEqual([
|
||||
{
|
||||
id: channel.id,
|
||||
type: "webhook",
|
||||
config: '{"url":"https://example.test"}',
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps another user's wildcard rules off a host they do not own", async () => {
|
||||
const repo = await createRepository();
|
||||
await adapter!.exec(`
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES (2, 'user-2', 'bravo', '127.0.0.2', 22, 'root', 'password');
|
||||
`);
|
||||
|
||||
const ownerRule = await repo.createAlertRule({
|
||||
userId: "user-1",
|
||||
hostId: null,
|
||||
name: "Owner wildcard",
|
||||
enabled: true,
|
||||
triggerType: "cpu_threshold",
|
||||
thresholdValue: 90,
|
||||
thresholdDurationSeconds: 0,
|
||||
cooldownMinutes: 15,
|
||||
channels: [],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const otherRule = await repo.createAlertRule({
|
||||
userId: "user-2",
|
||||
hostId: null,
|
||||
name: "Other wildcard",
|
||||
enabled: true,
|
||||
triggerType: "cpu_threshold",
|
||||
thresholdValue: 90,
|
||||
thresholdDurationSeconds: 0,
|
||||
cooldownMinutes: 15,
|
||||
channels: [],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
// Host 1 belongs to user-1, so only user-1's wildcard rule may fire.
|
||||
const forHostOne = await repo.listEnabledRulesForHost(1);
|
||||
expect(forHostOne.map((rule) => rule.id)).toEqual([ownerRule.id]);
|
||||
|
||||
const forHostTwo = await repo.listEnabledRulesForHost(2);
|
||||
expect(forHostTwo.map((rule) => rule.id)).toEqual([otherRule.id]);
|
||||
});
|
||||
|
||||
it("still matches a rule pinned to a specific host", async () => {
|
||||
const repo = await createRepository();
|
||||
const pinned = await repo.createAlertRule({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
name: "Pinned",
|
||||
enabled: true,
|
||||
triggerType: "disk_threshold",
|
||||
thresholdValue: 90,
|
||||
thresholdDurationSeconds: 0,
|
||||
cooldownMinutes: 15,
|
||||
channels: [],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect((await repo.listEnabledRulesForHost(1)).map((r) => r.id)).toEqual([
|
||||
pinned.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it("encrypts channel configs at rest and returns them decrypted", async () => {
|
||||
const key = crypto.randomBytes(32);
|
||||
const spy = vi
|
||||
.spyOn(DataCrypto, "getUserDataKey")
|
||||
.mockImplementation(() => key);
|
||||
|
||||
try {
|
||||
const repo = await createRepository();
|
||||
const secret = '{"url":"https://ntfy.test","token":"super-secret"}';
|
||||
|
||||
const created = await repo.createNotificationChannel({
|
||||
userId: "user-1",
|
||||
name: "Ntfy",
|
||||
type: "ntfy",
|
||||
config: secret,
|
||||
enabled: true,
|
||||
});
|
||||
expect(created.config).toBe(secret);
|
||||
|
||||
// The stored bytes must not contain the token in the clear.
|
||||
const stored = await adapter!.query<{ config: string }>(
|
||||
sql`SELECT config FROM notification_channels WHERE id = ${created.id}`,
|
||||
);
|
||||
expect(stored[0].config).not.toContain("super-secret");
|
||||
|
||||
// Every read path hands back plaintext.
|
||||
const listed = await repo.listNotificationChannels("user-1");
|
||||
expect(listed[0].config).toBe(secret);
|
||||
expect(
|
||||
(await repo.findNotificationChannelForUser(created.id, "user-1"))
|
||||
?.config,
|
||||
).toBe(secret);
|
||||
|
||||
const rule = await repo.createAlertRule({
|
||||
userId: "user-1",
|
||||
hostId: null,
|
||||
name: "CPU",
|
||||
enabled: true,
|
||||
triggerType: "cpu_threshold",
|
||||
thresholdValue: 90,
|
||||
thresholdDurationSeconds: 0,
|
||||
cooldownMinutes: 15,
|
||||
channels: [created.id],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const engineChannels = await repo.listEnabledChannelsForRule(rule.id);
|
||||
expect(engineChannels[0].config).toBe(secret);
|
||||
|
||||
const rotated = '{"url":"https://ntfy.test","token":"rotated"}';
|
||||
const updated = await repo.updateNotificationChannel(
|
||||
created.id,
|
||||
"user-1",
|
||||
{ config: rotated },
|
||||
);
|
||||
expect(updated?.config).toBe(rotated);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("still reads channel configs written before encryption", async () => {
|
||||
const repo = await createRepository();
|
||||
const plaintext = '{"url":"https://legacy.test"}';
|
||||
const created = await repo.createNotificationChannel({
|
||||
userId: "user-1",
|
||||
name: "Legacy",
|
||||
type: "webhook",
|
||||
config: plaintext,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const key = crypto.randomBytes(32);
|
||||
const spy = vi
|
||||
.spyOn(DataCrypto, "getUserDataKey")
|
||||
.mockImplementation(() => key);
|
||||
try {
|
||||
const found = await repo.findNotificationChannelForUser(
|
||||
created.id,
|
||||
"user-1",
|
||||
);
|
||||
expect(found?.config).toBe(plaintext);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("loads host display names for alert payloads", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
expect(await repo.getHostDisplayName(1)).toBe("alpha");
|
||||
expect(await repo.getHostDisplayName(999)).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes all alert data for a user", async () => {
|
||||
let writes = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
const userChannel = await repo.createNotificationChannel({
|
||||
userId: "user-1",
|
||||
name: "Ops",
|
||||
type: "webhook",
|
||||
config: '{"url":"https://example.test"}',
|
||||
enabled: true,
|
||||
});
|
||||
const otherChannel = await repo.createNotificationChannel({
|
||||
userId: "user-2",
|
||||
name: "Other",
|
||||
type: "webhook",
|
||||
config: '{"url":"https://other.test"}',
|
||||
enabled: true,
|
||||
});
|
||||
const userRule = await repo.createAlertRule({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
name: "CPU high",
|
||||
enabled: true,
|
||||
triggerType: "cpu_threshold",
|
||||
thresholdValue: 80,
|
||||
thresholdDurationSeconds: 30,
|
||||
cooldownMinutes: 5,
|
||||
channels: [userChannel.id],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const otherRule = await repo.createAlertRule({
|
||||
userId: "user-2",
|
||||
hostId: null,
|
||||
name: "Memory high",
|
||||
enabled: true,
|
||||
triggerType: "memory_threshold",
|
||||
thresholdValue: 90,
|
||||
thresholdDurationSeconds: 60,
|
||||
cooldownMinutes: 10,
|
||||
channels: [otherChannel.id],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
await repo.createFiring({
|
||||
userId: "user-1",
|
||||
ruleId: userRule.id,
|
||||
hostId: 1,
|
||||
hostName: "alpha",
|
||||
value: 95,
|
||||
message: "high",
|
||||
severity: "warning",
|
||||
});
|
||||
await repo.createFiring({
|
||||
userId: "user-2",
|
||||
ruleId: otherRule.id,
|
||||
hostId: 1,
|
||||
hostName: "alpha",
|
||||
value: 91,
|
||||
message: "other",
|
||||
severity: "warning",
|
||||
});
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toEqual({
|
||||
firingsDeleted: 1,
|
||||
ruleLinksDeleted: 1,
|
||||
rulesDeleted: 1,
|
||||
channelsDeleted: 1,
|
||||
});
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toEqual({
|
||||
firingsDeleted: 0,
|
||||
ruleLinksDeleted: 0,
|
||||
rulesDeleted: 0,
|
||||
channelsDeleted: 0,
|
||||
});
|
||||
|
||||
expect(await repo.listNotificationChannels("user-1")).toEqual([]);
|
||||
expect(await repo.listAlertRules("user-1")).toEqual([]);
|
||||
expect(
|
||||
await repo.listAlertFirings({ userId: "user-1", limit: 10, offset: 0 }),
|
||||
).toEqual({ firings: [], total: 0 });
|
||||
expect(await repo.listNotificationChannels("user-2")).toHaveLength(1);
|
||||
expect(await repo.listAlertRules("user-2")).toHaveLength(1);
|
||||
expect(await repo.listEnabledChannelsForRule(otherRule.id)).toHaveLength(1);
|
||||
expect(writes).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -6,12 +6,12 @@ const BASE = { hostCount: 3, allowReadOnlyCommands: false };
|
||||
describe("buildSystemPrompt", () => {
|
||||
it("tells the assistant to stay inside what was asked", () => {
|
||||
// Without these the assistant answered "what is running on this server"
|
||||
// by also proposing a monitoring script and an alert rule nobody wanted.
|
||||
// by also proposing a monitoring script and an automation nobody wanted.
|
||||
const prompt = buildSystemPrompt(BASE);
|
||||
|
||||
expect(prompt).toContain("nothing beyond it");
|
||||
expect(prompt).toMatch(/Read, then report\. Do not propose anything\./);
|
||||
expect(prompt).toMatch(/no monitoring, no alert rules, no scripts/);
|
||||
expect(prompt).toMatch(/no monitoring automations, no scripts/);
|
||||
expect(prompt).toMatch(/One request means one proposal at most/);
|
||||
});
|
||||
|
||||
|
||||
@@ -13,14 +13,12 @@ const snippetRepository = {
|
||||
deleteSnippet: vi.fn(),
|
||||
};
|
||||
const fleetRepository = { create: vi.fn(), addMember: vi.fn() };
|
||||
const alertRepository = { createAlertRule: vi.fn() };
|
||||
const automationRepository = { create: vi.fn() };
|
||||
|
||||
vi.mock("../../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostRepository: () => hostRepository,
|
||||
createCurrentSnippetRepository: () => snippetRepository,
|
||||
createCurrentFleetRepository: () => fleetRepository,
|
||||
createCurrentAlertRepository: () => alertRepository,
|
||||
createCurrentAutomationRepository: () => automationRepository,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Once the alert rules have been migrated into automations, both systems hold
|
||||
* a copy of every rule. If the old engine kept evaluating, every alert would
|
||||
* be delivered twice.
|
||||
*/
|
||||
|
||||
const listEnabledRulesForHost = vi.fn();
|
||||
const listEnabledRulesForHostUser = vi.fn();
|
||||
const createFiring = vi.fn();
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentAlertRepository: () => ({
|
||||
listEnabledRulesForHost,
|
||||
listEnabledRulesForHostUser,
|
||||
createFiring,
|
||||
pruneFiringsOlderThan: vi.fn(),
|
||||
listEnabledChannelsForRule: vi.fn(async () => []),
|
||||
findRuleById: vi.fn(async () => null),
|
||||
getHostDisplayName: vi.fn(async () => "host"),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
statsLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/notification-sender.js", () => ({
|
||||
sendNotification: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/discord-sender.js", () => ({
|
||||
sendDiscord: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const alertEngineModule =
|
||||
await import("../../../../../plugins/host-metrics/backend/alert-engine.js");
|
||||
|
||||
const cpuRule = {
|
||||
id: 1,
|
||||
userId: "user-1",
|
||||
hostId: null,
|
||||
name: "CPU",
|
||||
enabled: true,
|
||||
triggerType: "cpu_threshold",
|
||||
thresholdValue: 50,
|
||||
thresholdDurationSeconds: 0,
|
||||
cooldownMinutes: 0,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listEnabledRulesForHost.mockResolvedValue([cpuRule]);
|
||||
listEnabledRulesForHostUser.mockResolvedValue([cpuRule]);
|
||||
});
|
||||
|
||||
describe("AlertEngine before migration", () => {
|
||||
it("still evaluates rules", async () => {
|
||||
expect(alertEngineModule.isAlertEngineSuperseded()).toBe(false);
|
||||
|
||||
await alertEngineModule.AlertEngine.getInstance().evaluateMetrics(7, {
|
||||
cpu: { percent: 90 },
|
||||
});
|
||||
|
||||
expect(listEnabledRulesForHost).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AlertEngine after migration", () => {
|
||||
it("stops evaluating every trigger type", async () => {
|
||||
alertEngineModule.markAlertEngineSuperseded();
|
||||
expect(alertEngineModule.isAlertEngineSuperseded()).toBe(true);
|
||||
|
||||
const engine = alertEngineModule.AlertEngine.getInstance();
|
||||
await engine.evaluateMetrics(7, { cpu: { percent: 99 } });
|
||||
await engine.evaluateStatus(7, false);
|
||||
await engine.evaluateHealthCheck(7, "user-1", "web", false);
|
||||
await engine.evaluateUserLogin(7, "user-1", "root", "10.0.0.1");
|
||||
|
||||
// Nothing is even loaded, so nothing can be delivered a second time.
|
||||
expect(listEnabledRulesForHost).not.toHaveBeenCalled();
|
||||
expect(listEnabledRulesForHostUser).not.toHaveBeenCalled();
|
||||
expect(createFiring).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,6 @@ const route = ast.statements.find(
|
||||
);
|
||||
if (!route) throw new Error("Login event route is missing");
|
||||
const notify = vi.fn();
|
||||
const legacy = vi.fn().mockResolvedValue(undefined);
|
||||
let handler: (req: unknown, res: unknown) => Promise<void>;
|
||||
runInNewContext(
|
||||
ts.transpileModule(route.getText(ast), {
|
||||
@@ -49,7 +48,6 @@ runInNewContext(
|
||||
},
|
||||
}),
|
||||
notifyAutomationInternalEvent: notify,
|
||||
AlertEngine: { getInstance: () => ({ evaluateUserLogin: legacy }) },
|
||||
},
|
||||
);
|
||||
const body = {
|
||||
@@ -61,7 +59,7 @@ const body = {
|
||||
const response = () => ({ status: vi.fn().mockReturnThis(), json: vi.fn() });
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("delivers successful SSH login details to automations while retaining the legacy hook", async () => {
|
||||
it("delivers successful SSH login details to automations", async () => {
|
||||
const res = response();
|
||||
await handler(
|
||||
{
|
||||
@@ -75,12 +73,6 @@ it("delivers successful SSH login details to automations while retaining the leg
|
||||
sshUser: "alice",
|
||||
fromIp: "192.0.2.4",
|
||||
});
|
||||
expect(legacy).toHaveBeenCalledExactlyOnceWith(
|
||||
42,
|
||||
"owner",
|
||||
"alice",
|
||||
"192.0.2.4",
|
||||
);
|
||||
expect(res.json).toHaveBeenCalledWith({ ok: true });
|
||||
});
|
||||
|
||||
@@ -101,6 +93,5 @@ it.each([
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
expect(legacy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
+9
-9
@@ -1,9 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { triggerLoginAlert } from "../../utils/alert-trigger.js";
|
||||
import { dispatchLoginEvent } from "../../utils/login-event-dispatch.js";
|
||||
import { SystemCrypto } from "../../utils/system-crypto.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
|
||||
describe("triggerLoginAlert", () => {
|
||||
describe("dispatchLoginEvent", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -19,12 +19,12 @@ describe("triggerLoginAlert", () => {
|
||||
);
|
||||
const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {});
|
||||
|
||||
await triggerLoginAlert(7, "user-1", "root", "192.0.2.1");
|
||||
await dispatchLoginEvent(7, "user-1", "root", "192.0.2.1");
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"Failed to trigger login alert",
|
||||
"Failed to dispatch login event",
|
||||
expect.objectContaining({
|
||||
operation: "login_alert_trigger_error",
|
||||
operation: "login_event_dispatch_error",
|
||||
hostId: 7,
|
||||
error:
|
||||
'Metrics service returned 401: {"error":"Missing authentication token"}',
|
||||
@@ -41,7 +41,7 @@ describe("triggerLoginAlert", () => {
|
||||
);
|
||||
const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {});
|
||||
|
||||
await triggerLoginAlert(7, "user-1", "root", "192.0.2.1");
|
||||
await dispatchLoginEvent(7, "user-1", "root", "192.0.2.1");
|
||||
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -54,7 +54,7 @@ describe("triggerLoginAlert", () => {
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response('{"ok":true}', { status: 200 }));
|
||||
|
||||
await triggerLoginAlert(42, "user-1", "root", "10.0.0.5");
|
||||
await dispatchLoginEvent(42, "user-1", "root", "10.0.0.5");
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"http://localhost:30005/internal/login-alert",
|
||||
@@ -83,11 +83,11 @@ describe("triggerLoginAlert", () => {
|
||||
const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {});
|
||||
|
||||
await expect(
|
||||
triggerLoginAlert(1, "user-1", "root", "127.0.0.1"),
|
||||
dispatchLoginEvent(1, "user-1", "root", "127.0.0.1"),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"Failed to trigger login alert",
|
||||
"Failed to dispatch login event",
|
||||
expect.objectContaining({ hostId: 1 }),
|
||||
);
|
||||
});
|
||||
@@ -1,234 +0,0 @@
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
Step,
|
||||
Trigger,
|
||||
} from "../../../types/automations.js";
|
||||
import { AUTOMATION_DEFINITION_VERSION } from "../../../types/automations.js";
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import {
|
||||
createCurrentAutomationRepository,
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { alertRuleChannels, alertRules } from "../../database/db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const MIGRATION_FLAG = "alert_rules_to_automations_v1";
|
||||
|
||||
export interface AutomationsMigrationResult {
|
||||
migrated: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries existing alert rules over into automations.
|
||||
*
|
||||
* Each rule becomes an automation whose trigger is the rule's threshold or
|
||||
* state change and whose only step notifies the channels that rule was linked
|
||||
* to, which is exactly what the alert engine did with it.
|
||||
*
|
||||
* The alert_* tables are deliberately left in place. They are the rollback
|
||||
* path, and alert_firings is user-visible history; nothing writes to them once
|
||||
* the automations engine takes over.
|
||||
*/
|
||||
export async function runAutomationsMigration(): Promise<AutomationsMigrationResult | null> {
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
|
||||
try {
|
||||
if ((await settingsRepository.get(MIGRATION_FLAG)) === "done") {
|
||||
// Already migrated on an earlier boot; the automations engine owns
|
||||
// evaluation from here, so the old one must stay quiet.
|
||||
await standDownAlertEngine();
|
||||
return null;
|
||||
}
|
||||
|
||||
const { drizzle } = createCurrentRepositoryContext();
|
||||
const rules = await drizzle.select().from(alertRules);
|
||||
|
||||
if (rules.length === 0) {
|
||||
await settingsRepository.set(MIGRATION_FLAG, "done");
|
||||
await standDownAlertEngine();
|
||||
return { migrated: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
const repository = createCurrentAutomationRepository();
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const rule of rules) {
|
||||
const trigger = triggerForRule(rule);
|
||||
if (!trigger) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const links = await drizzle
|
||||
.select({ channelId: alertRuleChannels.channelId })
|
||||
.from(alertRuleChannels)
|
||||
.where(eq(alertRuleChannels.ruleId, rule.id));
|
||||
const channelIds = links.map((link) => link.channelId);
|
||||
|
||||
const steps: Step[] = [
|
||||
{
|
||||
id: "notify",
|
||||
type: "notify",
|
||||
channelIds,
|
||||
title: `${rule.name}`,
|
||||
body: messageTemplateFor(rule.triggerType),
|
||||
severity: "warning",
|
||||
},
|
||||
];
|
||||
|
||||
const definition: AutomationDefinition = {
|
||||
version: AUTOMATION_DEFINITION_VERSION,
|
||||
trigger,
|
||||
steps,
|
||||
};
|
||||
|
||||
await repository.create({
|
||||
userId: rule.userId,
|
||||
name: rule.name,
|
||||
description: "Migrated from an alert rule",
|
||||
enabled: !!rule.enabled,
|
||||
definition: JSON.stringify(definition),
|
||||
channels: channelIds,
|
||||
});
|
||||
migrated++;
|
||||
}
|
||||
|
||||
await settingsRepository.set(MIGRATION_FLAG, "done");
|
||||
await standDownAlertEngine();
|
||||
|
||||
if (migrated > 0) {
|
||||
databaseLogger.info(`Migrated ${migrated} alert rule(s) to automations`, {
|
||||
operation: "automations_migration",
|
||||
migrated,
|
||||
skipped,
|
||||
});
|
||||
}
|
||||
|
||||
return { migrated, skipped };
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Alert rule migration failed", {
|
||||
operation: "automations_migration_error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imported lazily: this migration runs before the metrics subsystem is loaded,
|
||||
* and pulling that module in early would drag its timers in with it.
|
||||
*
|
||||
* alert-engine now lives in the host-metrics plugin rather than core, so it is
|
||||
* resolved through getBundledPluginsDir() the same way the plugin loader finds
|
||||
* any other bundled plugin file, instead of a relative import into a module
|
||||
* this file no longer sits next to.
|
||||
*/
|
||||
async function standDownAlertEngine(): Promise<void> {
|
||||
try {
|
||||
const { getBundledPluginsDir } = await import("../../plugins/paths.js");
|
||||
const path = await import("node:path");
|
||||
const { pathToFileURL } = await import("node:url");
|
||||
|
||||
const entry = path.join(
|
||||
getBundledPluginsDir(),
|
||||
"host-metrics",
|
||||
"backend",
|
||||
"alert-engine.js",
|
||||
);
|
||||
const { markAlertEngineSuperseded } = await import(
|
||||
pathToFileURL(entry).href
|
||||
);
|
||||
markAlertEngineSuperseded();
|
||||
} catch {
|
||||
// If it cannot be loaded there is nothing running to stand down.
|
||||
}
|
||||
}
|
||||
|
||||
type AlertRuleRecord = typeof alertRules.$inferSelect;
|
||||
|
||||
function triggerForRule(rule: AlertRuleRecord): Trigger | null {
|
||||
const hostSelector =
|
||||
rule.hostId === null
|
||||
? ({ kind: "all" } as const)
|
||||
: ({ kind: "host", hostId: rule.hostId } as const);
|
||||
const cooldownMinutes = rule.cooldownMinutes ?? 15;
|
||||
|
||||
switch (rule.triggerType) {
|
||||
case "cpu_threshold":
|
||||
case "memory_threshold":
|
||||
case "disk_threshold": {
|
||||
const path =
|
||||
rule.triggerType === "cpu_threshold"
|
||||
? "cpu.percent"
|
||||
: rule.triggerType === "memory_threshold"
|
||||
? "memory.percent"
|
||||
: "disk.percent";
|
||||
return {
|
||||
kind: "metric_threshold",
|
||||
hostSelector,
|
||||
metric: { path } as Extract<
|
||||
Trigger,
|
||||
{ kind: "metric_threshold" }
|
||||
>["metric"],
|
||||
// The old engine only ever compared with >=.
|
||||
operator: ">=",
|
||||
value: rule.thresholdValue ?? 0,
|
||||
forSeconds: rule.thresholdDurationSeconds ?? undefined,
|
||||
cooldownMinutes,
|
||||
};
|
||||
}
|
||||
case "host_offline":
|
||||
return {
|
||||
kind: "host_status",
|
||||
hostSelector,
|
||||
to: "offline",
|
||||
cooldownMinutes,
|
||||
};
|
||||
case "host_online":
|
||||
return {
|
||||
kind: "host_status",
|
||||
hostSelector,
|
||||
to: "online",
|
||||
cooldownMinutes,
|
||||
};
|
||||
case "health_check_failure":
|
||||
return {
|
||||
kind: "health_check",
|
||||
hostSelector,
|
||||
to: "failing",
|
||||
cooldownMinutes,
|
||||
};
|
||||
case "health_check_recovery":
|
||||
return {
|
||||
kind: "health_check",
|
||||
hostSelector,
|
||||
to: "recovered",
|
||||
cooldownMinutes,
|
||||
};
|
||||
case "user_login":
|
||||
return {
|
||||
kind: "internal_event",
|
||||
event: "user_login",
|
||||
hostSelector,
|
||||
cooldownMinutes,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function messageTemplateFor(triggerType: string): string {
|
||||
if (triggerType.endsWith("_threshold")) {
|
||||
return "{{trigger.metric}} on host {{trigger.hostId}} is at {{trigger.value}} (threshold {{trigger.threshold}})";
|
||||
}
|
||||
if (triggerType.startsWith("host_")) {
|
||||
return "Host {{trigger.hostId}} is {{trigger.status}}";
|
||||
}
|
||||
if (triggerType.startsWith("health_check")) {
|
||||
return "Health check {{trigger.checkId}} on host {{trigger.hostId}} is {{trigger.state}}";
|
||||
}
|
||||
return "Triggered on host {{trigger.hostId}}";
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { sshLogger } from "./logger.js";
|
||||
|
||||
const METRICS_SERVICE_URL = "http://localhost:30005";
|
||||
|
||||
export async function triggerLoginAlert(
|
||||
export async function dispatchLoginEvent(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
sshUser: string,
|
||||
@@ -29,8 +29,8 @@ export async function triggerLoginAlert(
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to trigger login alert", {
|
||||
operation: "login_alert_trigger_error",
|
||||
sshLogger.warn("Failed to dispatch login event", {
|
||||
operation: "login_event_dispatch_error",
|
||||
hostId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
Reference in new issue
Block a user