9a54978eef
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.
221 lines
7.9 KiB
JavaScript
221 lines
7.9 KiB
JavaScript
/**
|
||
* @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;
|