diff --git a/docs/audit.md b/docs/audit.md index b3bb0bf..3689da0 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -89,8 +89,8 @@ Denied calls always have a null `upstream_response` since no upstream request wa The dashboard provides two views for audit data: -- **Feed tab** — A real-time stream of tool calls as they happen, powered by Server-Sent Events. Each action card shows the tool name, policy decision, timing, and matched rule. -- **Audit tab** — A searchable, filterable, paginated log of all recorded actions. Click any record to see full details including tool arguments, upstream response, and evidence chain. +- **Feed tab** — A real-time stream of tool calls as they happen, powered by Server-Sent Events. Each action card shows the tool name, policy decision, timing, and matched rule. An **Origin** badge (MCP or the adapter slug) identifies where the call originated. For non-`tool_call` records, a **record-kind chip** (Install Scan, Drift, or Expired) appears alongside the badge. Adapter-origin cards also surface context such as `channel_id` and `sender_id` from the record's metadata. +- **Audit tab** — A searchable, filterable, paginated log of all recorded actions. An **Origin** column shows the enforcement origin (MCP or adapter) with a record-kind chip for non-`tool_call` entries. **Channel ID** and **Sender ID** columns show adapter metadata and are visible at wider viewports. Click any record to see full details including tool arguments, upstream response, and evidence chain. ![Dashboard Audit](./images/dashboard-audit.png) @@ -105,6 +105,10 @@ The dashboard provides two views for audit data: - Upstream HTTP status range (`upstream_status_min` / `upstream_status_max`) - Destructive flag - Dry-run flag +- Origin (`mcp`, `openclaw`, or any adapter slug) +- Record kind (`tool_call`, `drift_event`, `install_scan`, `evaluation_expired`) +- Channel ID (`metadata.channel_id`) +- Sender ID (`metadata.sender_id`) ## CLI Export diff --git a/docs/sideband-api.md b/docs/sideband-api.md index 84d7b84..aa27e11 100644 --- a/docs/sideband-api.md +++ b/docs/sideband-api.md @@ -306,24 +306,28 @@ Searchable, filtered, paginated audit log. **Query parameters:** -| Parameter | Default | Range | Description | -| --------------------- | ------- | -------------- | --------------------------------------------- | -| `limit` | `50` | `[1, 1000]` | Page size. | -| `offset` | `0` | `[0, ∞)` | Number of records to skip. | -| `tool` | — | — | Tool name substring filter (`LIKE %tool%`). | -| `decision` | — | — | Filter by `policy_decision`. | -| `reason` | — | — | Filter by `block_reason`. | -| `blocked` | — | `true`/`false` | Filter by whether `block_reason` is non-null. | -| `session` | — | — | Filter by session ID. | -| `agent` | — | — | Filter by agent ID. | -| `from` | — | ISO 8601 | Lower bound on `created_at` (inclusive). | -| `to` | — | ISO 8601 | Upper bound on `created_at` (inclusive). | -| `upstream_status_min` | — | integer | Minimum upstream HTTP status (inclusive). | -| `upstream_status_max` | — | integer | Maximum upstream HTTP status (inclusive). | -| `destructive` | — | `true`/`false` | Filter by the `flagged_destructive` column. | -| `dry_run` | — | `true`/`false` | Filter by the `dry_run` column. | - -`tool` uses substring matching (`LIKE %value%`). `decision`, `reason`, `session`, and `agent` use exact equality matching. +| Parameter | Default | Range | Description | +| --------------------- | ------- | -------------- | --------------------------------------------------------------------------------------------- | +| `limit` | `50` | `[1, 1000]` | Page size. | +| `offset` | `0` | `[0, ∞)` | Number of records to skip. | +| `tool` | — | — | Tool name substring filter (`LIKE %tool%`). | +| `decision` | — | — | Filter by `policy_decision`. | +| `reason` | — | — | Filter by `block_reason`. | +| `blocked` | — | `true`/`false` | Filter by whether `block_reason` is non-null. | +| `session` | — | — | Filter by session ID. | +| `agent` | — | — | Filter by agent ID. | +| `from` | — | ISO 8601 | Lower bound on `created_at` (inclusive). | +| `to` | — | ISO 8601 | Upper bound on `created_at` (inclusive). | +| `upstream_status_min` | — | integer | Minimum upstream HTTP status (inclusive). | +| `upstream_status_max` | — | integer | Maximum upstream HTTP status (inclusive). | +| `destructive` | — | `true`/`false` | Filter by the `flagged_destructive` column. | +| `dry_run` | — | `true`/`false` | Filter by the `dry_run` column. | +| `origin` | — | — | Filter by enforcement origin (`mcp`, or an adapter slug like `openclaw`). | +| `record_kind` | — | — | Filter by record category (`tool_call`, `drift_event`, `install_scan`, `evaluation_expired`). | +| `channel_id` | — | — | Filter by `metadata.channel_id` (adapter-supplied). | +| `sender_id` | — | — | Filter by `metadata.sender_id` (adapter-supplied). | + +`tool`, `origin`, `channel_id`, and `sender_id` use substring matching (`LIKE %value%`). `decision`, `reason`, `session`, `agent`, and `record_kind` use exact equality matching. **Response (200):** @@ -366,21 +370,25 @@ Bulk export of audit records as a downloadable attachment. **Not a JSON envelope **Query parameters:** -| Parameter | Default | Description | -| --------------------- | ------- | --------------------------------------------- | -| `format` | `json` | `json` or `csv`. | -| `limit` | `10000` | Maximum records. Capped at 10k. | -| `tool` | — | Filter by tool name substring. | -| `decision` | — | Filter by policy decision. | -| `reason` | — | Filter by block reason. | -| `blocked` | — | Filter by whether `block_reason` is non-null. | -| `dry_run` | — | Filter by dry-run records (`true`/`false`). | -| `session` | — | Filter by session ID. | -| `agent` | — | Filter by agent ID. | -| `from` | — | Start time (ISO 8601). | -| `to` | — | End time (ISO 8601). | -| `upstream_status_min` | — | Minimum upstream HTTP status (inclusive). | -| `upstream_status_max` | — | Maximum upstream HTTP status (inclusive). | +| Parameter | Default | Description | +| --------------------- | ------- | --------------------------------------------------------------------------------------------- | +| `format` | `json` | `json` or `csv`. | +| `limit` | `10000` | Maximum records. Capped at 10k. | +| `tool` | — | Filter by tool name substring. | +| `decision` | — | Filter by policy decision. | +| `reason` | — | Filter by block reason. | +| `blocked` | — | Filter by whether `block_reason` is non-null. | +| `dry_run` | — | Filter by dry-run records (`true`/`false`). | +| `session` | — | Filter by session ID. | +| `agent` | — | Filter by agent ID. | +| `from` | — | Start time (ISO 8601). | +| `to` | — | End time (ISO 8601). | +| `upstream_status_min` | — | Minimum upstream HTTP status (inclusive). | +| `upstream_status_max` | — | Maximum upstream HTTP status (inclusive). | +| `origin` | — | Filter by enforcement origin (`mcp`, or an adapter slug like `openclaw`). | +| `record_kind` | — | Filter by record category (`tool_call`, `drift_event`, `install_scan`, `evaluation_expired`). | +| `channel_id` | — | Filter by `metadata.channel_id` (adapter-supplied). | +| `sender_id` | — | Filter by `metadata.sender_id` (adapter-supplied). | See [Audit Trail → Dashboard API Export](./audit.md#dashboard-api-export) for full context and examples. @@ -564,14 +572,14 @@ Server-Sent Events stream of dashboard events. The stream stays open indefinitel **Event types:** -| Event | Payload fields | -| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `heartbeat` | empty `data`. Sent on connect and every `sseHeartbeatMs`. | -| `action` | `id`, `tool_name`, `policy_decision`, `block_reason`, `approval_status`, `session_id`, `agent_id`, `environment`, `timestamp`, `total_duration_ms`, `approval_wait_ms`, `proxy_compute_ms`, `flagged_destructive`, `dry_run`, `matched_rule`, `matched_rule_index` | -| `approval_requested` | `ticket_id`, `tool_name`, `channel`, `requested_at` | -| `approval_resolved` | `ticket_id`, `status`, `resolved_by` (optional), `resolved_at` | -| `approval_notification_failed` | `ticket_id`, `channel`, `phase` (`initial`/`escalation`), `error` | -| `limit_warning` | `key`, `type` (`rate`/`spend`), `current`, `limit`, `utilization` | +| Event | Payload fields | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `heartbeat` | empty `data`. Sent on connect and every `sseHeartbeatMs`. | +| `action` | `id`, `tool_name`, `policy_decision`, `block_reason`, `approval_status`, `session_id`, `agent_id`, `environment`, `timestamp`, `total_duration_ms`, `approval_wait_ms`, `proxy_compute_ms`, `flagged_destructive`, `dry_run`, `matched_rule`, `matched_rule_index`, `origin` (enforcement origin: `mcp` or adapter slug), `record_kind` (`tool_call` / `drift_event` / `install_scan` / `evaluation_expired`) | +| `approval_requested` | `ticket_id`, `tool_name`, `channel`, `requested_at` | +| `approval_resolved` | `ticket_id`, `status`, `resolved_by` (optional), `resolved_at` | +| `approval_notification_failed` | `ticket_id`, `channel`, `phase` (`initial`/`escalation`), `error` | +| `limit_warning` | `key`, `type` (`rate`/`spend`), `current`, `limit`, `utilization` | For `approval_resolved`, `status` is one of `approved`, `denied`, `timeout`, `break_glass`, `client_disconnected`, or `shutdown_cancelled`. diff --git a/packages/dashboard/src/api.test.ts b/packages/dashboard/src/api.test.ts index bca4a0e..621b47e 100644 --- a/packages/dashboard/src/api.test.ts +++ b/packages/dashboard/src/api.test.ts @@ -222,6 +222,21 @@ describe('read endpoints', () => { expect('page' in res).toBe(false) }) + it('fetchAudit serializes origin/record_kind/channel/sender into the query (#16)', async () => { + mockFetch.mockReturnValue(okJson({ data: [], total: 0, limit: 50, offset: 0 })) + await fetchAudit({ + origin: 'openclaw', + record_kind: 'install_scan', + channel: 'C1', + sender: 'U1', + }) + const url = calledUrl() + expect(url).toContain('origin=openclaw') + expect(url).toContain('record_kind=install_scan') + expect(url).toContain('channel_id=C1') + expect(url).toContain('sender_id=U1') + }) + it('fetchAuditRecord encodes ID', async () => { mockFetch.mockReturnValue(okJson({ data: { id: 'abc' } })) await fetchAuditRecord('abc/def') diff --git a/packages/dashboard/src/api.ts b/packages/dashboard/src/api.ts index f7ef3d2..5b926a1 100644 --- a/packages/dashboard/src/api.ts +++ b/packages/dashboard/src/api.ts @@ -121,6 +121,10 @@ export interface AuditParams { blocked?: boolean session?: string agent?: string + origin?: string + record_kind?: string + channel?: string + sender?: string from?: string to?: string destructive?: boolean @@ -141,6 +145,10 @@ export function fetchAudit(params?: AuditParams): Promise { blocked: params?.blocked, session: params?.session, agent: params?.agent, + origin: params?.origin, + record_kind: params?.record_kind, + channel_id: params?.channel, + sender_id: params?.sender, from: params?.from, to: params?.to, destructive: params?.destructive, diff --git a/packages/dashboard/src/app.css b/packages/dashboard/src/app.css index ad1e06b..403037d 100644 --- a/packages/dashboard/src/app.css +++ b/packages/dashboard/src/app.css @@ -1,8 +1,11 @@ @import 'tailwindcss'; -/* Tailwind CSS v4 removed cursor:pointer from buttons. Restore it. */ +/* Tailwind CSS v4 removed cursor:pointer from buttons. Restore it. + Native + + { + setFilter('origin', e.target.value) + }} + className="rounded-md border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-900 placeholder:text-gray-400 focus:border-gray-300 focus:outline-none" + /> + + {/* Row 2: time range + session + export */} @@ -276,6 +311,26 @@ export function AuditFilterBar({ filters, setFilter, setBulkFilters }: AuditFilt className="rounded-md border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-900 placeholder:text-gray-400 focus:border-gray-300 focus:outline-none" /> + { + setFilter('channel', e.target.value) + }} + className="rounded-md border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-900 placeholder:text-gray-400 focus:border-gray-300 focus:outline-none" + /> + + { + setFilter('sender', e.target.value) + }} + className="rounded-md border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-900 placeholder:text-gray-400 focus:border-gray-300 focus:outline-none" + /> + = {}): AuditRecord { flagged_destructive: false, dry_run: false, created_at: '2025-01-15T10:00:00.100Z', + record_kind: 'tool_call', + origin: 'mcp', + metadata: null, } return { ...defaults, @@ -160,4 +163,24 @@ describe('AuditTable', () => { fireEvent.change(screen.getByDisplayValue('25'), { target: { value: '50' } }) expect(props.onLimitChange).toHaveBeenCalledWith(50) }) + + it('renders an Origin column with the friendly label and kind chip (#16)', () => { + const props = { + ...defaultProps(), + records: [makeRecord({ origin: 'openclaw', record_kind: 'install_scan' })], + } + render() + expect(screen.getByText('OpenClaw')).toBeTruthy() + expect(screen.getByText('Install Scan')).toBeTruthy() + }) + + it('renders channel_id/sender_id from metadata (#16)', () => { + const props = { + ...defaultProps(), + records: [makeRecord({ metadata: { channel_id: 'C123', sender_id: 'U1' } })], + } + render() + expect(screen.getByText('C123')).toBeTruthy() + expect(screen.getByText('U1')).toBeTruthy() + }) }) diff --git a/packages/dashboard/src/components/AuditTable.tsx b/packages/dashboard/src/components/AuditTable.tsx index 05fbf47..8ddf86b 100644 --- a/packages/dashboard/src/components/AuditTable.tsx +++ b/packages/dashboard/src/components/AuditTable.tsx @@ -1,5 +1,6 @@ import type { AuditRecord } from '../types' import { formatTimestamp, truncateId, formatLatency } from '../utils' +import { OriginBadge } from './OriginBadge' import { PolicyBadge } from './PolicyBadge' const PAGE_SIZES = [10, 25, 50] as const @@ -61,12 +62,21 @@ export function AuditTable({ Tool + + Origin + Decision Session / Agent + + Channel + + + Sender + Duration @@ -92,6 +102,9 @@ export function AuditTable({ {record.tool_name} + + + {truncateId(record.agent_id ?? record.session_id)} + + {typeof record.metadata?.channel_id === 'string' + ? record.metadata.channel_id + : '—'} + + + {typeof record.metadata?.sender_id === 'string' + ? record.metadata.sender_id + : '—'} + {formatLatency(record.total_duration_ms)} diff --git a/packages/dashboard/src/components/OriginBadge.test.tsx b/packages/dashboard/src/components/OriginBadge.test.tsx new file mode 100644 index 0000000..ab466f5 --- /dev/null +++ b/packages/dashboard/src/components/OriginBadge.test.tsx @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { OriginBadge } from './OriginBadge' + +describe('OriginBadge', () => { + it('renders the friendly origin label', () => { + render() + expect(screen.getByText('OpenClaw')).toBeTruthy() + }) + it('renders a kind chip for non-tool_call kinds', () => { + render() + expect(screen.getByText('Install Scan')).toBeTruthy() + }) + it('renders no kind chip for tool_call', () => { + render() + expect(screen.queryByText('Install Scan')).toBeNull() + }) +}) diff --git a/packages/dashboard/src/components/OriginBadge.tsx b/packages/dashboard/src/components/OriginBadge.tsx new file mode 100644 index 0000000..6b51bc6 --- /dev/null +++ b/packages/dashboard/src/components/OriginBadge.tsx @@ -0,0 +1,34 @@ +import { memo } from 'react' +import { formatOrigin, formatRecordKind } from '../origin' + +interface OriginBadgeProps { + origin: string + recordKind: string +} + +const ORIGIN_COLORS: Record = { + mcp: 'bg-gray-100 text-gray-600 ring-gray-500/20', +} +const ORIGIN_DEFAULT = 'bg-indigo-50 text-indigo-700 ring-indigo-600/20' // adapters +const KIND_COLORS = 'bg-amber-50 text-amber-700 ring-amber-600/20' + +export const OriginBadge = memo(function OriginBadge({ origin, recordKind }: OriginBadgeProps) { + const originColors = ORIGIN_COLORS[origin] ?? ORIGIN_DEFAULT + const kindLabel = formatRecordKind(recordKind) + return ( +
+ + {formatOrigin(origin)} + + {kindLabel && ( + + {kindLabel} + + )} +
+ ) +}) diff --git a/packages/dashboard/src/origin.test.ts b/packages/dashboard/src/origin.test.ts new file mode 100644 index 0000000..5e48e4f --- /dev/null +++ b/packages/dashboard/src/origin.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest' +import { formatOrigin, formatRecordKind } from './origin' + +describe('formatOrigin', () => { + it('maps known origins to friendly labels', () => { + expect(formatOrigin('mcp')).toBe('MCP') + expect(formatOrigin('openclaw')).toBe('OpenClaw') + }) + it('falls back to the raw slug for unknown origins', () => { + expect(formatOrigin('some_adapter')).toBe('some_adapter') + }) +}) + +describe('formatRecordKind', () => { + it('returns null for tool_call (no chip)', () => { + expect(formatRecordKind('tool_call')).toBeNull() + }) + it('labels non-default kinds', () => { + expect(formatRecordKind('install_scan')).toBe('Install Scan') + expect(formatRecordKind('drift_event')).toBe('Drift') + expect(formatRecordKind('evaluation_expired')).toBe('Expired') + }) + it('falls back to the raw kind for unknown values', () => { + expect(formatRecordKind('something_new')).toBe('something_new') + }) +}) diff --git a/packages/dashboard/src/origin.ts b/packages/dashboard/src/origin.ts new file mode 100644 index 0000000..9d9fa5b --- /dev/null +++ b/packages/dashboard/src/origin.ts @@ -0,0 +1,27 @@ +// --------------------------------------------------------------------------- +// Origin + record-kind display helpers (#16). +// origin is a constrained adapter slug (^[a-z0-9_-]{1,64}$); map known values +// to friendly labels and fall back to the raw slug for forward compatibility. +// --------------------------------------------------------------------------- + +const ORIGIN_LABELS: Record = { + mcp: 'MCP', + openclaw: 'OpenClaw', +} + +export function formatOrigin(origin: string): string { + return ORIGIN_LABELS[origin] ?? origin +} + +// record_kind is orthogonal to the decision badge. tool_call is the default +// and renders no chip; the other kinds get a short label. +const RECORD_KIND_LABELS: Record = { + install_scan: 'Install Scan', + drift_event: 'Drift', + evaluation_expired: 'Expired', +} + +export function formatRecordKind(kind: string): string | null { + if (kind === 'tool_call') return null + return RECORD_KIND_LABELS[kind] ?? kind +} diff --git a/packages/dashboard/src/pages/AuditPage.test.tsx b/packages/dashboard/src/pages/AuditPage.test.tsx index 673b5d5..a765399 100644 --- a/packages/dashboard/src/pages/AuditPage.test.tsx +++ b/packages/dashboard/src/pages/AuditPage.test.tsx @@ -52,6 +52,9 @@ function makeAuditRecord(overrides: Partial = {}): AuditRecord { flagged_destructive: false, dry_run: false, created_at: '2025-01-15T10:00:00.100Z', + record_kind: 'tool_call', + origin: 'mcp', + metadata: null, } return { ...defaults, diff --git a/packages/dashboard/src/pages/FeedPage.test.tsx b/packages/dashboard/src/pages/FeedPage.test.tsx index 1d8d32a..ade87c4 100644 --- a/packages/dashboard/src/pages/FeedPage.test.tsx +++ b/packages/dashboard/src/pages/FeedPage.test.tsx @@ -75,6 +75,8 @@ describe('FeedPage', () => { matched_rule: null, matched_rule_index: null, environment: null, + record_kind: 'tool_call', + origin: 'mcp', tool_input: {}, upstream_response: null, upstream_error: null, @@ -83,6 +85,7 @@ describe('FeedPage', () => { approved_by: null, evidence_chain: null, created_at: new Date().toISOString(), + metadata: null, }, ], total: 1, diff --git a/packages/dashboard/src/types.ts b/packages/dashboard/src/types.ts index fbc1a57..9b3b1e0 100644 --- a/packages/dashboard/src/types.ts +++ b/packages/dashboard/src/types.ts @@ -48,6 +48,9 @@ export interface AuditRecord { readonly flagged_destructive: boolean readonly dry_run: boolean readonly created_at: string + readonly record_kind: 'tool_call' | 'drift_event' | 'install_scan' | 'evaluation_expired' + readonly origin: string + readonly metadata: Record | null } export interface AuditListResponse { @@ -184,6 +187,10 @@ export interface ActionEvent { readonly dry_run: boolean readonly matched_rule: string | null readonly matched_rule_index: number | null + // record_kind + origin let the live Feed render an origin/kind chip. metadata is + // intentionally omitted here — the Feed fetches the full AuditRecord on card expand. + readonly record_kind: AuditRecord['record_kind'] + readonly origin: string } export interface ApprovalRequestedEvent { diff --git a/packages/dashboard/src/useAuditQuery.test.ts b/packages/dashboard/src/useAuditQuery.test.ts index 8ac966c..c5b1244 100644 --- a/packages/dashboard/src/useAuditQuery.test.ts +++ b/packages/dashboard/src/useAuditQuery.test.ts @@ -246,4 +246,52 @@ describe('useAuditQuery', () => { expect(result.current.loading).toBe(false) }) }) + + it('passes origin/record_kind/channel/sender to fetchAudit (#16)', async () => { + const { result } = renderHook(() => useAuditQuery()) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + mockFetchAudit.mockClear() + + act(() => { + result.current.setBulkFilters({ origin: 'openclaw', record_kind: 'install_scan' }) + }) + + // origin is debounced; record_kind (select) is not. Flush the 300ms window so origin reaches fetchAudit. + act(() => { + vi.advanceTimersByTime(300) + }) + + await waitFor(() => { + expect(mockFetchAudit).toHaveBeenLastCalledWith( + expect.objectContaining({ origin: 'openclaw', record_kind: 'install_scan' }), + ) + }) + }) + + it('debounces channel/sender text filters by 300ms (#16)', async () => { + const { result } = renderHook(() => useAuditQuery()) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + mockFetchAudit.mockClear() + + // Change channel filter — should not trigger fetch with channel value immediately + act(() => { + result.current.setFilter('channel', 'C123') + }) + expect(mockFetchAudit).not.toHaveBeenCalledWith(expect.objectContaining({ channel: 'C123' })) + + // Advance 300ms for debounce + act(() => { + vi.advanceTimersByTime(300) + }) + + await waitFor(() => { + expect(mockFetchAudit).toHaveBeenLastCalledWith(expect.objectContaining({ channel: 'C123' })) + }) + }) }) diff --git a/packages/dashboard/src/useAuditQuery.ts b/packages/dashboard/src/useAuditQuery.ts index 6d394e7..b2f2c12 100644 --- a/packages/dashboard/src/useAuditQuery.ts +++ b/packages/dashboard/src/useAuditQuery.ts @@ -16,6 +16,10 @@ export interface AuditFilters { to: string upstream_status_min: string upstream_status_max: string + origin: string + record_kind: string + channel: string + sender: string } export interface UseAuditQueryReturn { @@ -45,6 +49,10 @@ const INITIAL_FILTERS: AuditFilters = { to: '', upstream_status_min: '', upstream_status_max: '', + origin: '', + record_kind: '', + channel: '', + sender: '', } function parseOptionalInt(value: string): number | undefined { @@ -61,6 +69,9 @@ export function useAuditQuery(): UseAuditQueryReturn { const [debouncedTool, setDebouncedTool] = useState('') const [debouncedSession, setDebouncedSession] = useState('') + const [debouncedOrigin, setDebouncedOrigin] = useState('') + const [debouncedChannel, setDebouncedChannel] = useState('') + const [debouncedSender, setDebouncedSender] = useState('') const [data, setData] = useState(null) const [loading, setLoading] = useState(true) @@ -85,6 +96,33 @@ export function useAuditQuery(): UseAuditQueryReturn { } }, [filters.session]) + useEffect(() => { + const id = setTimeout(() => { + setDebouncedOrigin(filters.origin) + }, 300) + return () => { + clearTimeout(id) + } + }, [filters.origin]) + + useEffect(() => { + const id = setTimeout(() => { + setDebouncedChannel(filters.channel) + }, 300) + return () => { + clearTimeout(id) + } + }, [filters.channel]) + + useEffect(() => { + const id = setTimeout(() => { + setDebouncedSender(filters.sender) + }, 300) + return () => { + clearTimeout(id) + } + }, [filters.sender]) + // -- Fetch on filter/page change ------------------------------------------ useEffect(() => { let canceled = false @@ -102,6 +140,10 @@ export function useAuditQuery(): UseAuditQueryReturn { dry_run: outcomeParams.dry_run, upstream_status_min: parseOptionalInt(filters.upstream_status_min), upstream_status_max: parseOptionalInt(filters.upstream_status_max), + origin: debouncedOrigin || undefined, + record_kind: filters.record_kind || undefined, + channel: debouncedChannel || undefined, + sender: debouncedSender || undefined, offset: (page - 1) * limit, limit, }) @@ -129,6 +171,10 @@ export function useAuditQuery(): UseAuditQueryReturn { filters.to, filters.upstream_status_min, filters.upstream_status_max, + debouncedOrigin, + filters.record_kind, + debouncedChannel, + debouncedSender, page, limit, refreshToken, diff --git a/packages/proxy/src/__tests__/e2e-full.test.ts b/packages/proxy/src/__tests__/e2e-full.test.ts index 776e0a5..3409205 100644 --- a/packages/proxy/src/__tests__/e2e-full.test.ts +++ b/packages/proxy/src/__tests__/e2e-full.test.ts @@ -162,6 +162,8 @@ beforeAll(async () => { dry_run: record.dry_run, matched_rule: record.matched_rule, matched_rule_index: record.matched_rule_index, + record_kind: record.record_kind, + origin: record.origin, }) }, }) diff --git a/packages/proxy/src/audit/store.test.ts b/packages/proxy/src/audit/store.test.ts index 499ae29..cb640c2 100644 --- a/packages/proxy/src/audit/store.test.ts +++ b/packages/proxy/src/audit/store.test.ts @@ -775,6 +775,102 @@ CREATE TABLE IF NOT EXISTS audit_records ( }) }) + // ------------------------------------------------------------------------- + // List – metadata filters + // ------------------------------------------------------------------------- + + describe('list – metadata filters', () => { + it('filters by metadata.channel_id – positive match', () => { + const store = createStore() + store.insert( + makeRecord({ origin: 'openclaw', metadata: { channel_id: 'C123', sender_id: 'U1' } }), + ) + store.insert( + makeRecord({ origin: 'openclaw', metadata: { channel_id: 'C999', sender_id: 'U2' } }), + ) + store.insert(makeRecord({ origin: 'mcp', metadata: null })) + + const result = store.list({ channel_id: 'C123' }) + expect(result.total).toBe(1) + expect(result.records[0]?.metadata).toEqual({ channel_id: 'C123', sender_id: 'U1' }) + }) + + it('filters by metadata.channel_id – excludes records with null metadata', () => { + const store = createStore() + store.insert( + makeRecord({ origin: 'openclaw', metadata: { channel_id: 'C123', sender_id: 'U1' } }), + ) + store.insert(makeRecord({ origin: 'mcp', metadata: null })) + + // The mcp record has no channel_id in metadata; filtering by C999 must return nothing + const result = store.list({ channel_id: 'C999' }) + expect(result.total).toBe(0) + }) + + it('filters by metadata.sender_id', () => { + const store = createStore() + store.insert( + makeRecord({ origin: 'openclaw', metadata: { channel_id: 'C1', sender_id: 'U_alice' } }), + ) + store.insert( + makeRecord({ origin: 'openclaw', metadata: { channel_id: 'C1', sender_id: 'U_bob' } }), + ) + + const result = store.list({ sender_id: 'U_alice' }) + expect(result.total).toBe(1) + expect(result.records[0]?.metadata).toMatchObject({ sender_id: 'U_alice' }) + }) + + it('combines channel_id and sender_id filters (AND composition)', () => { + const store = createStore() + const aliceId = store.insert( + makeRecord({ origin: 'openclaw', metadata: { channel_id: 'C1', sender_id: 'U_alice' } }), + ) + const bobId = store.insert( + makeRecord({ origin: 'openclaw', metadata: { channel_id: 'C1', sender_id: 'U_bob' } }), + ) + // Insert a bob record in a different channel to confirm channel filter is respected + store.insert( + makeRecord({ origin: 'openclaw', metadata: { channel_id: 'C2', sender_id: 'U_bob' } }), + ) + + const result = store.list({ channel_id: 'C1', sender_id: 'U_bob' }) + expect(result.total).toBe(1) + expect(result.records[0]?.id).toBe(bobId) + // alice's record must not appear + expect(result.records.find((r) => r.id === aliceId)).toBeUndefined() + }) + + it('matches origin by substring (partial input narrows as you type)', () => { + const store = createStore() + store.insert(makeRecord({ origin: 'openclaw' })) + store.insert(makeRecord({ origin: 'mcp' })) + + // A partial slug ("open") must match "openclaw" — substring, not exact. + const result = store.list({ origin: 'open' }) + expect(result.total).toBe(1) + expect(result.records[0]?.origin).toBe('openclaw') + }) + + it('matches metadata.channel_id/sender_id by substring', () => { + const store = createStore() + store.insert( + makeRecord({ + origin: 'openclaw', + metadata: { channel_id: 'C-eng-releases', sender_id: 'U-alice' }, + }), + ) + store.insert( + makeRecord({ origin: 'openclaw', metadata: { channel_id: 'C-ops', sender_id: 'U-bob' } }), + ) + + expect(store.list({ channel_id: 'eng' }).total).toBe(1) + expect(store.list({ sender_id: 'alice' }).records[0]?.metadata).toMatchObject({ + sender_id: 'U-alice', + }) + }) + }) + // ------------------------------------------------------------------------- // Close // ------------------------------------------------------------------------- diff --git a/packages/proxy/src/audit/store.ts b/packages/proxy/src/audit/store.ts index d8880c7..6a67b77 100644 --- a/packages/proxy/src/audit/store.ts +++ b/packages/proxy/src/audit/store.ts @@ -200,8 +200,16 @@ function buildWhereClause(filters: AuditQueryFilters): { params.push(filters.record_kind) } if (filters.origin !== undefined) { - conditions.push('origin = ?') - params.push(filters.origin) + conditions.push('origin LIKE ?') + params.push(`%${filters.origin}%`) + } + if (filters.channel_id !== undefined) { + conditions.push("json_extract(metadata, '$.channel_id') LIKE ?") + params.push(`%${filters.channel_id}%`) + } + if (filters.sender_id !== undefined) { + conditions.push("json_extract(metadata, '$.sender_id') LIKE ?") + params.push(`%${filters.sender_id}%`) } if (filters.session_id !== undefined) { conditions.push('session_id = ?') diff --git a/packages/proxy/src/audit/types.ts b/packages/proxy/src/audit/types.ts index 5f26554..0234e6b 100644 --- a/packages/proxy/src/audit/types.ts +++ b/packages/proxy/src/audit/types.ts @@ -110,6 +110,10 @@ export interface AuditQueryFilters { readonly record_kind?: string /** Filter by enforcement origin (e.g. 'mcp', 'openclaw'). */ readonly origin?: string + /** Filter by metadata.channel_id (adapter-supplied; JSON-extracted, substring match). */ + readonly channel_id?: string + /** Filter by metadata.sender_id (adapter-supplied; JSON-extracted, substring match). */ + readonly sender_id?: string /** Include only records where upstream HTTP status is >= this value. */ readonly upstream_status_min?: number /** Include only records where upstream HTTP status is <= this value. */ diff --git a/packages/proxy/src/cli.ts b/packages/proxy/src/cli.ts index 751a855..1ff56d5 100644 --- a/packages/proxy/src/cli.ts +++ b/packages/proxy/src/cli.ts @@ -343,6 +343,8 @@ async function startCommand(configPath: string, options: StartOptions): Promise< dry_run: record.dry_run, matched_rule: record.matched_rule, matched_rule_index: record.matched_rule_index, + record_kind: record.record_kind, + origin: record.origin, }) }, }) diff --git a/packages/proxy/src/dashboard/api.test.ts b/packages/proxy/src/dashboard/api.test.ts index 7ec41a8..301f7de 100644 --- a/packages/proxy/src/dashboard/api.test.ts +++ b/packages/proxy/src/dashboard/api.test.ts @@ -354,6 +354,54 @@ describe('GET /api/audit', () => { expect(body.limit).toBe(50) expect(body.data).toHaveLength(1) }) + + it('filters by origin and record_kind (#16)', async () => { + const { get, auditStore } = setup() + cleanup.push(auditStore) + insertAuditRecord(auditStore, { + tool_name: 'kept', + origin: 'openclaw', + record_kind: 'install_scan', + }) + insertAuditRecord(auditStore, { + tool_name: 'wrong_kind', + origin: 'openclaw', + record_kind: 'tool_call', + }) + insertAuditRecord(auditStore, { + tool_name: 'wrong_origin', + origin: 'mcp', + record_kind: 'install_scan', + }) + + const res = await get('/api/audit?origin=openclaw&record_kind=install_scan') + const body = (await res.json()) as { total: number; data: AuditRecord[] } + expect(res.status).toBe(200) + expect(body.total).toBe(1) + expect(body.data.map((r) => r.tool_name)).toEqual(['kept']) + }) + + it('filters by channel_id and sender_id (#16)', async () => { + const { get, auditStore } = setup() + cleanup.push(auditStore) + insertAuditRecord(auditStore, { + tool_name: 'kept', + origin: 'openclaw', + metadata: { channel_id: 'C123', sender_id: 'U1' }, + }) + insertAuditRecord(auditStore, { + tool_name: 'other_sender', + origin: 'openclaw', + metadata: { channel_id: 'C123', sender_id: 'U2' }, + }) + insertAuditRecord(auditStore, { tool_name: 'mcp_no_meta', origin: 'mcp', metadata: null }) + + const res = await get('/api/audit?channel_id=C123&sender_id=U1') + const body = (await res.json()) as { total: number; data: AuditRecord[] } + expect(res.status).toBe(200) + expect(body.total).toBe(1) + expect(body.data.map((r) => r.tool_name)).toEqual(['kept']) + }) }) // --------------------------------------------------------------------------- @@ -432,6 +480,20 @@ describe('GET /api/audit/export', () => { expect(body).toHaveLength(1) expect(at(body, 0).tool_name).toBe('fail') }) + + it('export filters by origin (#16)', async () => { + const { get, auditStore } = setup() + cleanup.push(auditStore) + insertAuditRecord(auditStore, { tool_name: 'adapter_call', origin: 'openclaw' }) + insertAuditRecord(auditStore, { tool_name: 'mcp_call', origin: 'mcp' }) + + const res = await get('/api/audit/export?format=json&origin=openclaw') + expect(res.status).toBe(200) + const rows = (await res.json()) as AuditRecord[] + const names = rows.map((r) => r.tool_name) + expect(names).toContain('adapter_call') + expect(names).not.toContain('mcp_call') + }) }) // --------------------------------------------------------------------------- @@ -827,6 +889,8 @@ describe('GET /api/events', () => { dry_run: false, matched_rule: null, matched_rule_index: null, + record_kind: 'tool_call', + origin: 'mcp', }) const eventData = await readWithTimeout(1000) diff --git a/packages/proxy/src/dashboard/api.ts b/packages/proxy/src/dashboard/api.ts index 77db315..37e37ad 100644 --- a/packages/proxy/src/dashboard/api.ts +++ b/packages/proxy/src/dashboard/api.ts @@ -98,6 +98,10 @@ const auditExportQuerySchema = z.object({ to: optionalQueryString, upstream_status_min: optionalQueryInt, upstream_status_max: optionalQueryInt, + origin: optionalQueryString, + record_kind: optionalQueryString, + channel_id: optionalQueryString, + sender_id: optionalQueryString, }) const auditQuerySchema = z.object({ @@ -115,6 +119,10 @@ const auditQuerySchema = z.object({ dry_run: queryBoolean, upstream_status_min: optionalQueryInt, upstream_status_max: optionalQueryInt, + origin: optionalQueryString, + record_kind: optionalQueryString, + channel_id: optionalQueryString, + sender_id: optionalQueryString, }) const analyticsQuerySchema = z.object({ @@ -409,6 +417,10 @@ export function createDashboardAppWithLifecycle( to: query.to, upstream_status_min: query.upstream_status_min, upstream_status_max: query.upstream_status_max, + origin: query.origin, + record_kind: query.record_kind, + channel_id: query.channel_id, + sender_id: query.sender_id, } const result = auditStore.list(filters, { limit, order: 'asc' }) @@ -457,6 +469,10 @@ export function createDashboardAppWithLifecycle( dry_run: query.dry_run, upstream_status_min: query.upstream_status_min, upstream_status_max: query.upstream_status_max, + origin: query.origin, + record_kind: query.record_kind, + channel_id: query.channel_id, + sender_id: query.sender_id, } const result = auditStore.list(filters, { limit, offset, order: 'desc' }) diff --git a/packages/proxy/src/dashboard/event-bus.test.ts b/packages/proxy/src/dashboard/event-bus.test.ts index e4274a0..f39948b 100644 --- a/packages/proxy/src/dashboard/event-bus.test.ts +++ b/packages/proxy/src/dashboard/event-bus.test.ts @@ -24,6 +24,8 @@ function makeActionEvent(overrides: Partial = {}): ActionEvent { dry_run: false, matched_rule: null, matched_rule_index: null, + record_kind: 'tool_call', + origin: 'mcp', } return { ...defaults, @@ -148,6 +150,16 @@ describe('DashboardEventBus', () => { expect(count).toBe(1) // No new event after unsubscribe }) + it('action event carries origin and record_kind (#16)', () => { + bus = new DashboardEventBus() + const received: ActionEvent[] = [] + bus.on('action', (e) => received.push(e)) + bus.emit('action', makeActionEvent({ origin: 'openclaw', record_kind: 'install_scan' })) + const evt = received[0] + expect(evt?.origin).toBe('openclaw') + expect(evt?.record_kind).toBe('install_scan') + }) + it('close() removes all listeners', () => { bus = new DashboardEventBus() let count = 0 diff --git a/packages/proxy/src/dashboard/event-bus.ts b/packages/proxy/src/dashboard/event-bus.ts index 0a2b1da..002c3cb 100644 --- a/packages/proxy/src/dashboard/event-bus.ts +++ b/packages/proxy/src/dashboard/event-bus.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'node:events' +import type { AuditRecord } from '../audit/types.js' // --------------------------------------------------------------------------- // DashboardEventBus — typed event emitter for real-time dashboard updates. @@ -27,6 +28,8 @@ export interface ActionEvent { readonly dry_run: boolean readonly matched_rule: string | null readonly matched_rule_index: number | null + readonly record_kind: AuditRecord['record_kind'] + readonly origin: string } /** Payload for an approval_requested event. */