Files
AgentHub/server/routes/alerts.js
T
nntrivi2001 4aae61297f feat: analytics parity — tool cost, date ranges, budgets, query explorer, replay, snapshots (v2.4.0)
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
  Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
  with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
  Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
  its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
  status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
  reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
  ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files

Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
2026-10-06 17:12:28 +07:00

185 lines
6.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file Express router for the rules-based alerting engine: CRUD for alert
* rules, the fired-alert feed with pagination and unacked filtering, and
* acknowledge endpoints. Rule evaluation itself lives in server/lib/alerts.js.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const { v4: uuidv4 } = require("uuid");
const { stmts } = require("../db");
const { broadcast } = require("../websocket");
const {
RULE_TYPES,
validateRuleConfig,
invalidateRuleCache,
previewRule,
} = require("../lib/alerts");
const router = Router();
function serializeRule(row) {
let config = {};
try {
config = JSON.parse(row.config || "{}");
} catch {
/* surface as empty config rather than failing the request */
}
return { ...row, config, enabled: row.enabled === 1 };
}
// GET /api/alerts/rules - List all alert rules
router.get("/rules", (_req, res) => {
res.json({ rules: stmts.listAlertRules.all().map(serializeRule) });
});
// POST /api/alerts/rules - Create an alert rule
router.post("/rules", (req, res) => {
const { name, rule_type, config, enabled, cooldown_seconds } = req.body || {};
if (!name || typeof name !== "string" || !name.trim()) {
return res.status(400).json({
error: { code: "INVALID_INPUT", message: "name is required" },
});
}
const validated = validateRuleConfig(rule_type, config);
if (!validated.ok) {
return res.status(400).json({ error: { code: "INVALID_INPUT", message: validated.error } });
}
const cooldown =
cooldown_seconds == null ? 300 : Number.isInteger(cooldown_seconds) ? cooldown_seconds : -1;
if (cooldown < 0) {
return res.status(400).json({
error: { code: "INVALID_INPUT", message: "cooldown_seconds must be a non-negative integer" },
});
}
const id = uuidv4();
stmts.insertAlertRule.run(
id,
name.trim(),
rule_type,
JSON.stringify(validated.config),
enabled === false ? 0 : 1,
cooldown
);
invalidateRuleCache();
res.status(201).json({ rule: serializeRule(stmts.getAlertRule.get(id)) });
});
// POST /api/alerts/rules/preview - What a rule WOULD have fired over the last
// `days` (1–90, default 7) of recorded history. Read-only: nothing is saved,
// fired or broadcast.
router.post("/rules/preview", (req, res) => {
const { rule_type, config, days, cooldown_seconds } = req.body || {};
const validated = validateRuleConfig(rule_type, config);
if (!validated.ok) {
return res.status(400).json({ error: { code: "INVALID_INPUT", message: validated.error } });
}
const window = days == null ? 7 : Number(days);
if (!Number.isInteger(window) || window < 1 || window > 90) {
return res.status(400).json({
error: { code: "INVALID_INPUT", message: "days must be an integer in 1..90" },
});
}
const cooldown = cooldown_seconds == null ? 300 : cooldown_seconds;
if (!Number.isInteger(cooldown) || cooldown < 0) {
return res.status(400).json({
error: { code: "INVALID_INPUT", message: "cooldown_seconds must be a non-negative integer" },
});
}
res.json(previewRule(rule_type, validated.config, { days: window, cooldownSeconds: cooldown }));
});
// PATCH /api/alerts/rules/:id - Update an alert rule (partial)
router.patch("/rules/:id", (req, res) => {
const existing = stmts.getAlertRule.get(req.params.id);
if (!existing) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Alert rule not found" } });
}
const { name, config, enabled, cooldown_seconds } = req.body || {};
if (name != null && (typeof name !== "string" || !name.trim())) {
return res.status(400).json({
error: { code: "INVALID_INPUT", message: "name must be a non-empty string" },
});
}
let configJson = null;
if (config != null) {
// rule_type is immutable — validate the new config against the stored type
const validated = validateRuleConfig(existing.rule_type, config);
if (!validated.ok) {
return res.status(400).json({ error: { code: "INVALID_INPUT", message: validated.error } });
}
configJson = JSON.stringify(validated.config);
}
if (cooldown_seconds != null && (!Number.isInteger(cooldown_seconds) || cooldown_seconds < 0)) {
return res.status(400).json({
error: { code: "INVALID_INPUT", message: "cooldown_seconds must be a non-negative integer" },
});
}
stmts.updateAlertRule.run(
name != null ? name.trim() : null,
configJson,
enabled == null ? null : enabled ? 1 : 0,
cooldown_seconds ?? null,
req.params.id
);
invalidateRuleCache();
res.json({ rule: serializeRule(stmts.getAlertRule.get(req.params.id)) });
});
// DELETE /api/alerts/rules/:id - Delete an alert rule (its alert history
// cascades away with it — the FK is ON DELETE CASCADE)
router.delete("/rules/:id", (req, res) => {
const existing = stmts.getAlertRule.get(req.params.id);
if (!existing) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Alert rule not found" } });
}
stmts.deleteAlertRule.run(req.params.id);
invalidateRuleCache();
res.json({ ok: true });
});
// GET /api/alerts - Fired-alert feed, newest first. ?unacked=true filters to
// unacknowledged alerts; limit/offset paginate.
router.get("/", (req, res) => {
// Clamp to sane bounds — negative values would make SQLite's LIMIT/OFFSET
// misbehave (a negative LIMIT means "no limit").
const limit = Math.max(1, Math.min(parseInt(req.query.limit, 10) || 50, 200));
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
const unackedOnly = req.query.unacked === "true";
const alerts = unackedOnly
? stmts.listUnackedAlertEvents.all(limit, offset)
: stmts.listAlertEvents.all(limit, offset);
const total = unackedOnly
? stmts.countUnackedAlertEvents.get().count
: stmts.countAlertEvents.get().count;
const unacked = stmts.countUnackedAlertEvents.get().count;
res.json({ alerts, total, unacked, limit, offset });
});
// POST /api/alerts/:id/ack - Acknowledge one alert
router.post("/:id(\\d+)/ack", (req, res) => {
const alert = stmts.getAlertEvent.get(req.params.id);
if (!alert) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Alert not found" } });
}
stmts.ackAlertEvent.run(req.params.id);
const updated = stmts.getAlertEvent.get(req.params.id);
broadcast("alert_updated", updated);
res.json({ alert: updated });
});
// POST /api/alerts/ack-all - Acknowledge every unacked alert
router.post("/ack-all", (_req, res) => {
const info = stmts.ackAllAlertEvents.run();
if (info.changes > 0) broadcast("alert_updated", { acked_all: true });
res.json({ ok: true, acknowledged: info.changes });
});
module.exports = router;
module.exports.RULE_TYPES = RULE_TYPES;