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.
152 lines
5.3 KiB
JavaScript
152 lines
5.3 KiB
JavaScript
/**
|
|
* @file Express router for event endpoints. Supports listing events with
|
|
* multi-dimensional filtering (event type, tool name, agent, session, text
|
|
* search, date range) plus pagination. Also exposes a `/facets` endpoint that
|
|
* returns the distinct event_type and tool_name values currently in the DB,
|
|
* so the UI can populate filter dropdowns without hardcoding them.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { Router } = require("express");
|
|
const { db } = require("../db");
|
|
const { parseSources, sessionIdInSourcesClause } = require("../lib/source-filter");
|
|
const { memberId, bySessionWhere } = require("../lib/scope");
|
|
|
|
const router = Router();
|
|
|
|
const MAX_LIMIT = 500;
|
|
const DEFAULT_LIMIT = 50;
|
|
|
|
function parseCsv(value) {
|
|
if (value == null) return null;
|
|
const raw = Array.isArray(value) ? value.join(",") : String(value);
|
|
const parts = raw
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
return parts.length > 0 ? parts : null;
|
|
}
|
|
|
|
function parseDate(value) {
|
|
if (typeof value !== "string" || value.trim() === "") return null;
|
|
const d = new Date(value);
|
|
if (Number.isNaN(d.getTime())) return null;
|
|
return d.toISOString();
|
|
}
|
|
|
|
function clampInt(raw, fallback, min, max) {
|
|
const n = parseInt(raw, 10);
|
|
if (Number.isNaN(n)) return fallback;
|
|
return Math.min(Math.max(n, min), max);
|
|
}
|
|
|
|
// Builds the WHERE clause + param array for a given filter set. Used by both
|
|
// the list and count queries so they stay in sync.
|
|
function buildWhere(filters) {
|
|
const clauses = [];
|
|
const params = [];
|
|
|
|
const inClause = (field, values) => {
|
|
clauses.push(`${field} IN (${values.map(() => "?").join(",")})`);
|
|
params.push(...values);
|
|
};
|
|
|
|
if (filters.event_type) inClause("event_type", filters.event_type);
|
|
if (filters.tool_name) inClause("tool_name", filters.tool_name);
|
|
if (filters.agent_id) inClause("agent_id", filters.agent_id);
|
|
if (filters.session_id) inClause("session_id", filters.session_id);
|
|
|
|
if (filters.q) {
|
|
clauses.push("(summary LIKE ? OR tool_name LIKE ? OR data LIKE ?)");
|
|
const pattern = `%${filters.q}%`;
|
|
params.push(pattern, pattern, pattern);
|
|
}
|
|
|
|
if (filters.from) {
|
|
clauses.push("created_at >= ?");
|
|
params.push(filters.from);
|
|
}
|
|
if (filters.to) {
|
|
clauses.push("created_at <= ?");
|
|
params.push(filters.to);
|
|
}
|
|
|
|
// Data-scope: restrict to events whose session was collected from a chosen
|
|
// set of machines. `events` carries only session_id, so filter via subquery.
|
|
const scope = sessionIdInSourcesClause(filters.sources, "session_id");
|
|
if (scope.clause) {
|
|
clauses.push(scope.clause);
|
|
params.push(...scope.params);
|
|
}
|
|
|
|
// Server mode: a member's events are only those of their own sessions.
|
|
if (filters.own) {
|
|
clauses.push(filters.own.where);
|
|
params.push(...filters.own.params);
|
|
}
|
|
|
|
return {
|
|
sql: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "",
|
|
params,
|
|
};
|
|
}
|
|
|
|
// GET /api/events?event_type=a,b&tool_name=Bash&q=curl&from=...&to=...&limit=50&offset=0
|
|
router.get("/", (req, res) => {
|
|
const limit = clampInt(req.query.limit, DEFAULT_LIMIT, 1, MAX_LIMIT);
|
|
const offset = clampInt(req.query.offset, 0, 0, Number.MAX_SAFE_INTEGER);
|
|
|
|
const filters = {
|
|
event_type: parseCsv(req.query.event_type),
|
|
tool_name: parseCsv(req.query.tool_name),
|
|
agent_id: parseCsv(req.query.agent_id),
|
|
session_id: parseCsv(req.query.session_id),
|
|
q: typeof req.query.q === "string" && req.query.q.trim() !== "" ? req.query.q.trim() : null,
|
|
from: parseDate(req.query.from),
|
|
to: parseDate(req.query.to),
|
|
sources: parseSources(req),
|
|
own: memberId(req) != null ? bySessionWhere(req) : null,
|
|
};
|
|
|
|
const { sql: whereSql, params: whereParams } = buildWhere(filters);
|
|
|
|
// Labels for the row (session name/cwd, agent name/type) come with it, so a
|
|
// list page never downloads every session and agent just to name them.
|
|
const listSql = `SELECT events.*,
|
|
(SELECT name FROM sessions WHERE id = events.session_id) as session_name,
|
|
(SELECT cwd FROM sessions WHERE id = events.session_id) as session_cwd,
|
|
(SELECT lane_id FROM sessions WHERE id = events.session_id) as session_lane_id,
|
|
(SELECT name FROM agents WHERE id = events.agent_id) as agent_name,
|
|
(SELECT subagent_type FROM agents WHERE id = events.agent_id) as agent_subagent_type
|
|
FROM events ${whereSql} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`;
|
|
const countSql = `SELECT COUNT(*) as count FROM events ${whereSql}`;
|
|
|
|
const events = db.prepare(listSql).all(...whereParams, limit, offset);
|
|
const { count: total } = db.prepare(countSql).get(...whereParams);
|
|
|
|
res.json({ events, limit, offset, total });
|
|
});
|
|
|
|
// GET /api/events/facets — distinct event_type / tool_name values in the DB.
|
|
router.get("/facets", (req, res) => {
|
|
// A server-mode member sees only the values in their own sessions' events.
|
|
const own = bySessionWhere(req);
|
|
const eventTypes = db
|
|
.prepare(
|
|
`SELECT DISTINCT event_type FROM events WHERE event_type IS NOT NULL AND ${own.where} ORDER BY event_type`
|
|
)
|
|
.all(...own.params)
|
|
.map((r) => r.event_type);
|
|
|
|
const toolNames = db
|
|
.prepare(
|
|
`SELECT DISTINCT tool_name FROM events WHERE tool_name IS NOT NULL AND ${own.where} ORDER BY tool_name`
|
|
)
|
|
.all(...own.params)
|
|
.map((r) => r.tool_name);
|
|
|
|
res.json({ event_types: eventTypes, tool_names: toolNames });
|
|
});
|
|
|
|
module.exports = router;
|