Files
AgentHub/server/routes/events.js
T
nntrivi2001 03f1673fce feat(dashboard): lazy routes, Needs-you strip, URL state, exact numbers
- Workspace, SettingsHub, SessionDetail and the Workflow sub-tab are lazy
  (main bundle 2,166 kB -> ~920 kB), wrapped in RouteErrorBoundary: a stale
  chunk after a self-update reloads once (vite:preloadError too), any other
  error shows a Reload panel instead of a blank page.
- Live refresh: lib/throttle.ts + hooks/useLiveRefresh.ts replace trailing
  debounces that froze under a steady event stream; refresh skips hidden
  tabs and refetches after a WebSocket reconnect. Monitor loads only while
  shown, with one batched GET /api/agents?session_ids= instead of N+1; a
  failing endpoint is an inline error, not a blank Dashboard.
- NeedsYouStrip on Monitor and above Analytics. Tabs, filters, page, sort
  and search live in the URL (Dashboard, Sessions, Activity, SessionDetail).
- Sessions: tool / workspace / date-range filters, CSV/JSON export
  (formula-neutralised), focusable row links, Open in workspace (the dead
  /run links are gone), stale responses dropped, error + Retry.
- Fixes: "NaNs" durations, 30/7-day charts skipping idle days, weekday
  labels west of UTC (now averages), cache hit rate denominator, billable
  tokens vs cache reads, exact tool-call counts, "To" date = end of day,
  invalid ?from= no longer throws, recent list no longer shrinks to 15,
  stable message keys, transcript switch races.
- Light-theme-unreadable bg-black code panels and dark-only DAG fills use
  tokens; status configs and chart colours use theme tokens; Health tab
  and other literals translated.
- Server (additive): GET /api/agents?session_ids=, GET /api/sessions
  awaiting=1&from=&to=, stats tool_call_count/distinct_tool_count, event
  rows carry session/agent labels.
2026-10-05 13:22:04 +07:00

140 lines
4.8 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 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);
}
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),
};
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) => {
const eventTypes = db
.prepare(
"SELECT DISTINCT event_type FROM events WHERE event_type IS NOT NULL ORDER BY event_type"
)
.all()
.map((r) => r.event_type);
const toolNames = db
.prepare("SELECT DISTINCT tool_name FROM events WHERE tool_name IS NOT NULL ORDER BY tool_name")
.all()
.map((r) => r.tool_name);
res.json({ event_types: eventTypes, tool_names: toolNames });
});
module.exports = router;