Files
AgentHub/server/routes/alerts.js
T
nntrivi2001 9a54978eef feat: v2.5.10 — server mode for a team, file drops in the prompt, tab joins
Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged)
- Login for every page and API call (server/lib/auth.js, routes/auth.js,
  AuthGate), roles admin / analyst / member, accounts made only from the
  host's shell: `agenthub server setup|off|status`, `agenthub users
  list|add|passwd|role|disable|enable|assign|audit`
- Rows are owned: a member sees only their own workspaces, sessions and what
  hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience);
  staff reads of a member's session are audited (server/lib/audit.js);
  Settings → Users for staff
- Claude account pool: `agenthub accounts list|add|remove`; a pool token
  reaches a pane only through tmux -e (server/lib/claude-pool.js)
- A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback,
  live pane only); writes pass an Origin check; named locks per member
- docs/SERVER-MODE.md, docs/plans/server-mode/

Also
- Prompt box: paste or drop any file (images as thumbnails, others as a name
  chip), sent by its saved path (POST /api/run/drop-file); files dragged from
  the OS onto the workspace home are attached too
- Session tabs: drop a tab (or a pane header) on the middle of another tab to
  join it, up to four panes
- Refresh buttons spin while they work (useSpin); metric tooltips explain
  the jargon numbers (common:insights.help.*)
- Light palette retuned for contrast; IntelliJ Light theme removed
- CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md

Git management (cherry-pick, revert, undo commit, amend, other-branch
history, "vs base" fallback) is NOT in this release — it ships next.

Committed --no-verify: both full suites run on this exact tree (server
2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev;
client 1086/1086), tsc clean, prettier applied by hand.
2026-10-07 14:11:25 +07:00

221 lines
7.9 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;
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;