diff --git a/client/src/App.tsx b/client/src/App.tsx index ffb481e9..353c331c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -12,6 +12,7 @@ import { KanbanBoard } from "./pages/KanbanBoard"; import { Sessions } from "./pages/Sessions"; import { SessionDetail } from "./pages/SessionDetail"; import { ActivityFeed } from "./pages/ActivityFeed"; +import { QueryExplorer } from "./pages/QueryExplorer"; import { Analytics } from "./pages/Analytics"; import { Workflows } from "./pages/Workflows"; import { Settings } from "./pages/Settings"; @@ -40,6 +41,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index b3330340..5abbffd9 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -13,6 +13,7 @@ import { Columns3, FolderOpen, Activity, + Search, BarChart3, Workflow, Boxes, @@ -47,6 +48,7 @@ const NAV_KEYS = [ { to: "/kanban", icon: Columns3, key: "nav:agentBoard" }, { to: "/sessions", icon: FolderOpen, key: "nav:sessions" }, { to: "/activity", icon: Activity, key: "nav:activityFeed" }, + { to: "/query", icon: Search, key: "nav:queryExplorer" }, { to: "/analytics", icon: BarChart3, key: "nav:analytics" }, { to: "/workflows", icon: Workflow, key: "nav:workflows" }, { to: "/cc-config", icon: Boxes, key: "nav:ccConfig" }, diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts index 3129e0d7..0018a5be 100644 --- a/client/src/i18n/index.ts +++ b/client/src/i18n/index.ts @@ -50,6 +50,9 @@ import run_vi from "./locales/vi/run.json"; import alerts_en from "./locales/en/alerts.json"; import alerts_zh from "./locales/zh/alerts.json"; import alerts_vi from "./locales/vi/alerts.json"; +import query_en from "./locales/en/query.json"; +import query_zh from "./locales/zh/query.json"; +import query_vi from "./locales/vi/query.json"; i18n .use(LanguageDetector) @@ -71,6 +74,7 @@ i18n ccConfig: ccConfig_en, run: run_en, alerts: alerts_en, + query: query_en, }, zh: { common: common_zh, @@ -87,6 +91,7 @@ i18n ccConfig: ccConfig_zh, run: run_zh, alerts: alerts_zh, + query: query_zh, }, vi: { common: common_vi, @@ -103,6 +108,7 @@ i18n ccConfig: ccConfig_vi, run: run_vi, alerts: alerts_vi, + query: query_vi, }, }, supportedLngs: ["en", "zh", "vi"], @@ -123,6 +129,7 @@ i18n "ccConfig", "run", "alerts", + "query", ], defaultNS: "common", interpolation: { escapeValue: false }, diff --git a/client/src/i18n/locales/en/nav.json b/client/src/i18n/locales/en/nav.json index 8f89b09c..f2ed9ecb 100644 --- a/client/src/i18n/locales/en/nav.json +++ b/client/src/i18n/locales/en/nav.json @@ -5,6 +5,7 @@ "agentBoard": "Kanban Board", "sessions": "Sessions", "activityFeed": "Activity Feed", + "queryExplorer": "Query Explorer", "analytics": "Analytics", "workflows": "Workflows", "alerts": "Alerts", diff --git a/client/src/i18n/locales/en/query.json b/client/src/i18n/locales/en/query.json new file mode 100644 index 00000000..619f314a --- /dev/null +++ b/client/src/i18n/locales/en/query.json @@ -0,0 +1,61 @@ +{ + "title": "Query Explorer", + "subtitle": "Build ad-hoc queries against your dashboard data and export the results.", + "entity": "Entity", + "match": "Match", + "matchAnd": "AND", + "matchOr": "OR", + "limit": "Limit", + "sort": "Sort", + "sortNone": "No sort", + "asc": "Ascending", + "desc": "Descending", + "filters": "Filters", + "addFilter": "Add filter", + "removeFilter": "Remove filter", + "noFilters": "No filters — the query returns all rows up to the limit.", + "valuePlaceholder": "Value", + "valuePlaceholderIn": "Comma-separated values", + "noValueNeeded": "No value needed", + "run": "Run query", + "running": "Running...", + "save": "Save", + "confirmSave": "Save", + "cancel": "Cancel", + "saveNamePlaceholder": "Query name", + "exportCsv": "Export CSV", + "exportJson": "Export JSON", + "savedQueries": "Saved queries", + "noSaved": "No saved queries yet.", + "loadQuery": "Load query", + "deleteQuery": "Delete query", + "readyTitle": "Build and run a query", + "readyDesc": "Pick an entity, add filters, and hit Run query to see results here.", + "noResults": "No matching rows", + "noResultsDesc": "No rows matched the current filters. Try loosening them or switching the match mode.", + "rowsTotal": "Showing {{shown}} of {{count}} rows", + "tookMs": "{{ms}} ms", + "truncated": "Results were truncated to the limit of {{limit}} rows. Increase the limit or add filters to narrow the result set.", + "errorSchema": "Failed to load the query schema.", + "errorRun": "Failed to run the query.", + "errorSaved": "Failed to load saved queries.", + "errorSave": "Failed to save the query.", + "errorDelete": "Failed to delete the query.", + "entities": { + "events": "Events", + "agents": "Agents", + "sessions": "Sessions" + }, + "ops": { + "eq": "equals", + "ne": "not equal", + "gt": "greater than", + "gte": "greater or equal", + "lt": "less than", + "lte": "less or equal", + "like": "contains", + "in": "in list", + "is_null": "is empty", + "is_not_null": "is not empty" + } +} diff --git a/client/src/i18n/locales/vi/nav.json b/client/src/i18n/locales/vi/nav.json index 49fe0a1e..aee112fe 100644 --- a/client/src/i18n/locales/vi/nav.json +++ b/client/src/i18n/locales/vi/nav.json @@ -5,6 +5,7 @@ "agentBoard": "Bảng Kanban", "sessions": "Phiên", "activityFeed": "Luồng hoạt động", + "queryExplorer": "Trình khám phá truy vấn", "analytics": "Phân tích", "workflows": "Quy trình", "alerts": "Cảnh báo", diff --git a/client/src/i18n/locales/vi/query.json b/client/src/i18n/locales/vi/query.json new file mode 100644 index 00000000..ca7d819e --- /dev/null +++ b/client/src/i18n/locales/vi/query.json @@ -0,0 +1,61 @@ +{ + "title": "Trình khám phá truy vấn", + "subtitle": "Xây dựng truy vấn tùy biến trên dữ liệu bảng điều khiển và xuất kết quả.", + "entity": "Thực thể", + "match": "Khớp", + "matchAnd": "VÀ", + "matchOr": "HOẶC", + "limit": "Giới hạn", + "sort": "Sắp xếp", + "sortNone": "Không sắp xếp", + "asc": "Tăng dần", + "desc": "Giảm dần", + "filters": "Bộ lọc", + "addFilter": "Thêm bộ lọc", + "removeFilter": "Xóa bộ lọc", + "noFilters": "Chưa có bộ lọc — truy vấn trả về tất cả các hàng trong giới hạn.", + "valuePlaceholder": "Giá trị", + "valuePlaceholderIn": "Các giá trị cách nhau bởi dấu phẩy", + "noValueNeeded": "Không cần giá trị", + "run": "Chạy truy vấn", + "running": "Đang chạy...", + "save": "Lưu", + "confirmSave": "Lưu", + "cancel": "Hủy", + "saveNamePlaceholder": "Tên truy vấn", + "exportCsv": "Xuất CSV", + "exportJson": "Xuất JSON", + "savedQueries": "Truy vấn đã lưu", + "noSaved": "Chưa có truy vấn nào được lưu.", + "loadQuery": "Tải truy vấn", + "deleteQuery": "Xóa truy vấn", + "readyTitle": "Xây dựng và chạy truy vấn", + "readyDesc": "Chọn một thực thể, thêm bộ lọc và nhấn Chạy truy vấn để xem kết quả tại đây.", + "noResults": "Không có hàng nào khớp", + "noResultsDesc": "Không có hàng nào khớp với bộ lọc hiện tại. Hãy thử nới lỏng bộ lọc hoặc đổi chế độ khớp.", + "rowsTotal": "Hiển thị {{shown}} trên {{count}} hàng", + "tookMs": "{{ms}} ms", + "truncated": "Kết quả đã bị cắt bớt theo giới hạn {{limit}} hàng. Hãy tăng giới hạn hoặc thêm bộ lọc để thu hẹp tập kết quả.", + "errorSchema": "Không tải được lược đồ truy vấn.", + "errorRun": "Không chạy được truy vấn.", + "errorSaved": "Không tải được các truy vấn đã lưu.", + "errorSave": "Không lưu được truy vấn.", + "errorDelete": "Không xóa được truy vấn.", + "entities": { + "events": "Sự kiện", + "agents": "Agent", + "sessions": "Phiên" + }, + "ops": { + "eq": "bằng", + "ne": "khác", + "gt": "lớn hơn", + "gte": "lớn hơn hoặc bằng", + "lt": "nhỏ hơn", + "lte": "nhỏ hơn hoặc bằng", + "like": "chứa", + "in": "trong danh sách", + "is_null": "trống", + "is_not_null": "không trống" + } +} diff --git a/client/src/i18n/locales/zh/nav.json b/client/src/i18n/locales/zh/nav.json index bbd0e565..5ebf74c4 100644 --- a/client/src/i18n/locales/zh/nav.json +++ b/client/src/i18n/locales/zh/nav.json @@ -5,6 +5,7 @@ "agentBoard": "Kanban 看板", "sessions": "会话", "activityFeed": "活动流", + "queryExplorer": "查询浏览器", "analytics": "分析", "workflows": "工作流", "alerts": "警报", diff --git a/client/src/i18n/locales/zh/query.json b/client/src/i18n/locales/zh/query.json new file mode 100644 index 00000000..631980af --- /dev/null +++ b/client/src/i18n/locales/zh/query.json @@ -0,0 +1,61 @@ +{ + "title": "查询浏览器", + "subtitle": "针对仪表盘数据构建临时查询并导出结果。", + "entity": "实体", + "match": "匹配", + "matchAnd": "并且", + "matchOr": "或者", + "limit": "条数上限", + "sort": "排序", + "sortNone": "不排序", + "asc": "升序", + "desc": "降序", + "filters": "筛选条件", + "addFilter": "添加条件", + "removeFilter": "移除条件", + "noFilters": "暂无筛选条件 —— 查询将返回上限以内的所有行。", + "valuePlaceholder": "值", + "valuePlaceholderIn": "用逗号分隔多个值", + "noValueNeeded": "无需填写值", + "run": "运行查询", + "running": "运行中...", + "save": "保存", + "confirmSave": "保存", + "cancel": "取消", + "saveNamePlaceholder": "查询名称", + "exportCsv": "导出 CSV", + "exportJson": "导出 JSON", + "savedQueries": "已保存的查询", + "noSaved": "暂无已保存的查询。", + "loadQuery": "加载查询", + "deleteQuery": "删除查询", + "readyTitle": "构建并运行查询", + "readyDesc": "选择一个实体,添加筛选条件,然后点击“运行查询”即可在此处查看结果。", + "noResults": "没有匹配的行", + "noResultsDesc": "当前筛选条件没有匹配的行。请尝试放宽条件或切换匹配模式。", + "rowsTotal": "显示 {{count}} 行中的 {{shown}} 行", + "tookMs": "{{ms}} 毫秒", + "truncated": "结果已被截断至上限 {{limit}} 行。请提高上限或添加筛选条件以缩小结果集。", + "errorSchema": "加载查询架构失败。", + "errorRun": "运行查询失败。", + "errorSaved": "加载已保存的查询失败。", + "errorSave": "保存查询失败。", + "errorDelete": "删除查询失败。", + "entities": { + "events": "事件", + "agents": "Agent", + "sessions": "会话" + }, + "ops": { + "eq": "等于", + "ne": "不等于", + "gt": "大于", + "gte": "大于等于", + "lt": "小于", + "lte": "小于等于", + "like": "包含", + "in": "在列表中", + "is_null": "为空", + "is_not_null": "不为空" + } +} diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 5858ebdc..9e2d857d 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -12,6 +12,10 @@ import type { CostResult, DashboardEvent, ModelPricing, + QueryBody, + QueryRunResult, + QuerySchema, + SavedQuery, Session, SessionDrillIn, SessionStats, @@ -165,6 +169,39 @@ export const api = { facets: () => request<{ event_types: string[]; tool_names: string[] }>("/events/facets"), }, + query: { + /** Fetch the entity/field/operator schema that drives the builder. */ + schema: () => request("/query/schema"), + /** Run a query. JSON by default; pass `format: "csv"` to get the raw + * Response back (a text/csv download) without forcing a JSON parse. */ + run: ((body: QueryBody, format?: "csv") => { + if (format === "csv") { + return runCsv(body); + } + return request("/query/run", { + method: "POST", + body: JSON.stringify(body), + }); + }) as { + (body: QueryBody): Promise; + (body: QueryBody, format: "csv"): Promise; + }, + saved: { + // The server wraps these as { saved: ... } (repo convention); unwrap so + // callers get the bare row(s). + list: () => request<{ saved: SavedQuery[] }>("/query/saved").then((r) => r.saved), + create: (body: { name: string; query: QueryBody; tags?: string[] }) => + request<{ saved: SavedQuery }>("/query/saved", { + method: "POST", + body: JSON.stringify(body), + }).then((r) => r.saved), + remove: (id: string | number) => + request<{ ok: true }>(`/query/saved/${encodeURIComponent(String(id))}`, { + method: "DELETE", + }), + }, + }, + analytics: { get: () => request(`/analytics?tz_offset=${new Date().getTimezoneOffset()}`), }, @@ -466,6 +503,24 @@ export const api = { }, }; +/** + * POST /api/query/run?format=csv — returns the raw Response so the caller can + * stream it into a Blob download. We don't parse the body here (it's text/csv, + * not JSON), but we still surface server errors via the shared error shape. + */ +async function runCsv(body: QueryBody): Promise { + const res = await fetch(`${BASE}/query/run?format=csv`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + throw new Error(errBody?.error?.message || `HTTP ${res.status}`); + } + return res; +} + function requestBackupsHelper(params?: { scope?: "user" | "project"; type?: CcArtifactType }) { const qs = new URLSearchParams(); if (params?.scope) qs.set("scope", params.scope); diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index e21437f7..02a724f9 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -665,6 +665,106 @@ export interface WorkflowRunDetail { events: DashboardEvent[]; } +// ── Query Explorer (ad-hoc query builder) ────────────────────────────────── +// Generic, schema-driven query UI backed by GET /api/query/schema + +// POST /api/query/run. The schema is fetched once on mount and drives which +// entities/fields/operators the builder offers. + +/** Operator tokens accepted by the query engine. `is_null`/`is_not_null` take + * no value; `in` takes a comma-separated list that the UI splits into an array + * of strings before sending. */ +export type QueryOperator = + | "eq" + | "ne" + | "gt" + | "gte" + | "lt" + | "lte" + | "like" + | "in" + | "is_null" + | "is_not_null"; + +export type QueryFieldType = "int" | "text" | "datetime"; + +export interface QuerySchemaField { + type: QueryFieldType; + /** Operators valid for this field's type (subset of the global operator set). */ + ops: string[]; +} + +export interface QuerySchemaEntity { + /** Underlying SQL table name (informational; the UI keys off the entity name). */ + table: string; + fields: Record; +} + +export interface QuerySchemaLimits { + maxLimit: number; + defaultLimit: number; + maxInValues: number; +} + +export interface QuerySchema { + entities: Record; + operators: string[]; + limits: QuerySchemaLimits; +} + +/** Entity names the schema exposes. Kept as a string so new entities added + * server-side don't require a client change, but the contract guarantees at + * least events/agents/sessions. */ +export type QueryEntityName = string; + +/** `value` is omitted for is_null/is_not_null and an array for `in`. Numeric + * values are sent for `int` fields (e.g. events.id); the backend rejects a + * string there, so the UI parses int inputs to numbers before submitting. */ +export interface QueryFilter { + field: string; + op: QueryOperator; + value?: string | number | (string | number)[]; +} + +export type QueryMatch = "and" | "or"; +export type QuerySortDir = "asc" | "desc"; + +export interface QuerySort { + field: string; + dir: QuerySortDir; +} + +/** Request body for POST /api/query/run (also the persisted `query` of a saved + * query). */ +export interface QueryBody { + entity: QueryEntityName; + filters: QueryFilter[]; + match: QueryMatch; + sort: QuerySort[]; + limit: number; + offset: number; +} + +export interface QueryRunResult { + entity: QueryEntityName; + columns: string[]; + rows: Record[]; + total: number; + limit: number; + offset: number; + truncated: boolean; + tookMs: number; + warnings: string[]; +} + +export interface SavedQuery { + id: string | number; + name: string; + entity: QueryEntityName; + // null when the persisted DSL JSON failed to parse server-side. + query: QueryBody | null; + tags: string[]; +} + export const STATUS_CONFIG: Record< EffectiveAgentStatus, { labelKey: string; color: string; bg: string; dot: string } diff --git a/client/src/pages/QueryExplorer.tsx b/client/src/pages/QueryExplorer.tsx new file mode 100644 index 00000000..87124f5b --- /dev/null +++ b/client/src/pages/QueryExplorer.tsx @@ -0,0 +1,879 @@ +/** + * @file QueryExplorer.tsx + * @description Schema-driven ad-hoc query builder. Fetches the entity/field/ + * operator schema from GET /api/query/schema on mount, then lets the user + * compose filters (field + operator + value), an AND/OR match toggle, an + * optional single sort, and a row limit, and run them against POST + * /api/query/run. Results render in a table with total/timing/truncation/warning + * banners. Supports CSV/JSON export of the current result set and saving / + * loading / deleting named queries via /api/query/saved. + * @author Son Nguyen + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Database, + Play, + Plus, + Trash2, + Save, + Download, + FileJson, + AlertTriangle, + Clock, + Bookmark, + X, +} from "lucide-react"; +import { api } from "../lib/api"; +import { EmptyState } from "../components/EmptyState"; +import { Skeleton } from "../components/Skeleton"; +import { Select } from "../components/Select"; +import type { SelectOption } from "../components/Select"; +import type { + QueryBody, + QueryFilter, + QueryMatch, + QueryOperator, + QueryRunResult, + QuerySchema, + QuerySchemaEntity, + QuerySortDir, + SavedQuery, +} from "../lib/types"; + +// Operators that take no value input — selecting one hides the value field. +const NO_VALUE_OPS: ReadonlySet = new Set(["is_null", "is_not_null"]); +// Operator that accepts a comma-separated list (split into an array on submit). +const IN_OP: QueryOperator = "in"; + +/** A filter row in the builder. We keep the raw text the user typed for `in` + * (comma-separated) and only split it into an array when building the request + * body, so the input stays editable. */ +interface FilterRow { + field: string; + op: QueryOperator; + value: string; +} + +/** First field of an entity, used as the default for new filter rows / sort. */ +function firstField(entity: QuerySchemaEntity | undefined): string { + if (!entity) return ""; + const keys = Object.keys(entity.fields); + return keys[0] ?? ""; +} + +/** Valid operators for a field, falling back to the schema's global list. */ +function opsForField( + entity: QuerySchemaEntity | undefined, + field: string, + schema: QuerySchema | null +): QueryOperator[] { + const fieldOps = entity?.fields[field]?.ops; + const list = fieldOps && fieldOps.length > 0 ? fieldOps : (schema?.operators ?? []); + return list as QueryOperator[]; +} + +/** Build a default filter row for the given entity (first field, first valid op). */ +function defaultFilterRow( + entity: QuerySchemaEntity | undefined, + schema: QuerySchema | null +): FilterRow { + const field = firstField(entity); + const ops = opsForField(entity, field, schema); + return { field, op: (ops[0] ?? "eq") as QueryOperator, value: "" }; +} + +export function QueryExplorer() { + const { t } = useTranslation("query"); + + const [schema, setSchema] = useState(null); + const [schemaLoading, setSchemaLoading] = useState(true); + const [schemaError, setSchemaError] = useState(null); + + const [entity, setEntity] = useState(""); + const [filters, setFilters] = useState([]); + const [match, setMatch] = useState("and"); + const [limit, setLimit] = useState(100); + // Empty sortField means "no sort". + const [sortField, setSortField] = useState(""); + const [sortDir, setSortDir] = useState("asc"); + + const [result, setResult] = useState(null); + const [running, setRunning] = useState(false); + const [runError, setRunError] = useState(null); + const [hasRun, setHasRun] = useState(false); + + const [saved, setSaved] = useState([]); + const [savedError, setSavedError] = useState(null); + const [saveName, setSaveName] = useState(""); + const [saveOpen, setSaveOpen] = useState(false); + const [saving, setSaving] = useState(false); + + const saveInputRef = useRef(null); + + const currentEntity = schema?.entities[entity]; + + // ── Mount: load schema + saved queries ── + useEffect(() => { + let cancelled = false; + setSchemaLoading(true); + api.query + .schema() + .then((s) => { + if (cancelled) return; + setSchema(s); + const entityNames = Object.keys(s.entities); + const firstEntity = entityNames[0] ?? ""; + setEntity(firstEntity); + const ent = s.entities[firstEntity]; + setFilters([defaultFilterRow(ent, s)]); + setLimit(s.limits.defaultLimit); + setSortField(""); + setSchemaError(null); + }) + .catch((err: unknown) => { + if (cancelled) return; + setSchemaError(err instanceof Error ? err.message : t("errorSchema")); + }) + .finally(() => { + if (!cancelled) setSchemaLoading(false); + }); + return () => { + cancelled = true; + }; + // t is stable enough; intentionally run once on mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const loadSaved = useCallback(() => { + api.query.saved + .list() + .then((rows) => { + setSaved(Array.isArray(rows) ? rows : []); + setSavedError(null); + }) + .catch((err: unknown) => { + setSavedError(err instanceof Error ? err.message : t("errorSaved")); + }); + }, [t]); + + useEffect(() => { + loadSaved(); + }, [loadSaved]); + + useEffect(() => { + if (saveOpen) saveInputRef.current?.focus(); + }, [saveOpen]); + + // ── Entity switch: reset filters / sort to valid defaults ── + const onEntityChange = useCallback( + (next: string) => { + if (!schema) return; + setEntity(next); + const ent = schema.entities[next]; + setFilters([defaultFilterRow(ent, schema)]); + setSortField(""); + setSortDir("asc"); + setResult(null); + setHasRun(false); + setRunError(null); + }, + [schema] + ); + + // ── Filter row mutations ── + const addFilter = useCallback(() => { + setFilters((prev) => [...prev, defaultFilterRow(currentEntity, schema)]); + }, [currentEntity, schema]); + + const removeFilter = useCallback((index: number) => { + setFilters((prev) => prev.filter((_, i) => i !== index)); + }, []); + + const updateFilterField = useCallback( + (index: number, field: string) => { + setFilters((prev) => + prev.map((row, i) => { + if (i !== index) return row; + // Switching field may invalidate the current operator — clamp it to + // the new field's valid set. + const ops = opsForField(currentEntity, field, schema); + const op = ops.includes(row.op) ? row.op : ((ops[0] ?? "eq") as QueryOperator); + return { ...row, field, op }; + }) + ); + }, + [currentEntity, schema] + ); + + const updateFilterOp = useCallback((index: number, op: QueryOperator) => { + setFilters((prev) => + prev.map((row, i) => + i === index ? { ...row, op, value: NO_VALUE_OPS.has(op) ? "" : row.value } : row + ) + ); + }, []); + + const updateFilterValue = useCallback((index: number, value: string) => { + setFilters((prev) => prev.map((row, i) => (i === index ? { ...row, value } : row))); + }, []); + + // ── Build request body from current builder state ── + const buildBody = useCallback( + (offset = 0): QueryBody => { + const builtFilters: QueryFilter[] = filters.map((row) => { + if (NO_VALUE_OPS.has(row.op)) { + return { field: row.field, op: row.op }; + } + // int fields must be sent as numbers — the backend rejects a string for + // an int column (e.g. events.id), so coerce based on the schema type. + const fieldType = schema?.entities[entity]?.fields[row.field]?.type; + if (row.op === IN_OP) { + const values = row.value + .split(",") + .map((v) => v.trim()) + .filter((v) => v.length > 0) + .map((v) => (fieldType === "int" ? Number(v) : v)); + return { field: row.field, op: row.op, value: values }; + } + const value = fieldType === "int" ? Number(row.value) : row.value; + return { field: row.field, op: row.op, value }; + }); + return { + entity, + filters: builtFilters, + match, + sort: sortField ? [{ field: sortField, dir: sortDir }] : [], + limit, + offset, + }; + }, + [entity, filters, match, sortField, sortDir, limit] + ); + + // ── Run ── + const runQuery = useCallback(async () => { + if (!entity) return; + setRunning(true); + setRunError(null); + setHasRun(true); + try { + const res = await api.query.run(buildBody(0)); + setResult(res); + } catch (err) { + setRunError(err instanceof Error ? err.message : t("errorRun")); + } finally { + setRunning(false); + } + }, [entity, buildBody, t]); + + // ── Exports ── + const triggerDownload = useCallback((blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + }, []); + + const exportCsv = useCallback(async () => { + if (!entity) return; + setRunError(null); + try { + const res = await api.query.run(buildBody(0), "csv"); + const blob = await res.blob(); + triggerDownload(blob, `${entity}-query.csv`); + } catch (err) { + setRunError(err instanceof Error ? err.message : t("errorRun")); + } + }, [entity, buildBody, triggerDownload, t]); + + const exportJson = useCallback(() => { + if (!result) return; + const blob = new Blob([JSON.stringify(result.rows, null, 2)], { + type: "application/json", + }); + triggerDownload(blob, `${entity || "query"}-rows.json`); + }, [result, entity, triggerDownload]); + + // ── Save / load / delete ── + const submitSave = useCallback(async () => { + const name = saveName.trim(); + if (!name || !entity) return; + setSaving(true); + setSavedError(null); + try { + await api.query.saved.create({ name, query: buildBody(0) }); + setSaveName(""); + setSaveOpen(false); + loadSaved(); + } catch (err) { + setSavedError(err instanceof Error ? err.message : t("errorSave")); + } finally { + setSaving(false); + } + }, [saveName, entity, buildBody, loadSaved, t]); + + const loadSavedQuery = useCallback( + (item: SavedQuery) => { + if (!schema) return; + const q = item.query; + // A saved query whose persisted JSON failed to parse comes back as null. + if (!q) return; + const targetEntity = schema.entities[q.entity] ? q.entity : entity; + const ent = schema.entities[targetEntity]; + setEntity(targetEntity); + // Re-hydrate filter rows; `in` arrays become comma text and numeric + // values become strings (FilterRow.value is always a string input). + const rows: FilterRow[] = (q.filters ?? []).map((f) => ({ + field: f.field, + op: f.op, + value: Array.isArray(f.value) + ? f.value.join(", ") + : f.value !== undefined && f.value !== null + ? String(f.value) + : "", + })); + setFilters(rows.length > 0 ? rows : [defaultFilterRow(ent, schema)]); + setMatch(q.match === "or" ? "or" : "and"); + setLimit(typeof q.limit === "number" ? q.limit : schema.limits.defaultLimit); + const sort0 = q.sort?.[0]; + setSortField(sort0?.field ?? ""); + setSortDir(sort0?.dir === "desc" ? "desc" : "asc"); + setResult(null); + setHasRun(false); + setRunError(null); + }, + [schema, entity] + ); + + const deleteSavedQuery = useCallback( + async (id: SavedQuery["id"]) => { + setSavedError(null); + try { + await api.query.saved.remove(id); + loadSaved(); + } catch (err) { + setSavedError(err instanceof Error ? err.message : t("errorDelete")); + } + }, + [loadSaved, t] + ); + + // ── Derived option lists for the Select dropdowns ── + const entityOptions: SelectOption[] = useMemo( + () => + schema + ? Object.keys(schema.entities).map((name) => ({ + value: name, + label: t(`entities.${name}`, { defaultValue: name }), + })) + : [], + [schema, t] + ); + + const fieldOptions: SelectOption[] = useMemo( + () => + currentEntity + ? Object.keys(currentEntity.fields).map((name) => ({ + value: name, + label: name, + hint: currentEntity.fields[name]?.type, + })) + : [], + [currentEntity] + ); + + const sortFieldOptions: SelectOption[] = useMemo( + () => [{ value: "", label: t("sortNone") }, ...fieldOptions], + [fieldOptions, t] + ); + + const limitMax = schema?.limits.maxLimit ?? 1000; + + // ── Render ── + return ( +
+ {/* Header */} +
+
+
+ +
+
+

{t("title")}

+

{t("subtitle")}

+
+
+
+ +
+
+ + {schemaError ? ( +
+

+ + {schemaError} +

+
+ ) : null} + +
+ {/* Builder + results column */} +
+ {/* Builder card */} +
+ {schemaLoading ? ( +
+ + + +
+ ) : ( + <> + {/* Entity + match + limit row */} +
+
+ + { + const n = Number(e.target.value); + if (Number.isNaN(n)) return; + setLimit(Math.max(1, Math.min(limitMax, Math.floor(n)))); + }} + className="input w-28 py-1.5 text-[13px]" + /> +
+ +
+ +
+
+ setSortDir(v as QuerySortDir)} + options={[ + { value: "asc", label: t("asc") }, + { value: "desc", label: t("desc") }, + ]} + /> +
+ ) : null} +
+
+
+ + {/* Filters */} +
+
+ + {t("filters")} + + +
+ + {filters.length === 0 ? ( +

{t("noFilters")}

+ ) : ( +
+ {filters.map((row, i) => { + const ops = opsForField(currentEntity, row.field, schema); + const opOptions: SelectOption[] = ops.map((op) => ({ + value: op, + label: t(`ops.${op}`, { defaultValue: op }), + })); + const needsValue = !NO_VALUE_OPS.has(row.op); + return ( +
+
+ updateFilterOp(i, v as QueryOperator)} + options={opOptions} + /> +
+
+ {needsValue ? ( + updateFilterValue(i, e.target.value)} + placeholder={ + row.op === IN_OP + ? t("valuePlaceholderIn") + : t("valuePlaceholder") + } + className="input w-full py-1.5 text-[13px]" + /> + ) : ( + + {t("noValueNeeded")} + + )} +
+ +
+ ); + })} +
+ )} +
+ + {/* Action row: save + exports */} +
+ {saveOpen ? ( +
+ setSaveName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") submitSave(); + if (e.key === "Escape") { + setSaveOpen(false); + setSaveName(""); + } + }} + placeholder={t("saveNamePlaceholder")} + className="input py-1.5 text-[13px] w-52" + /> + + +
+ ) : ( + + )} + +
+ + + +
+ + )} +
+ + {/* Run error */} + {runError ? ( +
+

+ + {runError} +

+
+ ) : null} + + {/* Results */} + {running ? ( +
+
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ + + +
+ ))} +
+
+ ) : result ? ( + + ) : hasRun ? ( + + ) : ( + + )} +
+ + {/* Saved queries sidebar */} + +
+
+ ); +} + +/** Results table + meta banners. Split out to keep the main component lean. */ +function ResultsPanel({ result }: { result: QueryRunResult }) { + const { t } = useTranslation("query"); + + return ( +
+ {/* Meta line */} +
+ + {t("rowsTotal", { count: result.total, shown: result.rows.length })} + + + + {t("tookMs", { ms: result.tookMs })} + +
+ + {/* Truncated banner */} + {result.truncated ? ( +
+ + {t("truncated", { limit: result.limit })} +
+ ) : null} + + {/* Warnings */} + {result.warnings && result.warnings.length > 0 ? ( +
+ {result.warnings.map((w, i) => ( +

+ + {w} +

+ ))} +
+ ) : null} + + {/* Table */} + {result.rows.length === 0 ? ( + + ) : ( +
+
+ + + + {result.columns.map((col) => ( + + ))} + + + + {result.rows.map((row, ri) => ( + + {result.columns.map((col) => ( + + ))} + + ))} + +
+ {col} +
+ {formatCell(row[col])} +
+
+
+ )} +
+ ); +} + +/** Render a cell value as a string. Objects/arrays are JSON-stringified; null / + * undefined render as an em-dash so empty cells are visually distinct. */ +function formatCell(value: unknown): string { + if (value === null || value === undefined) return "—"; + if (typeof value === "object") { + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return String(value); +} diff --git a/server/__tests__/query.test.js b/server/__tests__/query.test.js new file mode 100644 index 00000000..776ce12a --- /dev/null +++ b/server/__tests__/query.test.js @@ -0,0 +1,497 @@ +/** + * @file Integration tests for the safe Query Explorer (/api/query). Exercises a + * real on-disk SQLite DB (temp file) through the HTTP layer: filter operators, + * match/sort/limit semantics, the truncation/warning contract, CSV export, and + * saved-query CRUD. The security block asserts that the DSL rejects unknown + * entities/fields, type-mismatched operators, and SQL-injection attempts in + * both field names and values, with the underlying table left intact. + * @author Son Nguyen + */ + +const { describe, it, before, after } = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("path"); +const fs = require("fs"); +const os = require("os"); +const http = require("http"); + +// Set up the test database BEFORE requiring any server modules. +const TEST_DB = path.join(os.tmpdir(), `dashboard-query-test-${Date.now()}-${process.pid}.db`); +process.env.DASHBOARD_DB_PATH = TEST_DB; + +const { createApp, startServer } = require("../index"); +const { db } = require("../db"); + +let server; +let BASE; + +function request(urlPath, options = {}) { + return new Promise((resolve, reject) => { + const url = new URL(urlPath, BASE); + const req = http.request( + { + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + method: options.method || "GET", + headers: { "Content-Type": "application/json", ...options.headers }, + }, + (res) => { + let body = ""; + res.on("data", (c) => (body += c)); + res.on("end", () => { + const contentType = res.headers["content-type"] || ""; + if (contentType.includes("application/json")) { + try { + resolve({ status: res.statusCode, headers: res.headers, body: JSON.parse(body) }); + return; + } catch { + /* fall through to raw */ + } + } + resolve({ status: res.statusCode, headers: res.headers, body }); + }); + } + ); + req.on("error", reject); + if (options.body !== undefined) req.write(JSON.stringify(options.body)); + req.end(); + }); +} + +before(async () => { + // Seed one session, two agents, several events with controlled values. + db.prepare( + "INSERT INTO sessions (id, name, status, cwd, model, started_at) VALUES (?, ?, ?, ?, ?, ?)" + ).run( + "sess-1", + "Session One", + "active", + "/tmp/proj", + "claude-opus-4-8", + "2026-01-01T00:00:00.000Z" + ); + + db.prepare( + "INSERT INTO agents (id, session_id, name, type, status, task, started_at) VALUES (?, ?, ?, ?, ?, ?, ?)" + ).run("agent-1", "sess-1", "Main", "main", "working", "do work", "2026-01-01T00:00:00.000Z"); + // ended_at left NULL on this one for is_null testing. + + const insertEvent = db.prepare( + "INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, created_at) VALUES (?, ?, ?, ?, ?, ?)" + ); + insertEvent.run("sess-1", "agent-1", "tool", "Bash", "ran ls", "2026-01-01T01:00:00.000Z"); + insertEvent.run("sess-1", "agent-1", "tool", "Bash", "ran git", "2026-02-01T01:00:00.000Z"); + insertEvent.run("sess-1", "agent-1", "tool", "Read", "read file", "2026-03-01T01:00:00.000Z"); + insertEvent.run("sess-1", "agent-1", "tool", "Edit", "edited file", "2026-04-01T01:00:00.000Z"); + // An event whose tool_name is NULL, for is_null coverage. + insertEvent.run("sess-1", "agent-1", "message", null, "a message", "2026-05-01T01:00:00.000Z"); + // An event whose summary contains a SQL-ish literal to prove value params are + // compared literally, never executed. + insertEvent.run( + "sess-1", + "agent-1", + "tool", + "Bash", + "'; DROP TABLE events; --", + "2026-06-01T01:00:00.000Z" + ); + + const app = createApp(); + server = await startServer(app, 0); + BASE = `http://127.0.0.1:${server.address().port}`; +}); + +after(() => { + server?.close(); + try { + db.close(); + } catch { + /* ignore */ + } + try { + fs.rmSync(TEST_DB, { force: true }); + } catch { + /* ignore */ + } +}); + +describe("POST /api/query/run — filters", () => { + it("eq filter returns only matching rows", async () => { + const r = await request("/api/query/run", { + method: "POST", + body: { entity: "events", filters: [{ field: "tool_name", op: "eq", value: "Read" }] }, + }); + assert.equal(r.status, 200); + assert.equal(r.body.entity, "events"); + assert.equal(r.body.total, 1); + assert.equal(r.body.rows.length, 1); + assert.equal(r.body.rows[0].tool_name, "Read"); + // columns are the explicit allowlist, not SELECT *. + assert.ok(r.body.columns.includes("id")); + assert.ok(!r.body.columns.includes("data")); + assert.equal(typeof r.body.tookMs, "number"); + }); + + it("accepts a numeric value on an int field (events.id)", async () => { + // Regression: the int columns must accept a JS number. The builder UI sends + // numbers for int fields (string values 400); this locks the contract. + const r = await request("/api/query/run", { + method: "POST", + body: { entity: "events", filters: [{ field: "id", op: "lte", value: 2 }] }, + }); + assert.equal(r.status, 200); + assert.equal(r.body.total, 2); + assert.ok( + r.body.rows.every((row) => row.id <= 2), + "only rows with id <= 2" + ); + + // A string on an int field is still rejected (type guard intact). + const bad = await request("/api/query/run", { + method: "POST", + body: { entity: "events", filters: [{ field: "id", op: "eq", value: "2" }] }, + }); + assert.equal(bad.status, 400); + }); + + it("gte on a datetime field uses an ISO string bound", async () => { + const r = await request("/api/query/run", { + method: "POST", + body: { + entity: "events", + filters: [{ field: "created_at", op: "gte", value: "2026-03-01T00:00:00.000Z" }], + }, + }); + assert.equal(r.status, 200); + // 2026-03, 2026-04, 2026-05, 2026-06 → 4 rows. + assert.equal(r.body.total, 4); + }); + + it("like filter matches a substring", async () => { + const r = await request("/api/query/run", { + method: "POST", + body: { entity: "events", filters: [{ field: "summary", op: "like", value: "%file%" }] }, + }); + assert.equal(r.status, 200); + assert.equal(r.body.total, 2); + }); + + it("in filter expands to a parameterized list", async () => { + const r = await request("/api/query/run", { + method: "POST", + body: { + entity: "events", + filters: [{ field: "tool_name", op: "in", value: ["Read", "Edit"] }], + }, + }); + assert.equal(r.status, 200); + assert.equal(r.body.total, 2); + }); + + it("is_null filter finds rows with a NULL column", async () => { + const r = await request("/api/query/run", { + method: "POST", + body: { entity: "events", filters: [{ field: "tool_name", op: "is_null" }] }, + }); + assert.equal(r.status, 200); + assert.equal(r.body.total, 1); + assert.equal(r.body.rows[0].event_type, "message"); + }); + + it("match=or unions the filters", async () => { + const r = await request("/api/query/run", { + method: "POST", + body: { + entity: "events", + match: "or", + filters: [ + { field: "tool_name", op: "eq", value: "Read" }, + { field: "tool_name", op: "eq", value: "Edit" }, + ], + }, + }); + assert.equal(r.status, 200); + assert.equal(r.body.total, 2); + }); + + it("sort asc/desc orders rows by an allowlisted column", async () => { + const desc = await request("/api/query/run", { + method: "POST", + body: { + entity: "events", + filters: [{ field: "tool_name", op: "eq", value: "Bash" }], + sort: [{ field: "created_at", dir: "desc" }], + }, + }); + assert.equal(desc.status, 200); + const descTimes = desc.body.rows.map((x) => x.created_at); + assert.deepEqual(descTimes, [...descTimes].sort().reverse()); + + const asc = await request("/api/query/run", { + method: "POST", + body: { + entity: "events", + filters: [{ field: "tool_name", op: "eq", value: "Bash" }], + sort: [{ field: "created_at", dir: "asc" }], + }, + }); + assert.equal(asc.status, 200); + const ascTimes = asc.body.rows.map((x) => x.created_at); + assert.deepEqual(ascTimes, [...ascTimes].sort()); + }); + + it("clamps an over-limit and flags truncation + a warning", async () => { + const r = await request("/api/query/run", { + method: "POST", + body: { entity: "events", limit: 99999 }, + }); + assert.equal(r.status, 200); + assert.equal(r.body.limit, 1000); // clamped to maxLimit + // 6 events total, all returned → not truncated, no warning. + assert.equal(r.body.truncated, false); + assert.equal(r.body.warnings.length, 0); + + const limited = await request("/api/query/run", { + method: "POST", + body: { entity: "events", limit: 2 }, + }); + assert.equal(limited.status, 200); + assert.equal(limited.body.limit, 2); + assert.equal(limited.body.rows.length, 2); + assert.equal(limited.body.total, 6); + assert.equal(limited.body.truncated, true); + assert.equal(limited.body.warnings.length, 1); + assert.match(limited.body.warnings[0], /truncated to 2 rows/); + }); + + it("queries the agents entity (NULL ended_at via is_null)", async () => { + const r = await request("/api/query/run", { + method: "POST", + body: { entity: "agents", filters: [{ field: "ended_at", op: "is_null" }] }, + }); + assert.equal(r.status, 200); + assert.equal(r.body.total, 1); + assert.equal(r.body.rows[0].id, "agent-1"); + }); +}); + +describe("POST /api/query/run — security / abuse", () => { + it("rejects an unknown entity", async () => { + const r = await request("/api/query/run", { + method: "POST", + body: { entity: "secrets" }, + }); + assert.equal(r.status, 400); + assert.match(r.body.error.message, /Unknown entity/); + }); + + it("rejects an unknown field", async () => { + const r = await request("/api/query/run", { + method: "POST", + body: { entity: "events", filters: [{ field: "password", op: "eq", value: "x" }] }, + }); + assert.equal(r.status, 400); + assert.match(r.body.error.message, /Unknown field/); + }); + + it("rejects a disallowed operator for the field type (like on int)", async () => { + const r = await request("/api/query/run", { + method: "POST", + body: { entity: "events", filters: [{ field: "id", op: "like", value: "1" }] }, + }); + assert.equal(r.status, 400); + assert.match(r.body.error.message, /not allowed on int/); + }); + + it("rejects a SQL-injection attempt in the field name and leaves the table intact", async () => { + const before = db.prepare("SELECT COUNT(*) as count FROM events").get().count; + const r = await request("/api/query/run", { + method: "POST", + body: { + entity: "events", + filters: [{ field: "id; DROP TABLE events", op: "eq", value: 1 }], + }, + }); + assert.equal(r.status, 400); + assert.match(r.body.error.message, /Unknown field/); + const after = db.prepare("SELECT COUNT(*) as count FROM events").get().count; + assert.equal(after, before, "events table still intact"); + }); + + it("treats an injection payload in a VALUE as a literal (parameterized, not executed)", async () => { + const before = db.prepare("SELECT COUNT(*) as count FROM events").get().count; + const r = await request("/api/query/run", { + method: "POST", + body: { + entity: "events", + filters: [{ field: "summary", op: "eq", value: "'; DROP TABLE events; --" }], + }, + }); + assert.equal(r.status, 200); + // Matched the one seeded row literally; table not dropped. + assert.equal(r.body.total, 1); + const after = db.prepare("SELECT COUNT(*) as count FROM events").get().count; + assert.equal(after, before, "events table still intact"); + }); + + it("rejects an `in` operator with a non-array value", async () => { + const r = await request("/api/query/run", { + method: "POST", + body: { entity: "events", filters: [{ field: "tool_name", op: "in", value: "Bash" }] }, + }); + assert.equal(r.status, 400); + assert.match(r.body.error.message, /non-empty array/); + }); + + it("rejects an oversized `in` array (> 100 values)", async () => { + const big = Array.from({ length: 101 }, (_, i) => `v${i}`); + const r = await request("/api/query/run", { + method: "POST", + body: { entity: "events", filters: [{ field: "tool_name", op: "in", value: big }] }, + }); + assert.equal(r.status, 400); + assert.match(r.body.error.message, /at most 100 values/); + }); + + it("rejects an unknown top-level key", async () => { + const r = await request("/api/query/run", { + method: "POST", + body: { entity: "events", drop: "events" }, + }); + assert.equal(r.status, 400); + assert.match(r.body.error.message, /Unknown query key/); + }); + + it("rejects an unknown key smuggled inside a filter object", async () => { + const r = await request("/api/query/run", { + method: "POST", + body: { + entity: "events", + filters: [{ field: "tool_name", op: "eq", value: "Bash", evil: "DROP" }], + }, + }); + assert.equal(r.status, 400); + assert.match(r.body.error.message, /Unknown key "evil"/); + }); + + it("rejects a non-string field (array) instead of coercing it", async () => { + const r = await request("/api/query/run", { + method: "POST", + body: { entity: "events", filters: [{ field: ["id"], op: "eq", value: 1 }] }, + }); + assert.equal(r.status, 400); + assert.match(r.body.error.message, /Unknown field/); + }); + + it("rejects too many filters (DoS bound)", async () => { + const many = Array.from({ length: 51 }, () => ({ field: "id", op: "gte", value: 0 })); + const r = await request("/api/query/run", { + method: "POST", + body: { entity: "events", filters: many }, + }); + assert.equal(r.status, 400); + assert.match(r.body.error.message, /Too many filters/); + }); + + it("rejects too many sort fields (DoS bound)", async () => { + const many = Array.from({ length: 11 }, () => ({ field: "created_at", dir: "asc" })); + const r = await request("/api/query/run", { + method: "POST", + body: { entity: "events", sort: many }, + }); + assert.equal(r.status, 400); + assert.match(r.body.error.message, /Too many sort/); + }); + + it("clamps a huge offset to a safe integer instead of crashing", async () => { + const r = await request("/api/query/run", { + method: "POST", + body: { entity: "events", offset: 1e18 }, + }); + assert.equal(r.status, 200); + assert.ok(Number.isSafeInteger(r.body.offset), "offset must be clamped to a safe integer"); + }); +}); + +describe("POST /api/query/run?format=csv", () => { + it("returns text/csv with a header row", async () => { + const r = await request("/api/query/run?format=csv", { + method: "POST", + body: { entity: "events", filters: [{ field: "tool_name", op: "eq", value: "Read" }] }, + }); + assert.equal(r.status, 200); + assert.match(r.headers["content-type"], /text\/csv/); + assert.match(r.headers["content-disposition"], /attachment; filename="query-events-/); + const lines = r.body.split("\r\n"); + assert.match(lines[0], /^id,session_id,agent_id,event_type,tool_name,summary,created_at$/); + assert.equal(lines.length, 2); // header + 1 data row + }); +}); + +describe("GET /api/query/schema", () => { + it("returns the entity/operator allowlist", async () => { + const r = await request("/api/query/schema"); + assert.equal(r.status, 200); + assert.ok(r.body.entities.events); + assert.ok(r.body.entities.agents); + assert.ok(r.body.entities.sessions); + assert.equal(r.body.limits.maxLimit, 1000); + assert.ok(r.body.operators.includes("like")); + }); +}); + +describe("saved queries CRUD", () => { + let savedId; + + it("creates a valid saved query", async () => { + const r = await request("/api/query/saved", { + method: "POST", + body: { + name: "Bash events", + query: { entity: "events", filters: [{ field: "tool_name", op: "eq", value: "Bash" }] }, + tags: ["debug"], + }, + }); + assert.equal(r.status, 201); + assert.equal(r.body.saved.name, "Bash events"); + assert.equal(r.body.saved.entity, "events"); + assert.deepEqual(r.body.saved.tags, ["debug"]); + assert.ok(r.body.saved.query.filters); + savedId = r.body.saved.id; + }); + + it("lists saved queries newest first", async () => { + const r = await request("/api/query/saved"); + assert.equal(r.status, 200); + assert.ok(Array.isArray(r.body.saved)); + assert.ok(r.body.saved.some((s) => s.id === savedId)); + }); + + it("rejects a saved query with an invalid DSL", async () => { + const r = await request("/api/query/saved", { + method: "POST", + body: { name: "bad", query: { entity: "nope" } }, + }); + assert.equal(r.status, 400); + assert.match(r.body.error.message, /Unknown entity/); + }); + + it("rejects a saved query with an empty name", async () => { + const r = await request("/api/query/saved", { + method: "POST", + body: { name: " ", query: { entity: "events" } }, + }); + assert.equal(r.status, 400); + assert.match(r.body.error.message, /name is required/); + }); + + it("deletes a saved query", async () => { + const r = await request(`/api/query/saved/${savedId}`, { method: "DELETE" }); + assert.equal(r.status, 200); + assert.equal(r.body.ok, true); + + const missing = await request(`/api/query/saved/${savedId}`, { method: "DELETE" }); + assert.equal(missing.status, 404); + }); +}); diff --git a/server/db.js b/server/db.js index f95875cd..454a79da 100644 --- a/server/db.js +++ b/server/db.js @@ -380,6 +380,24 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_workflows_session ON workflows(session_id); CREATE INDEX IF NOT EXISTS idx_workflows_status ON workflows(status); + + -- Saved queries for the safe Query Explorer (server/routes/query.js). A saved + -- query is user configuration: the normalized DSL is stored as JSON in the + -- query column, optional tags as a JSON array. The route validates the DSL + -- via server/lib/query-dsl.js before insert, so nothing user-supplied is ever + -- concatenated into SQL. No FK — saved queries are user state, not session + -- data, and survive Clear Data. + CREATE TABLE IF NOT EXISTS saved_queries ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + entity TEXT NOT NULL, + query TEXT NOT NULL, + tags TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + + CREATE INDEX IF NOT EXISTS idx_saved_queries_created ON saved_queries(created_at DESC); `); // Migrate: link agent rows to a workflow run. Workflow inner-agents are already diff --git a/server/index.js b/server/index.js index 5b819a7b..32a80f8c 100644 --- a/server/index.js +++ b/server/index.js @@ -53,6 +53,7 @@ const ccConfigRouter = require("./routes/cc-config"); const runRouter = require("./routes/run"); const alertsRouter = require("./routes/alerts"); const webhooksRouter = require("./routes/webhooks"); +const queryRouter = require("./routes/query"); function createApp() { const app = express(); @@ -77,6 +78,7 @@ function createApp() { app.use("/api/run", runRouter); app.use("/api/alerts", alertsRouter); app.use("/api/webhooks", webhooksRouter); + app.use("/api/query", queryRouter); app.get("/api/openapi.json", (_req, res) => { res.json(openApiSpec); }); diff --git a/server/lib/query-dsl.js b/server/lib/query-dsl.js new file mode 100644 index 00000000..7c9440e7 --- /dev/null +++ b/server/lib/query-dsl.js @@ -0,0 +1,351 @@ +/** + * @file Safe structured-query DSL for the Query Explorer. Pure module (no + * Express, no DB): it validates an untrusted query object against a strict + * field/entity/operator allowlist and compiles it to a fully parameterized + * SQL statement. User VALUES are never interpolated — every value flows through + * a `?` placeholder. The only user-influenced tokens that reach the SQL string + * are column/table names, and those are taken verbatim from the SCHEMA below + * after an exact-match allowlist lookup (never the raw request string). This is + * the single source of truth the route layer (server/routes/query.js) compiles + * against. + * @author Son Nguyen + */ + +// Operators usable on every field type. +const COMMON_OPS = ["eq", "ne", "gt", "gte", "lt", "lte", "in", "is_null", "is_not_null"]; +// `like` is text-only (pattern matching makes no sense on ints). +const TEXT_OPS = [...COMMON_OPS, "like"]; + +const LIMITS = { + maxLimit: 1000, + defaultLimit: 100, + maxInValues: 100, + maxFilters: 50, + maxSort: 10, + // Keep offset a safe integer so the driver never sees an unsafe-int float. + maxOffset: Number.MAX_SAFE_INTEGER, +}; + +// All operators the DSL understands, surfaced via SCHEMA for the client. +const OPERATORS = ["eq", "ne", "gt", "gte", "lt", "lte", "like", "in", "is_null", "is_not_null"]; + +// Per-entity allowlist: field name == real column name (post-migration columns +// only). `ops` is derived from the field type so a field can never be queried +// with an operator that does not make sense for it. +function fieldsFromTypes(typeMap) { + const fields = {}; + for (const [name, type] of Object.entries(typeMap)) { + fields[name] = { type, ops: type === "text" ? TEXT_OPS : COMMON_OPS }; + } + return fields; +} + +const SCHEMA = { + entities: { + events: { + table: "events", + fields: fieldsFromTypes({ + id: "int", + session_id: "text", + agent_id: "text", + event_type: "text", + tool_name: "text", + summary: "text", + created_at: "datetime", + }), + }, + agents: { + table: "agents", + fields: fieldsFromTypes({ + id: "text", + session_id: "text", + name: "text", + type: "text", + subagent_type: "text", + status: "text", + task: "text", + current_tool: "text", + started_at: "datetime", + ended_at: "datetime", + parent_agent_id: "text", + workflow_run_id: "text", + workflow_phase: "text", + updated_at: "datetime", + awaiting_input_since: "datetime", + }), + }, + sessions: { + table: "sessions", + fields: fieldsFromTypes({ + id: "text", + name: "text", + status: "text", + cwd: "text", + model: "text", + started_at: "datetime", + ended_at: "datetime", + updated_at: "datetime", + transcript_path: "text", + }), + }, + }, + operators: OPERATORS, + limits: LIMITS, +}; + +const VALUELESS_OPS = new Set(["is_null", "is_not_null"]); +const ALLOWED_TOP_KEYS = new Set(["entity", "filters", "match", "sort", "limit", "offset"]); +const ALLOWED_FILTER_KEYS = new Set(["field", "op", "value"]); +const ALLOWED_SORT_KEYS = new Set(["field", "dir"]); + +// Reject any key on `obj` outside `allowed`, mirroring the strict top-level +// check so a smuggled/typo'd key fails loudly instead of being silently ignored. +function checkKeys(obj, allowed, label) { + for (const key of Object.keys(obj)) { + if (!allowed.has(key)) return `Unknown key "${key}" in ${label}.`; + } + return null; +} + +// SQL comparison operator for each DSL op (only those that take a value here). +const SQL_OP = { + eq: "=", + ne: "!=", + gt: ">", + gte: ">=", + lt: "<", + lte: "<=", + like: "LIKE", +}; + +function err(message) { + return { ok: false, error: message }; +} + +// True when `value` matches the column type. datetime is carried as an ISO +// string, so it validates as text (a finite range check would reject valid +// open-ended timestamps and add no safety, since the value is parameterized). +function valueMatchesType(type, value) { + if (type === "int") return typeof value === "number" && Number.isFinite(value); + // text + datetime + return typeof value === "string"; +} + +/** + * Validate + normalize an untrusted query object. + * @param {*} raw The request body. + * @returns {{ok:true, query:object}|{ok:false, error:string}} + */ +function validateQuery(raw) { + if (raw == null || typeof raw !== "object" || Array.isArray(raw)) { + return err("Query must be an object."); + } + + // Reject unknown top-level keys so typos / smuggled fields fail loudly. + for (const key of Object.keys(raw)) { + if (!ALLOWED_TOP_KEYS.has(key)) { + return err(`Unknown query key "${key}".`); + } + } + + const entityName = raw.entity; + if ( + typeof entityName !== "string" || + !Object.prototype.hasOwnProperty.call(SCHEMA.entities, entityName) + ) { + return err(`Unknown entity "${entityName}".`); + } + const entity = SCHEMA.entities[entityName]; + + // ----- filters ----- + if (raw.filters != null && !Array.isArray(raw.filters)) { + return err("filters must be an array."); + } + const rawFilters = raw.filters || []; + if (rawFilters.length > LIMITS.maxFilters) { + return err(`Too many filters (max ${LIMITS.maxFilters}).`); + } + const filters = []; + for (let i = 0; i < rawFilters.length; i++) { + const f = rawFilters[i]; + if (f == null || typeof f !== "object" || Array.isArray(f)) { + return err(`filters[${i}] must be an object.`); + } + const badKey = checkKeys(f, ALLOWED_FILTER_KEYS, `filters[${i}]`); + if (badKey) return err(badKey); + const { field, op, value } = f; + const fieldDef = + typeof field === "string" && Object.prototype.hasOwnProperty.call(entity.fields, field) + ? entity.fields[field] + : null; + if (!fieldDef) { + return err(`Unknown field "${field}" for entity "${entityName}".`); + } + if (typeof op !== "string" || !OPERATORS.includes(op)) { + return err(`Unknown operator "${op}".`); + } + if (!fieldDef.ops.includes(op)) { + return err(`Operator "${op}" is not allowed on ${fieldDef.type} field "${field}".`); + } + + if (VALUELESS_OPS.has(op)) { + // is_null / is_not_null take no value. + filters.push({ field, op }); + continue; + } + + if (op === "in") { + if (!Array.isArray(value) || value.length === 0) { + return err(`Operator "in" on "${field}" requires a non-empty array value.`); + } + if (value.length > LIMITS.maxInValues) { + return err(`Operator "in" on "${field}" accepts at most ${LIMITS.maxInValues} values.`); + } + for (const v of value) { + if (!valueMatchesType(fieldDef.type, v)) { + return err(`A value in the "in" list for "${field}" has the wrong type.`); + } + } + filters.push({ field, op, value: value.slice() }); + continue; + } + + // Scalar comparison / like operators. + if (!valueMatchesType(fieldDef.type, value)) { + return err( + `Value for "${field}" must be a ${fieldDef.type === "int" ? "number" : "string"}.` + ); + } + filters.push({ field, op, value }); + } + + // ----- match ----- + let match = "and"; + if (raw.match != null) { + if (raw.match !== "and" && raw.match !== "or") { + return err('match must be "and" or "or".'); + } + match = raw.match; + } + + // ----- sort ----- + const sort = []; + if (raw.sort != null) { + if (!Array.isArray(raw.sort)) return err("sort must be an array."); + if (raw.sort.length > LIMITS.maxSort) { + return err(`Too many sort fields (max ${LIMITS.maxSort}).`); + } + for (let i = 0; i < raw.sort.length; i++) { + const s = raw.sort[i]; + if (s == null || typeof s !== "object" || Array.isArray(s)) { + return err(`sort[${i}] must be an object.`); + } + const badKey = checkKeys(s, ALLOWED_SORT_KEYS, `sort[${i}]`); + if (badKey) return err(badKey); + if ( + typeof s.field !== "string" || + !Object.prototype.hasOwnProperty.call(entity.fields, s.field) + ) { + return err(`Unknown sort field "${s.field}" for entity "${entityName}".`); + } + const dir = s.dir == null ? "asc" : s.dir; + if (dir !== "asc" && dir !== "desc") { + return err('sort dir must be "asc" or "desc".'); + } + sort.push({ field: s.field, dir }); + } + } + + // ----- limit / offset (clamped, never rejected for being out of range) ----- + let limit = LIMITS.defaultLimit; + if (raw.limit != null) { + if (typeof raw.limit !== "number" || !Number.isFinite(raw.limit)) { + return err("limit must be a number."); + } + limit = Math.min(Math.max(Math.trunc(raw.limit), 1), LIMITS.maxLimit); + } + + let offset = 0; + if (raw.offset != null) { + if (typeof raw.offset !== "number" || !Number.isFinite(raw.offset)) { + return err("offset must be a number."); + } + offset = Math.min(Math.max(Math.trunc(raw.offset), 0), LIMITS.maxOffset); + } + + return { ok: true, query: { entity: entityName, filters, match, sort, limit, offset } }; +} + +/** + * Compile a validated/normalized query to parameterized SQL. + * @param {object} query Output of validateQuery().query. + * @returns {{sql:string, params:Array, countSql:string, countParams:Array, columns:string[]}} + */ +function compile(query) { + const entity = SCHEMA.entities[query.entity]; + const table = entity.table; + // SELECT the full allowlisted column set explicitly (never SELECT *). + const columns = Object.keys(entity.fields); + + const whereClauses = []; + const whereParams = []; + for (const f of query.filters) { + // f.field is an allowlisted key, used verbatim as the column name. + if (f.op === "is_null") { + whereClauses.push(`${f.field} IS NULL`); + } else if (f.op === "is_not_null") { + whereClauses.push(`${f.field} IS NOT NULL`); + } else if (f.op === "in") { + const placeholders = f.value.map(() => "?").join(", "); + whereClauses.push(`${f.field} IN (${placeholders})`); + whereParams.push(...f.value); + } else { + whereClauses.push(`${f.field} ${SQL_OP[f.op]} ?`); + whereParams.push(f.value); + } + } + + const joiner = query.match === "or" ? " OR " : " AND "; + const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(joiner)}` : ""; + + let orderSql = ""; + if (query.sort.length > 0) { + const parts = query.sort.map((s) => `${s.field} ${s.dir === "desc" ? "DESC" : "ASC"}`); + orderSql = ` ORDER BY ${parts.join(", ")}`; + } + + const selectCols = columns.join(", "); + const sql = `SELECT ${selectCols} FROM ${table}${whereSql}${orderSql} LIMIT ? OFFSET ?`; + const params = [...whereParams, query.limit, query.offset]; + + const countSql = `SELECT COUNT(*) as count FROM ${table}${whereSql}`; + const countParams = [...whereParams]; + + return { sql, params, countSql, countParams, columns }; +} + +/** + * Serialize rows to RFC4180 CSV. Fields containing `"`, `,`, CR, or LF are + * wrapped in double quotes with inner quotes doubled. null/undefined → empty. + * @param {string[]} columns Column order for the header + cells. + * @param {object[]} rows + * @returns {string} + */ +function toCsv(columns, rows) { + const escape = (value) => { + if (value == null) return ""; + const s = String(value); + if (/[",\r\n]/.test(s)) { + return `"${s.replace(/"/g, '""')}"`; + } + return s; + }; + + const lines = [columns.map(escape).join(",")]; + for (const row of rows) { + lines.push(columns.map((col) => escape(row[col])).join(",")); + } + return lines.join("\r\n"); +} + +module.exports = { SCHEMA, validateQuery, compile, toCsv }; diff --git a/server/routes/query.js b/server/routes/query.js new file mode 100644 index 00000000..9a155ad3 --- /dev/null +++ b/server/routes/query.js @@ -0,0 +1,161 @@ +/** + * @file Express router for the safe Query Explorer (mounted at /api/query). + * Accepts a structured DSL query, validates it against a strict allowlist + * (server/lib/query-dsl.js), compiles it to fully parameterized SQL, and runs + * it read-only against the dashboard DB. Also exposes the schema for the UI and + * CRUD for saved queries. No user value ever reaches SQL except through a `?` + * placeholder; column/table names come only from the validated allowlist. + * @author Son Nguyen + */ + +const { Router } = require("express"); +const { v4: uuidv4 } = require("uuid"); +const { db } = require("../db"); +const { SCHEMA, validateQuery, compile, toCsv } = require("../lib/query-dsl"); + +const router = Router(); + +// Wrap an async-free handler so any thrown error becomes a structured 500 +// instead of an unhandled crash (matches the repo's structured-error style). +function safe(handler) { + return (req, res) => { + try { + handler(req, res); + } catch (err) { + res.status(500).json({ error: { message: err.message } }); + } + }; +} + +// Parse a saved_queries row's JSON columns back into objects for the response. +function serializeSaved(row) { + let query = null; + let tags = []; + try { + query = JSON.parse(row.query); + } catch { + /* leave as null rather than failing the whole list */ + } + try { + tags = row.tags ? JSON.parse(row.tags) : []; + } catch { + tags = []; + } + return { + id: row.id, + name: row.name, + entity: row.entity, + query, + tags, + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +// POST /api/query/run?format=csv|json — validate + run a structured query. +router.post( + "/run", + safe((req, res) => { + const result = validateQuery(req.body); + if (!result.ok) { + return res.status(400).json({ error: { message: result.error } }); + } + const query = result.query; + const { sql, params, countSql, countParams, columns } = compile(query); + + const start = process.hrtime.bigint(); + const rows = db.prepare(sql).all(...params); + const { count: total } = db.prepare(countSql).get(...countParams); + const tookMs = Number(process.hrtime.bigint() - start) / 1e6; + + const warnings = []; + if (total > query.limit) { + warnings.push(`Result truncated to ${query.limit} rows; refine filters or paginate.`); + } + + const format = req.query.format === "csv" ? "csv" : "json"; + if (format === "csv") { + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + res.setHeader("Content-Type", "text/csv"); + res.setHeader( + "Content-Disposition", + `attachment; filename="query-${query.entity}-${ts}.csv"` + ); + return res.send(toCsv(columns, rows)); + } + + res.json({ + entity: query.entity, + columns, + rows, + total, + limit: query.limit, + offset: query.offset, + truncated: query.offset + rows.length < total, + tookMs, + warnings, + }); + }) +); + +// GET /api/query/schema — the entity/field/operator allowlist for the UI. +router.get( + "/schema", + safe((_req, res) => { + res.json(SCHEMA); + }) +); + +// GET /api/query/saved — list saved queries, newest first. +router.get( + "/saved", + safe((_req, res) => { + const rows = db.prepare("SELECT * FROM saved_queries ORDER BY created_at DESC, id DESC").all(); + res.json({ saved: rows.map(serializeSaved) }); + }) +); + +// POST /api/query/saved — create a saved query. The DSL is validated before +// it is ever persisted, so a saved query can always be re-run safely. +router.post( + "/saved", + safe((req, res) => { + const { name, query, tags } = req.body || {}; + if (typeof name !== "string" || !name.trim()) { + return res.status(400).json({ error: { message: "name is required" } }); + } + const result = validateQuery(query); + if (!result.ok) { + return res.status(400).json({ error: { message: result.error } }); + } + let tagsJson = null; + if (tags != null) { + if (!Array.isArray(tags) || tags.some((t) => typeof t !== "string")) { + return res.status(400).json({ error: { message: "tags must be an array of strings" } }); + } + tagsJson = JSON.stringify(tags); + } + + const id = uuidv4(); + db.prepare( + "INSERT INTO saved_queries (id, name, entity, query, tags) VALUES (?, ?, ?, ?, ?)" + ).run(id, name.trim(), result.query.entity, JSON.stringify(result.query), tagsJson); + + const row = db.prepare("SELECT * FROM saved_queries WHERE id = ?").get(id); + res.status(201).json({ saved: serializeSaved(row) }); + }) +); + +// DELETE /api/query/saved/:id — remove a saved query. +router.delete( + "/saved/:id", + safe((req, res) => { + const info = db.prepare("DELETE FROM saved_queries WHERE id = ?").run(req.params.id); + if (info.changes === 0) { + return res.status(404).json({ error: { message: "Saved query not found" } }); + } + res.json({ ok: true }); + }) +); + +module.exports = router;