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:
LukeGus committed 2026-09-22 10:45:12 -05:00
1 parent e0f14f2ecf
commit e372b0eda7
101 files changed
+29118 -5539

No files matched your search

@@ -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;