199 lines
7.6 KiB
TypeScript
199 lines
7.6 KiB
TypeScript
import { Router } from 'express';
|
|
import { query } from '../config/database';
|
|
import { requireAuth, requireWrite } from '../middleware/auth';
|
|
import { alertService } from '../services/AlertService';
|
|
|
|
const router = Router();
|
|
|
|
// All alert routes require authentication
|
|
router.use(requireAuth);
|
|
|
|
// ── Default rules seed ─────────────────────────────────────────────────────
|
|
|
|
const DEFAULT_RULES = [
|
|
{ event_type: 'device_offline', enabled: true, threshold: null, cooldown_min: 5 },
|
|
{ event_type: 'device_online', enabled: true, threshold: null, cooldown_min: 5 },
|
|
{ event_type: 'log_error', enabled: false, threshold: null, cooldown_min: 15 },
|
|
{ event_type: 'log_warning', enabled: false, threshold: null, cooldown_min: 30 },
|
|
{ event_type: 'high_cpu', enabled: false, threshold: 90, cooldown_min: 30 },
|
|
{ event_type: 'high_memory', enabled: false, threshold: 90, cooldown_min: 30 },
|
|
{ event_type: 'cert_expiry', enabled: false, threshold: 14, cooldown_min: 1440 },
|
|
{ event_type: 'device_discovered', enabled: false, threshold: null, cooldown_min: 60 },
|
|
{ event_type: 'firmware_update_available', enabled: true, threshold: null, cooldown_min: 1440 },
|
|
];
|
|
|
|
async function ensureDefaultRules(): Promise<void> {
|
|
for (const rule of DEFAULT_RULES) {
|
|
await query(
|
|
`INSERT INTO alert_rules (event_type, enabled, threshold, cooldown_min)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (event_type) DO NOTHING`,
|
|
[rule.event_type, rule.enabled, rule.threshold, rule.cooldown_min]
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Alert Rules ────────────────────────────────────────────────────────────
|
|
|
|
/** GET /api/alerts/rules — list all rules (seeds defaults on first call) */
|
|
router.get('/rules', async (_req, res) => {
|
|
await ensureDefaultRules();
|
|
const rules = await query(
|
|
`SELECT event_type, enabled, threshold, cooldown_min, updated_at
|
|
FROM alert_rules ORDER BY event_type`
|
|
);
|
|
res.json(rules);
|
|
});
|
|
|
|
/** PUT /api/alerts/rules/:type — update a single rule */
|
|
router.put('/rules/:type', requireWrite, async (req, res) => {
|
|
const { type } = req.params;
|
|
const { enabled, threshold, cooldown_min } = req.body;
|
|
|
|
const row = await alertService.upsertRule(type, {
|
|
enabled: enabled !== undefined ? Boolean(enabled) : undefined,
|
|
threshold: threshold !== undefined ? (Number(threshold) || null) : undefined,
|
|
cooldown_min: cooldown_min !== undefined ? Number(cooldown_min) : undefined,
|
|
});
|
|
res.json(row);
|
|
});
|
|
|
|
// ── Alert Channels ─────────────────────────────────────────────────────────
|
|
|
|
/** GET /api/alerts/channels */
|
|
router.get('/channels', async (_req, res) => {
|
|
const channels = await query(
|
|
`SELECT id, name, type, enabled, config, created_at, updated_at
|
|
FROM alert_channels ORDER BY id`
|
|
);
|
|
// Mask sensitive fields before returning
|
|
const masked = channels.map((ch: Record<string, unknown>) => ({
|
|
...ch,
|
|
config: maskConfig(ch.type as string, ch.config as Record<string, unknown>),
|
|
}));
|
|
res.json(masked);
|
|
});
|
|
|
|
/** POST /api/alerts/channels */
|
|
router.post('/channels', requireWrite, async (req, res) => {
|
|
const { name, type, enabled = true, config = {} } = req.body;
|
|
if (!name || !type) {
|
|
return res.status(400).json({ error: 'name and type are required' });
|
|
}
|
|
const validTypes = ['email', 'slack', 'discord', 'telegram'];
|
|
if (!validTypes.includes(type)) {
|
|
return res.status(400).json({ error: `type must be one of: ${validTypes.join(', ')}` });
|
|
}
|
|
|
|
const rows = await query(
|
|
`INSERT INTO alert_channels (name, type, enabled, config)
|
|
VALUES ($1, $2, $3, $4) RETURNING *`,
|
|
[name, type, Boolean(enabled), JSON.stringify(config)]
|
|
);
|
|
const ch = rows[0] as Record<string, unknown>;
|
|
res.status(201).json({
|
|
...ch,
|
|
config: maskConfig(type, config),
|
|
});
|
|
});
|
|
|
|
/** PUT /api/alerts/channels/:id */
|
|
router.put('/channels/:id', requireWrite, async (req, res) => {
|
|
const id = parseInt(req.params.id, 10);
|
|
const { name, enabled, config } = req.body;
|
|
|
|
// Fetch existing so we can merge config (preserve masked/unchanged fields)
|
|
const existing = await query<{ config: Record<string, unknown>; type: string }>(
|
|
`SELECT config, type FROM alert_channels WHERE id = $1`,
|
|
[id]
|
|
);
|
|
if (!existing[0]) return res.status(404).json({ error: 'Channel not found' });
|
|
|
|
const existingConfig = existing[0].config as Record<string, unknown>;
|
|
const type = existing[0].type;
|
|
|
|
// Merge new config over existing, preserving existing passwords when placeholders sent
|
|
const mergedConfig = mergeConfig(type, existingConfig, config ?? {});
|
|
|
|
const rows = await query(
|
|
`UPDATE alert_channels
|
|
SET name = COALESCE($2, name),
|
|
enabled = COALESCE($3, enabled),
|
|
config = $4,
|
|
updated_at = NOW()
|
|
WHERE id = $1 RETURNING *`,
|
|
[id, name ?? null, enabled !== undefined ? Boolean(enabled) : null, JSON.stringify(mergedConfig)]
|
|
);
|
|
if (!rows[0]) return res.status(404).json({ error: 'Channel not found' });
|
|
|
|
const ch = rows[0] as Record<string, unknown>;
|
|
res.json({ ...ch, config: maskConfig(type, mergedConfig) });
|
|
});
|
|
|
|
/** DELETE /api/alerts/channels/:id */
|
|
router.delete('/channels/:id', requireWrite, async (req, res) => {
|
|
const id = parseInt(req.params.id, 10);
|
|
await query(`DELETE FROM alert_channels WHERE id = $1`, [id]);
|
|
res.status(204).end();
|
|
});
|
|
|
|
/** POST /api/alerts/channels/:id/test */
|
|
router.post('/channels/:id/test', requireWrite, async (req, res) => {
|
|
const id = parseInt(req.params.id, 10);
|
|
try {
|
|
await alertService.testChannel(id);
|
|
res.json({ message: 'Test alert sent successfully' });
|
|
} catch (err) {
|
|
res.status(400).json({ error: (err as Error).message });
|
|
}
|
|
});
|
|
|
|
// ── Alert History ──────────────────────────────────────────────────────────
|
|
|
|
/** GET /api/alerts/history?limit=50 */
|
|
router.get('/history', async (req, res) => {
|
|
const limit = Math.min(parseInt(String(req.query.limit ?? 50), 10), 200);
|
|
const rows = await query(
|
|
`SELECT h.id, h.event_type, h.device_id, h.device_name, h.message,
|
|
h.channels_notified, h.sent_at
|
|
FROM alert_history h
|
|
ORDER BY h.sent_at DESC LIMIT $1`,
|
|
[limit]
|
|
);
|
|
res.json(rows);
|
|
});
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
const SENSITIVE_KEYS: Record<string, string[]> = {
|
|
email: ['smtp_pass'],
|
|
slack: [],
|
|
discord: [],
|
|
telegram: ['bot_token'],
|
|
};
|
|
|
|
function maskConfig(type: string, config: Record<string, unknown>): Record<string, unknown> {
|
|
const masked = { ...config };
|
|
for (const key of SENSITIVE_KEYS[type] ?? []) {
|
|
if (masked[key]) masked[key] = '••••••••';
|
|
}
|
|
return masked;
|
|
}
|
|
|
|
/** When updating, if a sensitive field comes back as the mask placeholder, keep the original. */
|
|
function mergeConfig(
|
|
type: string,
|
|
existing: Record<string, unknown>,
|
|
incoming: Record<string, unknown>
|
|
): Record<string, unknown> {
|
|
const merged = { ...existing, ...incoming };
|
|
for (const key of SENSITIVE_KEYS[type] ?? []) {
|
|
if (incoming[key] === '••••••••') {
|
|
merged[key] = existing[key]; // restore original
|
|
}
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
export default router;
|