From 3dab1a43281d0de306eb8e27d4fda7841a610f37 Mon Sep 17 00:00:00 2001 From: olivrg Date: Mon, 15 Jun 2026 19:56:40 +0100 Subject: [PATCH 01/14] feat(audit): filter audit records by metadata.channel_id/sender_id (#16) --- docs/audit.md | 4 ++ packages/proxy/src/audit/store.test.ts | 67 ++++++++++++++++++++++++++ packages/proxy/src/audit/store.ts | 8 +++ packages/proxy/src/audit/types.ts | 4 ++ 4 files changed, 83 insertions(+) diff --git a/docs/audit.md b/docs/audit.md index b3bb0bf..0d47210 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -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/packages/proxy/src/audit/store.test.ts b/packages/proxy/src/audit/store.test.ts index 499ae29..186acba 100644 --- a/packages/proxy/src/audit/store.test.ts +++ b/packages/proxy/src/audit/store.test.ts @@ -775,6 +775,73 @@ 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() + }) + }) + // ------------------------------------------------------------------------- // Close // ------------------------------------------------------------------------- diff --git a/packages/proxy/src/audit/store.ts b/packages/proxy/src/audit/store.ts index d8880c7..146a087 100644 --- a/packages/proxy/src/audit/store.ts +++ b/packages/proxy/src/audit/store.ts @@ -203,6 +203,14 @@ function buildWhereClause(filters: AuditQueryFilters): { conditions.push('origin = ?') params.push(filters.origin) } + if (filters.channel_id !== undefined) { + conditions.push("json_extract(metadata, '$.channel_id') = ?") + params.push(filters.channel_id) + } + if (filters.sender_id !== undefined) { + conditions.push("json_extract(metadata, '$.sender_id') = ?") + params.push(filters.sender_id) + } if (filters.session_id !== undefined) { conditions.push('session_id = ?') params.push(filters.session_id) diff --git a/packages/proxy/src/audit/types.ts b/packages/proxy/src/audit/types.ts index 5f26554..d96e35f 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, exact match). */ + readonly channel_id?: string + /** Filter by metadata.sender_id (adapter-supplied; JSON-extracted, exact 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. */ From 65476fb950501394e22bf615b2e297ef6a0eaa95 Mon Sep 17 00:00:00 2001 From: olivrg Date: Mon, 15 Jun 2026 20:11:19 +0100 Subject: [PATCH 02/14] feat(dashboard-api): expose origin/record_kind/channel_id/sender_id audit filters (#16) --- docs/sideband-api.md | 74 +++++++++++++----------- packages/proxy/src/dashboard/api.test.ts | 62 ++++++++++++++++++++ packages/proxy/src/dashboard/api.ts | 16 +++++ 3 files changed, 119 insertions(+), 33 deletions(-) diff --git a/docs/sideband-api.md b/docs/sideband-api.md index 84d7b84..cf2ac52 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` uses substring matching (`LIKE %value%`). `decision`, `reason`, `session`, `agent`, `origin`, `record_kind`, `channel_id`, and `sender_id` 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. diff --git a/packages/proxy/src/dashboard/api.test.ts b/packages/proxy/src/dashboard/api.test.ts index 7ec41a8..6f6b0e0 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') + }) }) // --------------------------------------------------------------------------- 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' }) From cf77400ba5b2501e29f8c400cc2dbe1611a415d4 Mon Sep 17 00:00:00 2001 From: olivrg Date: Mon, 15 Jun 2026 20:29:15 +0100 Subject: [PATCH 03/14] feat(dashboard-sse): carry origin/record_kind on action event (#16) --- docs/sideband-api.md | 16 ++++++++-------- packages/proxy/src/__tests__/e2e-full.test.ts | 2 ++ packages/proxy/src/cli.ts | 2 ++ packages/proxy/src/dashboard/api.test.ts | 2 ++ packages/proxy/src/dashboard/event-bus.test.ts | 12 ++++++++++++ packages/proxy/src/dashboard/event-bus.ts | 3 +++ 6 files changed, 29 insertions(+), 8 deletions(-) diff --git a/docs/sideband-api.md b/docs/sideband-api.md index cf2ac52..04fe9e8 100644 --- a/docs/sideband-api.md +++ b/docs/sideband-api.md @@ -572,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/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/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 6f6b0e0..301f7de 100644 --- a/packages/proxy/src/dashboard/api.test.ts +++ b/packages/proxy/src/dashboard/api.test.ts @@ -889,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/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. */ From 4c181a53f28b02eab77d07603574058b168d1c97 Mon Sep 17 00:00:00 2001 From: olivrg Date: Mon, 15 Jun 2026 20:49:36 +0100 Subject: [PATCH 04/14] feat(dashboard): type record_kind/origin on AuditRecord+ActionEvent, metadata on AuditRecord; migrate fixtures (#16) --- packages/dashboard/src/components/ActionCard.test.tsx | 6 +++--- .../dashboard/src/components/AuditDetailPanel.test.tsx | 3 +++ packages/dashboard/src/components/AuditTable.test.tsx | 3 +++ packages/dashboard/src/pages/AuditPage.test.tsx | 3 +++ packages/dashboard/src/types.ts | 7 +++++++ 5 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/dashboard/src/components/ActionCard.test.tsx b/packages/dashboard/src/components/ActionCard.test.tsx index 4d6ab71..007bf32 100644 --- a/packages/dashboard/src/components/ActionCard.test.tsx +++ b/packages/dashboard/src/components/ActionCard.test.tsx @@ -20,6 +20,8 @@ const baseRecord: ActionEvent = { dry_run: false, matched_rule: 'rule-1', matched_rule_index: 1, + record_kind: 'tool_call', + origin: 'mcp', } describe('ActionCard', () => { @@ -89,10 +91,8 @@ describe('ActionCard', () => { upstream_http_status: null, upstream_latency_ms: null, block_reason: null, - session_id: baseRecord.session_id, - agent_id: baseRecord.agent_id, - matched_rule: baseRecord.matched_rule, created_at: new Date().toISOString(), + metadata: null, } render( diff --git a/packages/dashboard/src/components/AuditDetailPanel.test.tsx b/packages/dashboard/src/components/AuditDetailPanel.test.tsx index 490a1a5..681ef23 100644 --- a/packages/dashboard/src/components/AuditDetailPanel.test.tsx +++ b/packages/dashboard/src/components/AuditDetailPanel.test.tsx @@ -33,6 +33,9 @@ function makeRecord(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/components/AuditTable.test.tsx b/packages/dashboard/src/components/AuditTable.test.tsx index 114515f..a734a18 100644 --- a/packages/dashboard/src/components/AuditTable.test.tsx +++ b/packages/dashboard/src/components/AuditTable.test.tsx @@ -33,6 +33,9 @@ function makeRecord(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/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/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 { From 2e811c102bb1ba8457100951d86a78cf768ffe83 Mon Sep 17 00:00:00 2001 From: olivrg Date: Mon, 15 Jun 2026 21:04:02 +0100 Subject: [PATCH 05/14] feat(dashboard): formatOrigin/formatRecordKind display helpers (#16) --- packages/dashboard/src/origin.test.ts | 26 ++++++++++++++++++++++++++ packages/dashboard/src/origin.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 packages/dashboard/src/origin.test.ts create mode 100644 packages/dashboard/src/origin.ts 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 +} From 8f9cfa6f2e88e14b3407253871f7479e4e47b91b Mon Sep 17 00:00:00 2001 From: olivrg Date: Mon, 15 Jun 2026 21:14:41 +0100 Subject: [PATCH 06/14] feat(dashboard): OriginBadge component for origin + record-kind chips (#16) --- .../src/components/OriginBadge.test.tsx | 18 ++++++++++ .../dashboard/src/components/OriginBadge.tsx | 34 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 packages/dashboard/src/components/OriginBadge.test.tsx create mode 100644 packages/dashboard/src/components/OriginBadge.tsx 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} + + )} +
+ ) +}) From a5204d328bb90cb206bfe33cc8415b0c55270103 Mon Sep 17 00:00:00 2001 From: olivrg Date: Mon, 15 Jun 2026 21:21:37 +0100 Subject: [PATCH 07/14] feat(dashboard): thread origin/record_kind/channel/sender through fetchAudit (#16) --- packages/dashboard/src/api.test.ts | 15 +++++++++++++++ packages/dashboard/src/api.ts | 8 ++++++++ 2 files changed, 23 insertions(+) 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, From 142423fcd9fa62c1bf30c616ec9034d6c69ed3c6 Mon Sep 17 00:00:00 2001 From: olivrg Date: Mon, 15 Jun 2026 21:31:41 +0100 Subject: [PATCH 08/14] feat(dashboard): origin/record_kind/channel/sender filter state in useAuditQuery (#16) --- .../src/components/AuditFilterBar.test.tsx | 4 ++ packages/dashboard/src/useAuditQuery.test.ts | 48 +++++++++++++++++++ packages/dashboard/src/useAuditQuery.ts | 46 ++++++++++++++++++ 3 files changed, 98 insertions(+) diff --git a/packages/dashboard/src/components/AuditFilterBar.test.tsx b/packages/dashboard/src/components/AuditFilterBar.test.tsx index 71623e0..7000556 100644 --- a/packages/dashboard/src/components/AuditFilterBar.test.tsx +++ b/packages/dashboard/src/components/AuditFilterBar.test.tsx @@ -17,6 +17,10 @@ function defaultProps() { to: '', upstream_status_min: '', upstream_status_max: '', + origin: '', + record_kind: '', + channel: '', + sender: '', }, setFilter: vi.fn(), setBulkFilters: vi.fn(), 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, From bd78b353f45f14dd886f9351dee9adfc07189563 Mon Sep 17 00:00:00 2001 From: olivrg Date: Mon, 15 Jun 2026 21:43:24 +0100 Subject: [PATCH 09/14] feat(dashboard): origin/kind/channel/sender filter controls + install_denied reason (#16) --- .../src/components/AuditFilterBar.test.tsx | 78 +++++++++++++++++++ .../src/components/AuditFilterBar.tsx | 55 +++++++++++++ 2 files changed, 133 insertions(+) diff --git a/packages/dashboard/src/components/AuditFilterBar.test.tsx b/packages/dashboard/src/components/AuditFilterBar.test.tsx index 7000556..2418ec4 100644 --- a/packages/dashboard/src/components/AuditFilterBar.test.tsx +++ b/packages/dashboard/src/components/AuditFilterBar.test.tsx @@ -27,6 +27,20 @@ function defaultProps() { } as const } +type RenderBarOverrides = Omit[0]>, 'filters'> & { + filters?: Partial[0]['filters']> +} + +function renderBar(overrides: RenderBarOverrides = {}) { + const base = defaultProps() + const props = { + ...base, + ...overrides, + filters: { ...base.filters, ...(overrides.filters ?? {}) }, + } + return render() +} + afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() @@ -180,4 +194,68 @@ describe('AuditFilterBar', () => { fireEvent.click(lastAll) expect(props.setBulkFilters).toHaveBeenCalledWith({ from: '', to: '' }) }) + + it('exposes an Install Denied block-reason option (#16)', () => { + renderBar() + expect(screen.getByRole('option', { name: 'Install Denied' })).toBeTruthy() + }) + + it('renders an origin free-text input and a record-kind select, calling setFilter (#16)', () => { + const setFilter = vi.fn() + renderBar({ setFilter }) + fireEvent.change(screen.getByLabelText('Origin'), { target: { value: 'some_future_adapter' } }) + expect(setFilter).toHaveBeenCalledWith('origin', 'some_future_adapter') + fireEvent.change(screen.getByLabelText('Record Kind'), { target: { value: 'install_scan' } }) + expect(setFilter).toHaveBeenCalledWith('record_kind', 'install_scan') + }) + + it('renders channel and sender free-text inputs, calling setFilter (#16)', () => { + const setFilter = vi.fn() + renderBar({ setFilter }) + fireEvent.change(screen.getByPlaceholderText('Channel ID…'), { target: { value: 'ch-abc' } }) + expect(setFilter).toHaveBeenCalledWith('channel', 'ch-abc') + fireEvent.change(screen.getByPlaceholderText('Sender ID…'), { target: { value: 'user-42' } }) + expect(setFilter).toHaveBeenCalledWith('sender', 'user-42') + }) + + it('includes origin/record_kind/channel_id/sender_id in export URL when set (#16)', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('[]', { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + const createObjectURLMock = vi.fn(() => 'blob:http://localhost/fake') + const revokeObjectURLMock = vi.fn() + vi.stubGlobal('URL', { + ...URL, + createObjectURL: createObjectURLMock, + revokeObjectURL: revokeObjectURLMock, + }) + const clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => undefined) + + renderBar({ + filters: { + origin: 'openclaw', + record_kind: 'install_scan', + channel: 'ch-1', + sender: 'user-1', + }, + }) + fireEvent.click(screen.getByText('Export')) + fireEvent.click(screen.getByText('Export JSON')) + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1) + const calledUrl: string = (fetchMock.mock.calls[0] as [string])[0] + expect(calledUrl).toContain('origin=openclaw') + expect(calledUrl).toContain('record_kind=install_scan') + expect(calledUrl).toContain('channel_id=ch-1') + expect(calledUrl).toContain('sender_id=user-1') + }) + + clickSpy.mockRestore() + }) }) diff --git a/packages/dashboard/src/components/AuditFilterBar.tsx b/packages/dashboard/src/components/AuditFilterBar.tsx index 172bc62..3b4c39d 100644 --- a/packages/dashboard/src/components/AuditFilterBar.tsx +++ b/packages/dashboard/src/components/AuditFilterBar.tsx @@ -30,6 +30,10 @@ function buildExportUrl( to: string upstream_status_min: string upstream_status_max: string + origin: string + record_kind: string + channel: string + sender: string }, ): string { const outcomeParams = outcomeFilterToAuditParams(filters.decision) @@ -46,6 +50,10 @@ function buildExportUrl( if (filters.to) params.set('to', filters.to) if (filters.upstream_status_min) params.set('upstream_status_min', filters.upstream_status_min) if (filters.upstream_status_max) params.set('upstream_status_max', filters.upstream_status_max) + if (filters.origin) params.set('origin', filters.origin) + if (filters.record_kind) params.set('record_kind', filters.record_kind) + if (filters.channel) params.set('channel_id', filters.channel) + if (filters.sender) params.set('sender_id', filters.sender) return `/api/audit/export?${params.toString()}` } @@ -59,6 +67,7 @@ const BLOCK_REASON_FILTERS: ReadonlyArray<{ label: string; value: string | null { label: 'Approval Timeout', value: 'approval_timeout' }, { label: 'Client Disconnected', value: 'client_disconnected' }, { label: 'Shutdown Cancelled', value: 'shutdown_cancelled' }, + { label: 'Install Denied', value: 'install_denied' }, { label: 'Rate Limited', value: 'rate_limited' }, { label: 'Spend Limited', value: 'spend_limited' }, ] @@ -207,6 +216,32 @@ export function AuditFilterBar({ filters, setFilter, setBulkFilters }: AuditFilt ))} + + { + 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" + /> + Date: Mon, 15 Jun 2026 21:52:49 +0100 Subject: [PATCH 10/14] feat(dashboard): Origin + channel/sender columns in AuditTable (#16) --- .../src/components/AuditTable.test.tsx | 20 ++++++++++++++++ .../dashboard/src/components/AuditTable.tsx | 23 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/dashboard/src/components/AuditTable.test.tsx b/packages/dashboard/src/components/AuditTable.test.tsx index a734a18..f977c41 100644 --- a/packages/dashboard/src/components/AuditTable.test.tsx +++ b/packages/dashboard/src/components/AuditTable.test.tsx @@ -163,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)} From a91cafc0205b57d1de529f542dc4d2c14f1a278c Mon Sep 17 00:00:00 2001 From: olivrg Date: Mon, 15 Jun 2026 22:00:00 +0100 Subject: [PATCH 11/14] feat(dashboard): origin + kind chip + adapter-context detail on feed card (#16) --- .../src/components/ActionCard.test.tsx | 39 +++++++++++++++++++ .../dashboard/src/components/ActionCard.tsx | 19 +++++++++ .../dashboard/src/pages/FeedPage.test.tsx | 3 ++ 3 files changed, 61 insertions(+) diff --git a/packages/dashboard/src/components/ActionCard.test.tsx b/packages/dashboard/src/components/ActionCard.test.tsx index 007bf32..02493a0 100644 --- a/packages/dashboard/src/components/ActionCard.test.tsx +++ b/packages/dashboard/src/components/ActionCard.test.tsx @@ -78,6 +78,45 @@ describe('ActionCard', () => { expect(card?.className).toContain('border-l-red-400') }) + it('shows origin and kind chip on the feed card summary (#16)', () => { + render( + , + ) + expect(screen.getByText('OpenClaw')).toBeTruthy() + expect(screen.getByText('Install Scan')).toBeTruthy() + }) + + it('shows channel/sender in expanded detail when present (#16)', () => { + const expandedRecord: AuditRecord = { + ...baseRecord, + tool_input: {}, + evidence_chain: null, + approval_status: null, + approved_by: null, + upstream_response: null, + upstream_error: null, + upstream_http_status: null, + upstream_latency_ms: null, + block_reason: null, + created_at: new Date().toISOString(), + metadata: { channel_id: 'C1', sender_id: 'U1' }, + } + render( + , + ) + expect(screen.getByText(/C1/)).toBeTruthy() + expect(screen.getByText(/U1/)).toBeTruthy() + }) + it('truncates oversized detail payloads with an inline note', () => { const huge = 'x'.repeat(9_000) const expandedRecord: AuditRecord = { diff --git a/packages/dashboard/src/components/ActionCard.tsx b/packages/dashboard/src/components/ActionCard.tsx index 74092d0..9eb28c8 100644 --- a/packages/dashboard/src/components/ActionCard.tsx +++ b/packages/dashboard/src/components/ActionCard.tsx @@ -3,6 +3,7 @@ import type { AuditRecord, ActionEvent } from '../types' import { timeAgo, truncateId, formatLatency } from '../utils' import { PolicyBadge } from './PolicyBadge' import { DetailSection } from './DetailSection' +import { OriginBadge } from './OriginBadge' // --------------------------------------------------------------------------- // Types @@ -109,6 +110,8 @@ export const ActionCard = memo(function ActionCard({ {truncateId(record.agent_id ?? record.session_id)} + + )} + {/* Adapter context (channel/sender from adapter metadata) */} + {detail.metadata && + (typeof detail.metadata.channel_id === 'string' || + typeof detail.metadata.sender_id === 'string') && ( + +
+ {typeof detail.metadata.channel_id === 'string' && ( +

Channel: {detail.metadata.channel_id}

+ )} + {typeof detail.metadata.sender_id === 'string' && ( +

Sender: {detail.metadata.sender_id}

+ )} +
+
+ )} + {/* Duration breakdown */}
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, From 35548ecdb1d4ddfee5dc4d9623e66324f812f62d Mon Sep 17 00:00:00 2001 From: olivrg Date: Mon, 15 Jun 2026 22:06:14 +0100 Subject: [PATCH 12/14] docs(audit): describe origin column, record-kind chips, and channel/sender columns (#16) --- docs/audit.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/audit.md b/docs/audit.md index 0d47210..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) From e9a3996a67c27df0a05effc710863b073da243f7 Mon Sep 17 00:00:00 2001 From: olivrg Date: Mon, 15 Jun 2026 23:53:05 +0100 Subject: [PATCH 13/14] fix(dashboard): pointer cursor on select dropdowns (#16) Tailwind v4 dropped the default button cursor and we restored it globally for button/[role=button]/summary, but native dropdowns also default to the arrow cursor — include them + so all interactive controls share the pointer affordance. */ button, [role='button'], +select, summary { cursor: pointer; } From c79d6563e48f38ded937ef2e5d9e7827d4a3d52c Mon Sep 17 00:00:00 2001 From: olivrg Date: Mon, 15 Jun 2026 23:54:47 +0100 Subject: [PATCH 14/14] fix(audit): substring matching for origin/channel/sender filters (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit origin, metadata.channel_id and metadata.sender_id filtered by exact equality, while the tool-name filter uses substring (LIKE %value%). In the dashboard's free-text filter inputs this made origin/channel/sender feel unresponsive — partial input matched nothing until the exact value was typed, unlike the tool field which narrows as you type. Switch all three to substring LIKE for a consistent search-as-you-type experience. record_kind stays exact (select). --- docs/sideband-api.md | 2 +- packages/proxy/src/audit/store.test.ts | 29 ++++++++++++++++++++++++++ packages/proxy/src/audit/store.ts | 12 +++++------ packages/proxy/src/audit/types.ts | 4 ++-- 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/docs/sideband-api.md b/docs/sideband-api.md index 04fe9e8..aa27e11 100644 --- a/docs/sideband-api.md +++ b/docs/sideband-api.md @@ -327,7 +327,7 @@ Searchable, filtered, paginated audit log. | `channel_id` | — | — | Filter by `metadata.channel_id` (adapter-supplied). | | `sender_id` | — | — | Filter by `metadata.sender_id` (adapter-supplied). | -`tool` uses substring matching (`LIKE %value%`). `decision`, `reason`, `session`, `agent`, `origin`, `record_kind`, `channel_id`, and `sender_id` use exact equality matching. +`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):** diff --git a/packages/proxy/src/audit/store.test.ts b/packages/proxy/src/audit/store.test.ts index 186acba..cb640c2 100644 --- a/packages/proxy/src/audit/store.test.ts +++ b/packages/proxy/src/audit/store.test.ts @@ -840,6 +840,35 @@ CREATE TABLE IF NOT EXISTS audit_records ( // 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', + }) + }) }) // ------------------------------------------------------------------------- diff --git a/packages/proxy/src/audit/store.ts b/packages/proxy/src/audit/store.ts index 146a087..6a67b77 100644 --- a/packages/proxy/src/audit/store.ts +++ b/packages/proxy/src/audit/store.ts @@ -200,16 +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') = ?") - params.push(filters.channel_id) + 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') = ?") - params.push(filters.sender_id) + 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 d96e35f..0234e6b 100644 --- a/packages/proxy/src/audit/types.ts +++ b/packages/proxy/src/audit/types.ts @@ -110,9 +110,9 @@ 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, exact match). */ + /** Filter by metadata.channel_id (adapter-supplied; JSON-extracted, substring match). */ readonly channel_id?: string - /** Filter by metadata.sender_id (adapter-supplied; JSON-extracted, exact match). */ + /** 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