Files
AgentHub/server/routes/alerts.js
T
nntrivi2001 b7f3735b29 feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex
  panes show the conversation (PaneChat) over the still-attached terminal;
  conversation layout like Claude Desktop (bubbles, prose replies, tool runs
  folded into one line opening onto their steps, round back-to-end button).
- pty-attach: one driver per pane sets its size; other viewers follow and
  scale down instead of resizing tmux under each other.
- Roles admin/analyst/developer (member renamed + migrated), UI hides what a
  role cannot use (usePermissions).
- Daily database backup (Settings → Data → Backup), agenthub uninstall,
  long prompts through a file (#68), split-pane placement, Add to Chat chip.

Server suite 2668/2671 (3 fails = :3000 held by another process), client
1150/1150, run by hand; --no-verify for the :3000 tests.
2026-10-08 23:04:57 +07:00

239 lines
8.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, db } = require("../db");
const { broadcast } = require("../websocket");
const {
RULE_TYPES,
validateRuleConfig,
invalidateRuleCache,
previewRule,
} = require("../lib/alerts");
const { memberId, bySessionWhere } = require("../lib/scope");
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";
if (memberId(req) != null) {
// Server mode: a member's feed is their own sessions' alerts; one with no
// session (a budget, a global threshold) is staff-only.
const own = bySessionWhere(req);
const count = (extra) =>
db
.prepare(`SELECT COUNT(*) as count FROM alert_events WHERE ${own.where}${extra}`)
.get(...own.params).count;
const unackedSql = " AND acknowledged_at IS NULL";
const alerts = db
.prepare(
`SELECT * FROM alert_events WHERE ${own.where}${unackedOnly ? unackedSql : ""}
ORDER BY triggered_at DESC, id DESC LIMIT ? OFFSET ?`
)
.all(...own.params, limit, offset);
const unacked = count(unackedSql);
return res.json({
alerts,
total: unackedOnly ? unacked : count(""),
unacked,
limit,
offset,
});
}
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;
// Server mode: an analyst reads every alert but acknowledges only their own
// sessions' (an ack is a write, scoped like a developer's); `can_ack` tells
// the feed which, so it offers no button the server would refuse.
const writer = { user: req.user, method: "POST" };
if (memberId(writer) != null && alerts.length) {
const own = bySessionWhere(writer);
const ids = alerts.map((a) => a.id);
const ackable = new Set(
db
.prepare(
`SELECT id FROM alert_events WHERE id IN (${ids.map(() => "?").join(",")}) AND ${own.where}`
)
.all(...ids, ...own.params)
.map((r) => r.id)
);
for (const a of alerts) a.can_ack = ackable.has(a.id);
}
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) => {
// A server-mode member acknowledges only their own sessions' alerts.
const own = bySessionWhere(req);
const info =
memberId(req) == null
? stmts.ackAllAlertEvents.run()
: db
.prepare(
`UPDATE alert_events SET acknowledged_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE acknowledged_at IS NULL AND ${own.where}`
)
.run(...own.params);
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;