diff --git a/backend/src/control_center/api/routes_platform_interactions_proxy.py b/backend/src/control_center/api/routes_platform_interactions_proxy.py new file mode 100644 index 0000000..8467e96 --- /dev/null +++ b/backend/src/control_center/api/routes_platform_interactions_proxy.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import os + +import httpx +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse, Response + +# PR-B5-B (Control Center Interaction Admin View). Same reasoning as +# every other proxy file in this directory (routes_audit_proxy.py in +# particular, whose shape this mirrors exactly): no authorization +# decision is made here -- omnibioai-auth's own +# require_permission(manage_all_orgs) on GET /platform/interactions +# (PR-B5-A) decides every request, pre-existing (reused, not new) and +# unmodified by this PR. A pure relay: never inspects, caches, or logs +# the response bodies it forwards -- matters here because an +# interaction's own metadata could in principle be large or +# sensitive-looking even though the write path (interaction_service.py's +# own _redact_metadata, reused by app/workers/interaction_consumer.py) +# already strips secret-shaped keys before persistence; see +# frontend/cc-ui/src/interactions.ts's own maskSensitiveFields for the +# UI-side defense-in-depth layer this PR adds on top, not instead of, +# that guarantee. +router = APIRouter() + +IAM_URL = os.environ.get("IAM_URL", "http://auth-service:8001") + + +async def _proxy(method: str, path: str, request: Request) -> Response: + body = await request.body() + headers = {"Content-Type": "application/json"} + auth_header = request.headers.get("authorization") + if auth_header: + headers["Authorization"] = auth_header + + try: + async with httpx.AsyncClient(timeout=10) as client: + r = await client.request( + method, + f"{IAM_URL}{path}", + params=request.query_params, + content=body if method in ("POST", "PATCH", "PUT") else None, + headers=headers, + ) + except httpx.RequestError as e: + return JSONResponse( + {"error": f"auth-service unreachable: {type(e).__name__}: {e}"}, status_code=503, + ) + + if not r.content: + return Response(status_code=r.status_code) + + try: + payload = r.json() + except ValueError: + payload = {"error": "auth-service returned a non-JSON response"} + return JSONResponse(payload, status_code=r.status_code) + + +# ---------------- Interactions (read-only, platform-admin only) ----------- + +# GET only -- no PATCH/PUT/DELETE route is proxied here because +# omnibioai-auth exposes none (PR-B5-A is GET /platform/interactions[/{id}] +# only). Interactions are a durable ledger, immutable through this proxy +# by simply never defining any other verb -- same convention +# routes_audit_proxy.py already established for AuditEvent. + +@router.get("/platform/interactions") +async def list_interactions_proxy(request: Request) -> Response: + return await _proxy("GET", "/platform/interactions", request) + + +@router.get("/platform/interactions/{interaction_id}") +async def get_interaction_proxy(interaction_id: str, request: Request) -> Response: + return await _proxy("GET", f"/platform/interactions/{interaction_id}", request) diff --git a/backend/src/control_center/main.py b/backend/src/control_center/main.py index de3323a..280f1e0 100644 --- a/backend/src/control_center/main.py +++ b/backend/src/control_center/main.py @@ -48,6 +48,7 @@ from control_center.api.routes_workflow_bundles_proxy import router as workflow_bundles_proxy_router from control_center.api.routes_rag_proxy import router as rag_proxy_router from control_center.api.routes_platform_config_proxy import router as platform_config_proxy_router +from control_center.api.routes_platform_interactions_proxy import router as platform_interactions_proxy_router from control_center.api.routes_cloud import router as cloud_router from control_center.core.auth import require_permission from control_center.api.routes_config import router as config_router @@ -166,6 +167,7 @@ def _setup_logging() -> logging.Logger: app.include_router(workflow_bundles_proxy_router) app.include_router(rag_proxy_router) app.include_router(platform_config_proxy_router) +app.include_router(platform_interactions_proxy_router) # No blanket permission dependency here, unlike summary/docker/config/ # services above -- routes_dashboard.py's own docstring explains why: # each section of its one response is authorized independently, either diff --git a/backend/tests/test_routes_platform_interactions_proxy.py b/backend/tests/test_routes_platform_interactions_proxy.py new file mode 100644 index 0000000..f509f96 --- /dev/null +++ b/backend/tests/test_routes_platform_interactions_proxy.py @@ -0,0 +1,304 @@ +""" +tests/test_routes_platform_interactions_proxy.py + +Unit tests for: + - control_center.api.routes_platform_interactions_proxy + (GET /platform/interactions, GET /platform/interactions/{interaction_id}) + +Mirrors test_routes_sessions_proxy.py's / test_routes_audit_proxy.py's +exact conventions -- this route is a thin relay, no authorization +decision is made here (that's entirely omnibioai-auth's job: +GET /platform/interactions is platform-admin-only, +require_permission(manage_all_orgs), PR-B5-A). +""" + +from __future__ import annotations + +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +from fastapi.testclient import TestClient + +from control_center.main import app + +client = TestClient(app) + + +def _mock_response(status_code: int, json_body=None, raise_json_error: bool = False, content: bytes = b"x") -> MagicMock: + resp = MagicMock() + resp.status_code = status_code + resp.content = content + if raise_json_error: + resp.json.side_effect = ValueError("not json") + else: + resp.json.return_value = json_body + return resp + + +def _mock_async_client(response: MagicMock = None, side_effect=None): + mock_client = MagicMock() + mock_request = AsyncMock() + if side_effect is not None: + mock_request.side_effect = side_effect + else: + mock_request.return_value = response + mock_client.request = mock_request + + mock_ctx = MagicMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_client) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + return mock_ctx + + +_INTERACTION = { + "id": 1, + "interaction_id": "becbce38-0ada-427c-a29c-4c1bdfd95095", + "organization_id": 339, + "user_id": 630, + "session_id": None, + "trace_id": "verify-trace-1", + "service": "rag", + "interaction_type": "query", + "action": "rag.query", + "resource_type": "study", + "resource_id": "default", + "status": "success", + "decision": None, + "metadata": {"mode": "rag", "top_k": 3}, + "created_at": "2026-08-10T01:41:38", +} + +_LIST_RESPONSE = { + "items": [_INTERACTION], + "total": 1, + "page": 1, + "page_size": 20, + "total_pages": 1, +} + + +class TestListInteractionsProxy(unittest.TestCase): + def test_forwards_success_response(self) -> None: + upstream = _mock_response(200, _LIST_RESPONSE) + with patch( + "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient", + return_value=_mock_async_client(upstream), + ): + resp = client.get("/platform/interactions", headers={"Authorization": "Bearer tok"}) + self.assertEqual(resp.status_code, 200) + body = resp.json() + self.assertEqual(body["items"][0]["interaction_id"], _INTERACTION["interaction_id"]) + # Never leaks anything token/secret-shaped, even indirectly. + body_text = resp.text + for forbidden in ("access_token", "refresh_token", "hashed_password", "jwt", "cookie"): + self.assertNotIn(forbidden, body_text.lower()) + + def test_envelope_preserved_exactly(self) -> None: + upstream = _mock_response(200, _LIST_RESPONSE) + with patch( + "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient", + return_value=_mock_async_client(upstream), + ): + resp = client.get("/platform/interactions", headers={"Authorization": "Bearer tok"}) + body = resp.json() + self.assertEqual(set(body.keys()), {"items", "total", "page", "page_size", "total_pages"}) + self.assertEqual(body["total"], 1) + self.assertEqual(body["page"], 1) + self.assertEqual(body["page_size"], 20) + self.assertEqual(body["total_pages"], 1) + + def test_forwards_authorization_header(self) -> None: + upstream = _mock_response(200, _LIST_RESPONSE) + mock_ctx = _mock_async_client(upstream) + with patch( + "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient", + return_value=mock_ctx, + ): + client.get("/platform/interactions", headers={"Authorization": "Bearer my-token-123"}) + call_kwargs = mock_ctx.__aenter__.return_value.request.call_args.kwargs + self.assertEqual(call_kwargs["headers"]["Authorization"], "Bearer my-token-123") + + def test_missing_authorization_header_is_not_forged(self) -> None: + # No Authorization header on the incoming request -- the proxy + # must not invent one; omnibioai-auth's own get_current_user is + # what actually rejects the request (mocked here as a 401, same + # as test_upstream_401_is_forwarded below covers end-to-end). + upstream = _mock_response(200, _LIST_RESPONSE) + mock_ctx = _mock_async_client(upstream) + with patch( + "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient", + return_value=mock_ctx, + ): + client.get("/platform/interactions") + call_kwargs = mock_ctx.__aenter__.return_value.request.call_args.kwargs + self.assertNotIn("Authorization", call_kwargs["headers"]) + + def test_query_parameters_forwarded_unchanged(self) -> None: + upstream = _mock_response(200, _LIST_RESPONSE) + mock_ctx = _mock_async_client(upstream) + with patch( + "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient", + return_value=mock_ctx, + ): + client.get( + "/platform/interactions", + params={ + "page": 2, "page_size": 50, "organization_id": 339, "user_id": 630, + "service": "rag", "interaction_type": "query", "status": "success", + "start_date": "2026-01-01T00:00:00", "end_date": "2026-12-31T00:00:00", + }, + headers={"Authorization": "Bearer tok"}, + ) + call_kwargs = mock_ctx.__aenter__.return_value.request.call_args.kwargs + forwarded_params = call_kwargs["params"] + self.assertEqual(forwarded_params["page"], "2") + self.assertEqual(forwarded_params["page_size"], "50") + self.assertEqual(forwarded_params["organization_id"], "339") + self.assertEqual(forwarded_params["user_id"], "630") + self.assertEqual(forwarded_params["service"], "rag") + self.assertEqual(forwarded_params["interaction_type"], "query") + self.assertEqual(forwarded_params["status"], "success") + self.assertEqual(forwarded_params["start_date"], "2026-01-01T00:00:00") + self.assertEqual(forwarded_params["end_date"], "2026-12-31T00:00:00") + + def test_upstream_401_is_forwarded(self) -> None: + upstream = _mock_response(401, {"detail": "Not authenticated"}) + with patch( + "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient", + return_value=_mock_async_client(upstream), + ): + resp = client.get("/platform/interactions") + self.assertEqual(resp.status_code, 401) + + def test_upstream_403_is_forwarded(self) -> None: + # A valid token lacking manage_all_orgs -- omnibioai-auth's own + # require_permission is the only authority here; this proxy makes + # no RBAC decision and must not turn a 403 into anything else. + upstream = _mock_response(403, {"detail": "Forbidden"}) + with patch( + "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient", + return_value=_mock_async_client(upstream), + ): + resp = client.get("/platform/interactions", headers={"Authorization": "Bearer tok"}) + self.assertEqual(resp.status_code, 403) + + def test_auth_service_unreachable_returns_503(self) -> None: + with patch( + "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient", + return_value=_mock_async_client(side_effect=httpx.ConnectError("refused")), + ): + resp = client.get("/platform/interactions", headers={"Authorization": "Bearer tok"}) + self.assertEqual(resp.status_code, 503) + self.assertIn("auth-service unreachable", resp.json()["error"]) + + def test_network_timeout_returns_503(self) -> None: + with patch( + "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient", + return_value=_mock_async_client(side_effect=httpx.TimeoutException("timed out")), + ): + resp = client.get("/platform/interactions", headers={"Authorization": "Bearer tok"}) + self.assertEqual(resp.status_code, 503) + + def test_non_json_upstream_response_handled(self) -> None: + upstream = _mock_response(500, raise_json_error=True) + with patch( + "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient", + return_value=_mock_async_client(upstream), + ): + resp = client.get("/platform/interactions", headers={"Authorization": "Bearer tok"}) + self.assertEqual(resp.status_code, 500) + self.assertIn("non-JSON", resp.json()["error"]) + + def test_upstream_5xx_is_forwarded(self) -> None: + upstream = _mock_response(500, {"detail": "Internal Server Error"}) + with patch( + "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient", + return_value=_mock_async_client(upstream), + ): + resp = client.get("/platform/interactions", headers={"Authorization": "Bearer tok"}) + self.assertEqual(resp.status_code, 500) + + def test_empty_upstream_body_preserved(self) -> None: + upstream = MagicMock() + upstream.status_code = 200 + upstream.content = b"" + upstream.json.side_effect = ValueError("no content to parse") + mock_ctx = _mock_async_client(upstream) + with patch( + "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient", + return_value=mock_ctx, + ): + resp = client.get("/platform/interactions", headers={"Authorization": "Bearer tok"}) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.content, b"") + + +class TestGetInteractionProxy(unittest.TestCase): + def test_forwards_success_response(self) -> None: + upstream = _mock_response(200, _INTERACTION) + mock_ctx = _mock_async_client(upstream) + with patch( + "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient", + return_value=mock_ctx, + ): + resp = client.get( + f"/platform/interactions/{_INTERACTION['interaction_id']}", + headers={"Authorization": "Bearer tok"}, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["interaction_id"], _INTERACTION["interaction_id"]) + forwarded_path = mock_ctx.__aenter__.return_value.request.call_args.args[1] + self.assertTrue(forwarded_path.endswith(f"/platform/interactions/{_INTERACTION['interaction_id']}")) + + def test_upstream_404_for_unknown_interaction_id(self) -> None: + upstream = _mock_response(404, {"detail": "Interaction not found"}) + with patch( + "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient", + return_value=_mock_async_client(upstream), + ): + resp = client.get( + "/platform/interactions/does-not-exist", headers={"Authorization": "Bearer tok"} + ) + self.assertEqual(resp.status_code, 404) + + def test_upstream_403_is_forwarded(self) -> None: + upstream = _mock_response(403, {"detail": "Forbidden"}) + with patch( + "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient", + return_value=_mock_async_client(upstream), + ): + resp = client.get( + f"/platform/interactions/{_INTERACTION['interaction_id']}", + headers={"Authorization": "Bearer tok"}, + ) + self.assertEqual(resp.status_code, 403) + + def test_forwards_authorization_header(self) -> None: + upstream = _mock_response(200, _INTERACTION) + mock_ctx = _mock_async_client(upstream) + with patch( + "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient", + return_value=mock_ctx, + ): + client.get( + f"/platform/interactions/{_INTERACTION['interaction_id']}", + headers={"Authorization": "Bearer owner-token"}, + ) + call_kwargs = mock_ctx.__aenter__.return_value.request.call_args.kwargs + self.assertEqual(call_kwargs["headers"]["Authorization"], "Bearer owner-token") + + def test_auth_service_unreachable_returns_503(self) -> None: + with patch( + "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient", + return_value=_mock_async_client(side_effect=httpx.ConnectError("refused")), + ): + resp = client.get( + f"/platform/interactions/{_INTERACTION['interaction_id']}", + headers={"Authorization": "Bearer tok"}, + ) + self.assertEqual(resp.status_code, 503) + + +if __name__ == "__main__": + unittest.main() diff --git a/frontend/cc-ui/src/apps/AdminApp.test.tsx b/frontend/cc-ui/src/apps/AdminApp.test.tsx index 03f5731..44e74d3 100644 --- a/frontend/cc-ui/src/apps/AdminApp.test.tsx +++ b/frontend/cc-ui/src/apps/AdminApp.test.tsx @@ -102,6 +102,7 @@ vi.mock('../pages/identity/ServiceAccountsPage', () => ({ vi.mock('../pages/audit/AuditLogsPage', () => ({ default: () =>
})) // PR-C (Control Center Sessions Integration). vi.mock('../pages/security/SessionsPage', () => ({ default: () =>
})) +vi.mock('../pages/InteractionsPage', () => ({ default: () =>
})) // PR14.5C. vi.mock('../pages/billing/BillingPage', () => ({ default: ({ orgId }: { orgId: number }) =>
, @@ -726,6 +727,53 @@ describe('AdminApp auth gate', () => { expect(screen.getAllByText('Sessions')).toHaveLength(1) }) + // ── PR-B5-B (Control Center Interaction Admin View) ─────────────────── + + it('reaches Interactions via the sidebar, no longer Coming Soon', async () => { + vi.mocked(auth.getToken).mockReturnValue('token-interactions1') + vi.mocked(auth.ensureSession).mockResolvedValue(admin) + vi.mocked(auth.getSessionUser).mockReturnValue(admin) + vi.mocked(auth.hasAdminAccess).mockReturnValue(true) + vi.mocked(auth.hasOrganizationsAccess).mockReturnValue(true) + vi.mocked(auth.hasPlatformAdminAccess).mockReturnValue(true) + + render() + await waitFor(() => expect(screen.getByTestId('DashboardPage')).toBeInTheDocument()) + + clickNav('Interactions') + + expect(await screen.findByTestId('InteractionsPage')).toBeInTheDocument() + expect(screen.queryByText('Coming soon')).not.toBeInTheDocument() + }) + + it('hides the Interactions nav item for a user who is not a platform admin', async () => { + vi.mocked(auth.getToken).mockReturnValue('token-interactions2') + vi.mocked(auth.ensureSession).mockResolvedValue(orgOnlyUser) + vi.mocked(auth.getSessionUser).mockReturnValue(orgOnlyUser) + vi.mocked(auth.hasAdminAccess).mockReturnValue(false) + vi.mocked(auth.hasOrganizationsAccess).mockReturnValue(true) + vi.mocked(auth.hasPlatformAdminAccess).mockReturnValue(false) + + render() + await waitFor(() => expect(screen.getByTestId('DashboardPage')).toBeInTheDocument()) + + expect(screen.queryByText('Interactions')).not.toBeInTheDocument() + }) + + it('shows exactly one Interactions navigation entry in the sidebar (no duplicate)', async () => { + vi.mocked(auth.getToken).mockReturnValue('token-interactions3') + vi.mocked(auth.ensureSession).mockResolvedValue(admin) + vi.mocked(auth.getSessionUser).mockReturnValue(admin) + vi.mocked(auth.hasAdminAccess).mockReturnValue(true) + vi.mocked(auth.hasOrganizationsAccess).mockReturnValue(true) + vi.mocked(auth.hasPlatformAdminAccess).mockReturnValue(true) + + render() + await waitFor(() => expect(screen.getByTestId('DashboardPage')).toBeInTheDocument()) + + expect(screen.getAllByText('Interactions')).toHaveLength(1) + }) + it('signing out via the profile menu returns to the login screen', async () => { vi.mocked(auth.getToken).mockReturnValue('token-admin6') vi.mocked(auth.ensureSession).mockResolvedValue(admin) diff --git a/frontend/cc-ui/src/apps/AdminApp.tsx b/frontend/cc-ui/src/apps/AdminApp.tsx index ceca811..d3bdf9a 100644 --- a/frontend/cc-ui/src/apps/AdminApp.tsx +++ b/frontend/cc-ui/src/apps/AdminApp.tsx @@ -31,6 +31,7 @@ import ServiceAccountsPage from '../pages/identity/ServiceAccountsPage' import BillingPage from '../pages/billing/BillingPage' import AuditLogsPage from '../pages/audit/AuditLogsPage' import SessionsPage from '../pages/security/SessionsPage' +import InteractionsPage from '../pages/InteractionsPage' import SecurityDashboardPage from '../pages/security/SecurityDashboardPage' import OrganizationMFAPolicyPage from '../pages/security/OrganizationMFAPolicyPage' import AuthGate from './AuthGate' @@ -123,6 +124,9 @@ function AdminDashboard() { // manage_all_orgs-gated, not org-scoped, so this is a flat platform- // wide page (no org-picker/deep-link, unlike 'iam'/'api-keys'). const canSeeAuditLogs = hasPlatformAdminAccess() + // PR-B5-B: same reasoning as canSeeAuditLogs -- GET /platform/ + // interactions is manage_all_orgs-gated, not org-scoped. + const canSeeInteractions = hasPlatformAdminAccess() // PR11.5.6: same reasoning as canSeeAuditLogs -- GET /platform/users, // GET /platform/orgs, GET /platform/audit-events (everything the // Security Dashboard reads) are all manage_all_orgs-gated. @@ -320,7 +324,7 @@ function AdminDashboard() { ) : undefined} > {renderPage(active, { - canSeeOps, canSeeOrganizations, canSeeUsers, canSeeAuditLogs, canSeeSecurityOverview, refreshKey, + canSeeOps, canSeeOrganizations, canSeeUsers, canSeeAuditLogs, canSeeInteractions, canSeeSecurityOverview, refreshKey, selectedOrgId, setSelectedOrgId, selectedUserId, setSelectedUserId, teamsOrgHint, rolesOrgHint, onViewTeams: handleViewTeams, onViewRoles: handleViewRoles, selectedSsoOrgId, setSelectedSsoOrgId, navigateToSsoSettings, @@ -338,6 +342,7 @@ interface RenderCtx { canSeeOrganizations: boolean canSeeUsers: boolean canSeeAuditLogs: boolean + canSeeInteractions: boolean canSeeSecurityOverview: boolean refreshKey: number selectedOrgId: number | null @@ -440,6 +445,13 @@ function renderPage(active: PageKey, ctx: RenderCtx) { case 'sessions': return + // PR-B5-B: flat platform-wide page, no org-picker/deep-link -- same + // shape as 'audit-logs' above (interactions span every organization, + // filtered in-page), unlike 'sessions' immediately above (self-service). + case 'interactions': + if (!ctx.canSeeInteractions) return null + return + // PR11.2: same gate as 'organizations' -- see navigation.ts. case 'teams': if (!ctx.canSeeOrganizations) return null diff --git a/frontend/cc-ui/src/interactions.ts b/frontend/cc-ui/src/interactions.ts new file mode 100644 index 0000000..acb6c63 --- /dev/null +++ b/frontend/cc-ui/src/interactions.ts @@ -0,0 +1,111 @@ +// PR-B5-B (Control Center Interaction Admin View). Data layer, mirroring +// audit.ts's own shape exactly. Every call hits control-center's own +// backend at a relative path (routes_platform_interactions_proxy.py +// proxies to omnibioai-auth's GET /platform/interactions[/{id}] -- +// PR-B5-A); no function here makes an authorization decision -- that's +// entirely omnibioai-auth's job (require_permission(manage_all_orgs), +// reused unchanged, no new permission). +import { authHeaders, reportUnauthorized } from './auth' + +async function apiFetch(path: string, init: RequestInit = {}): Promise { + const r = await fetch(path, { + ...init, + headers: { ...authHeaders(), ...(init.headers ?? {}) }, + }) + if (r.status === 401) { + reportUnauthorized() + } + return r +} + +// Mirrors omnibioai-auth's InteractionOut (app/schemas/interaction_read.py) +// exactly. No display-name enrichment exists on the upstream response +// (organization_id/user_id are returned as raw IDs, not resolved to a +// name/email -- unlike AuditEventOut's organization_name/actor_email) -- +// see this PR's own report for why that's not added here. metadata is +// untyped JSON on purpose -- every service/interaction_type's payload +// shape differs, same reasoning AuditEvent's own metadata field has. +export interface Interaction { + id: number + interaction_id: string + organization_id: number + user_id: number | null + session_id: string | null + trace_id: string | null + service: string + interaction_type: string + action: string + resource_type: string | null + resource_id: string | null + status: string | null + decision: string | null + metadata: Record | null + created_at: string +} + +export interface InteractionListResponse { + items: Interaction[] + total: number + page: number + page_size: number + total_pages: number +} + +export interface InteractionFilters { + organizationId?: number + userId?: number + service?: string + interactionType?: string + status?: string + /** ISO 8601 -- passed straight through to the backend's start_date/ + * end_date query params, which parse it the same way. */ + startDate?: string + endDate?: string + page?: number + pageSize?: number +} + +export async function fetchInteractions(filters: InteractionFilters = {}): Promise { + const qs = new URLSearchParams() + qs.set('page', String(filters.page ?? 1)) + qs.set('page_size', String(filters.pageSize ?? 20)) + if (filters.organizationId != null) qs.set('organization_id', String(filters.organizationId)) + if (filters.userId != null) qs.set('user_id', String(filters.userId)) + if (filters.service) qs.set('service', filters.service) + if (filters.interactionType) qs.set('interaction_type', filters.interactionType) + if (filters.status) qs.set('status', filters.status) + if (filters.startDate) qs.set('start_date', filters.startDate) + if (filters.endDate) qs.set('end_date', filters.endDate) + + const r = await apiFetch(`/platform/interactions?${qs.toString()}`) + if (!r.ok) throw new Error(`/platform/interactions ${r.status}`) + return r.json() +} + +export async function fetchInteraction(interactionId: string): Promise { + const r = await apiFetch(`/platform/interactions/${encodeURIComponent(interactionId)}`) + if (!r.ok) throw new Error(`/platform/interactions/${interactionId} ${r.status}`) + return r.json() +} + +// ── Defense-in-depth secret masking for the detail view ───────────────── +// +// The backend never writes a secret into an Interaction's metadata in +// the first place (interaction_service.py's own _redact_metadata, +// reused by app/workers/interaction_consumer.py for stream-sourced +// events -- verified directly, see PR-B5-B's own report). This is a +// second, independent layer on top of that guarantee, not a substitute +// for it -- identical in shape and intent to audit.ts's own +// maskSensitiveFields (same pattern, not re-derived): any key whose name +// merely *looks* sensitive is masked before ever reaching the DOM, so a +// future call site's mistake fails safe here too. +const SENSITIVE_KEY_PATTERN = /secret|token|password|api_?key|client_secret|hash/i + +export function maskSensitiveFields(obj: Record | null): Record | null { + if (!obj) return obj + const masked: Record = {} + for (const [key, value] of Object.entries(obj)) { + masked[key] = SENSITIVE_KEY_PATTERN.test(key) ? '••••••••' : value + } + return masked +} diff --git a/frontend/cc-ui/src/navigation.test.ts b/frontend/cc-ui/src/navigation.test.ts index 41d298f..2766628 100644 --- a/frontend/cc-ui/src/navigation.test.ts +++ b/frontend/cc-ui/src/navigation.test.ts @@ -42,3 +42,39 @@ describe('navigation: Sessions placement', () => { expect(auditItem.children).toBeUndefined() }) }) + +// PR-B5-B (Control Center Interaction Admin View). Same reasoning as the +// Sessions block above. + +describe('navigation: Interactions placement', () => { + it('has exactly one "interactions" entry across the entire tree', () => { + const found: { sectionKey: string; parentKey?: string }[] = [] + for (const section of NAVIGATION) { + for (const item of section.items) { + if (item.key === 'interactions') found.push({ sectionKey: section.key }) + for (const child of item.children ?? []) { + if (child.key === 'interactions') found.push({ sectionKey: section.key, parentKey: item.key }) + } + } + } + expect(found).toHaveLength(1) + }) + + it('places "interactions" under the Security section, functional and gated', () => { + const securitySection = NAVIGATION.find(s => s.key === 'security') + expect(securitySection).toBeDefined() + + const interactionsItem = securitySection!.items.find(i => i.key === 'interactions') + expect(interactionsItem).toBeDefined() + expect(interactionsItem!.functional).toBe(true) + // Same gate audit-logs uses -- GET /platform/interactions is + // manage_all_orgs-gated, not org-scoped or self-service. + expect(interactionsItem!.visible).toBeDefined() + }) + + it('is a top-level Security item alongside Audit Logs, not nested under it', () => { + const securitySection = NAVIGATION.find(s => s.key === 'security')! + const interactionsItem = securitySection.items.find(i => i.key === 'interactions')! + expect(interactionsItem.children).toBeUndefined() + }) +}) diff --git a/frontend/cc-ui/src/navigation.ts b/frontend/cc-ui/src/navigation.ts index 96e3e2d..0787d80 100644 --- a/frontend/cc-ui/src/navigation.ts +++ b/frontend/cc-ui/src/navigation.ts @@ -25,7 +25,7 @@ export type PageKey = | 'health' | 'docker' | 'ecosystem' | 'config' | 'llms' | 'cloud' | 'organizations' | 'users' | 'teams' | 'roles' | 'infrastructure' | 'workflows' | 'tool-execution' | 'ai-models' - | 'security-overview' | 'mfa-policy' | 'iam' | 'audit-logs' | 'sessions' | 'api-keys' + | 'security-overview' | 'mfa-policy' | 'iam' | 'audit-logs' | 'sessions' | 'interactions' | 'api-keys' | 'billing' | 'rag' | 'pubmed' | 'integrations' | 'settings' @@ -197,6 +197,21 @@ export const NAVIGATION: NavSection[] = [ // (hasConsoleAccess in AdminApp.tsx) already has an account whose // own sessions are meaningfully theirs to see. { key: 'sessions', label: 'Sessions', functional: true }, + // PR-B5-B (Control Center Interaction Admin View). functional: true + // because a real page now exists (InteractionsPage), reusing + // omnibioai-auth's GET /platform/interactions (PR-B5-A) via + // routes_platform_interactions_proxy.py. Gated by + // hasPlatformAdminAccess, the same gate 'audit-logs' above uses and + // for the identical reason -- the backend route this page reads is + // gated by manage_all_orgs specifically (no dedicated interactions + // permission exists, and this PR doesn't add one), so + // hasPlatformAdminAccess is the one gate that actually matches who + // can reach real data here. Placed in Security next to Audit Logs + // (its closest technical precedent: paginated, filtered, platform- + // admin-only, free-form JSON metadata) rather than a new section -- + // see this PR's own report for the open question of whether a + // dedicated "Activity" section would fit better long-term. + { key: 'interactions', label: 'Interactions', functional: true, visible: hasPlatformAdminAccess }, // PR11.4: Service Accounts & API Keys Management UI. functional: // true because a real page now exists (ServiceAccountsPage, // reached via an organization picker -- API keys/OAuth clients diff --git a/frontend/cc-ui/src/pages/InteractionsPage.test.tsx b/frontend/cc-ui/src/pages/InteractionsPage.test.tsx new file mode 100644 index 0000000..119cd44 --- /dev/null +++ b/frontend/cc-ui/src/pages/InteractionsPage.test.tsx @@ -0,0 +1,289 @@ +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import InteractionsPage from './InteractionsPage' +import * as interactions from '../interactions' +import type { Interaction, InteractionListResponse } from '../interactions' +import * as organizations from '../organizations' +import type { PlatformOrgSummary } from '../organizations' + +vi.mock('../interactions', async () => { + const actual = await vi.importActual('../interactions') + return { ...actual, fetchInteractions: vi.fn() } +}) + +vi.mock('../organizations', async () => { + const actual = await vi.importActual('../organizations') + return { ...actual, fetchPlatformOrgs: vi.fn() } +}) + +const orgOptions: PlatformOrgSummary[] = [ + { id: 3, name: 'Acme Corp', status: 'active', owner_email: null, member_count: 1, team_count: 0, api_key_count: 0, oauth_client_count: 0, license_count: 0, sso_enabled: false, mfa_policy_required: false, mfa_policy_configured: false, created_at: '2026-07-01T00:00:00' }, +] + +const ragQueryInteraction: Interaction = { + id: 1, interaction_id: 'becbce38-0ada-427c-a29c-4c1bdfd95095', + organization_id: 3, user_id: 630, session_id: null, trace_id: 'trace-1', + service: 'rag', interaction_type: 'query', action: 'rag.query', + resource_type: 'study', resource_id: 'default', status: 'success', decision: null, + metadata: { mode: 'rag', top_k: 3 }, + created_at: '2026-08-10T01:41:38', +} + +// Deliberately includes a sensitive-looking metadata key even though the +// real backend (interaction_service.py's own _redact_metadata, reused +// by app/workers/interaction_consumer.py) never writes one into a +// persisted Interaction's metadata -- proves this page's own +// defense-in-depth masking layer works, same reasoning +// AuditLogsPage.test.tsx's own overrideCreatedEvent fixture gives. +const secretShapedInteraction: Interaction = { + id: 2, interaction_id: '11111111-2222-3333-4444-555555555555', + organization_id: 3, user_id: 631, session_id: null, trace_id: 'trace-2', + service: 'rag', interaction_type: 'query', action: 'rag.query', + resource_type: 'study', resource_id: 'default', status: 'error', decision: null, + metadata: { mode: 'rag', client_secret: 'should-never-render' }, + created_at: '2026-08-10T02:00:00', +} + +function listResponse(items: Interaction[], overrides: Partial = {}): InteractionListResponse { + return { items, total: items.length, page: 1, page_size: 20, total_pages: 1, ...overrides } +} + +describe('InteractionsPage', () => { + beforeEach(() => { + vi.mocked(interactions.fetchInteractions).mockReset() + vi.mocked(organizations.fetchPlatformOrgs).mockReset().mockResolvedValue({ + items: orgOptions, total: 1, page: 1, page_size: 100, total_pages: 1, + }) + }) + + it('shows a loading state while the interactions fetch is in flight', async () => { + vi.mocked(interactions.fetchInteractions).mockReturnValue(new Promise(() => {})) + render() + expect(await screen.findByText('Loading interactions…')).toBeInTheDocument() + }) + + it('shows the empty state when no interactions exist yet', async () => { + vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([])) + render() + expect(await screen.findByText('No interactions recorded yet.')).toBeInTheDocument() + }) + + it('renders the table with Interaction Type/Action/Status/Organization/User/Details columns', async () => { + vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([ragQueryInteraction])) + render() + + expect(await screen.findByRole('columnheader', { name: 'Interaction Type' })).toBeInTheDocument() + expect(screen.getByRole('columnheader', { name: 'Action' })).toBeInTheDocument() + expect(screen.getByRole('columnheader', { name: 'Status' })).toBeInTheDocument() + expect(screen.getByRole('columnheader', { name: 'Organization' })).toBeInTheDocument() + expect(screen.getByRole('columnheader', { name: 'User' })).toBeInTheDocument() + expect(screen.getByRole('columnheader', { name: 'Details' })).toBeInTheDocument() + + expect(screen.getByRole('cell', { name: 'query' })).toBeInTheDocument() + expect(screen.getByRole('cell', { name: 'rag.query' })).toBeInTheDocument() + expect(screen.getByRole('cell', { name: 'success' })).toBeInTheDocument() + expect(screen.getByRole('cell', { name: 'Org #3' })).toBeInTheDocument() + expect(screen.getByRole('cell', { name: 'User #630' })).toBeInTheDocument() + }) + + it('re-fetches with the organization filter applied when changed', async () => { + vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([ragQueryInteraction])) + const user = userEvent.setup() + render() + await screen.findByRole('cell', { name: 'query' }) + + await user.selectOptions(screen.getByLabelText('Filter by organization'), '3') + + await waitFor(() => expect(interactions.fetchInteractions).toHaveBeenLastCalledWith( + expect.objectContaining({ organizationId: 3 }), + )) + }) + + it('re-fetches with the user filter applied when changed', async () => { + vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([ragQueryInteraction])) + const user = userEvent.setup() + render() + await screen.findByRole('cell', { name: 'query' }) + + await user.type(screen.getByLabelText('Filter by user ID'), '630') + + await waitFor(() => expect(interactions.fetchInteractions).toHaveBeenLastCalledWith( + expect.objectContaining({ userId: 630 }), + )) + }) + + it('re-fetches with the service filter applied when changed', async () => { + vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([ragQueryInteraction])) + const user = userEvent.setup() + render() + await screen.findByRole('cell', { name: 'query' }) + + await user.type(screen.getByLabelText('Filter by service'), 'rag') + + await waitFor(() => expect(interactions.fetchInteractions).toHaveBeenLastCalledWith( + expect.objectContaining({ service: 'rag' }), + )) + }) + + it('re-fetches with the interaction type filter applied when changed', async () => { + vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([ragQueryInteraction])) + const user = userEvent.setup() + render() + await screen.findByRole('cell', { name: 'query' }) + + await user.type(screen.getByLabelText('Filter by interaction type'), 'query') + + await waitFor(() => expect(interactions.fetchInteractions).toHaveBeenLastCalledWith( + expect.objectContaining({ interactionType: 'query' }), + )) + }) + + it('re-fetches with the status filter applied when changed', async () => { + vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([ragQueryInteraction])) + const user = userEvent.setup() + render() + await screen.findByRole('cell', { name: 'query' }) + + await user.type(screen.getByLabelText('Filter by status'), 'success') + + await waitFor(() => expect(interactions.fetchInteractions).toHaveBeenLastCalledWith( + expect.objectContaining({ status: 'success' }), + )) + }) + + it('re-fetches with multiple filters applied together', async () => { + vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([ragQueryInteraction])) + const user = userEvent.setup() + render() + await screen.findByRole('cell', { name: 'query' }) + + await user.selectOptions(screen.getByLabelText('Filter by organization'), '3') + await user.type(screen.getByLabelText('Filter by service'), 'rag') + + await waitFor(() => expect(interactions.fetchInteractions).toHaveBeenLastCalledWith( + expect.objectContaining({ organizationId: 3, service: 'rag' }), + )) + }) + + it('resets to page 1 after a filter changes', async () => { + vi.mocked(interactions.fetchInteractions).mockResolvedValue( + listResponse([ragQueryInteraction], { page: 2, total_pages: 3, total: 41 }), + ) + const user = userEvent.setup() + render() + await screen.findByRole('cell', { name: 'query' }) + + await user.selectOptions(screen.getByLabelText('Filter by organization'), '3') + + await waitFor(() => expect(interactions.fetchInteractions).toHaveBeenLastCalledWith( + expect.objectContaining({ page: 1, organizationId: 3 }), + )) + }) + + it('clears all filters and reloads unfiltered', async () => { + vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([ragQueryInteraction])) + const user = userEvent.setup() + render() + await screen.findByRole('cell', { name: 'query' }) + + await user.type(screen.getByLabelText('Filter by service'), 'rag') + await waitFor(() => expect(interactions.fetchInteractions).toHaveBeenLastCalledWith( + expect.objectContaining({ service: 'rag' }), + )) + + await user.click(screen.getByRole('button', { name: 'Clear filters' })) + + await waitFor(() => expect(interactions.fetchInteractions).toHaveBeenLastCalledWith( + expect.objectContaining({ + organizationId: undefined, userId: undefined, service: undefined, + interactionType: undefined, status: undefined, startDate: undefined, endDate: undefined, + }), + )) + }) + + it('shows pagination controls reflecting the response', async () => { + vi.mocked(interactions.fetchInteractions).mockResolvedValue( + listResponse([ragQueryInteraction], { page: 2, total_pages: 3, total: 41 }), + ) + render() + await screen.findByRole('cell', { name: 'query' }) + expect(screen.getByText(/Page/)).toBeInTheDocument() + expect(screen.getByText('2', { selector: 'span' })).toBeInTheDocument() + }) + + it('shows a permission-denied state on a 403, not the table', async () => { + vi.mocked(interactions.fetchInteractions).mockRejectedValue(new Error('/platform/interactions 403')) + render() + + expect(await screen.findByText('Permission denied')).toBeInTheDocument() + expect(screen.queryByText('Interaction Type')).not.toBeInTheDocument() + }) + + it('shows an error state with retry for an unexpected failure', async () => { + vi.mocked(interactions.fetchInteractions).mockRejectedValueOnce(new Error('/platform/interactions 503')) + vi.mocked(interactions.fetchInteractions).mockResolvedValueOnce(listResponse([ragQueryInteraction])) + const user = userEvent.setup() + render() + + expect(await screen.findByText('Error')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Retry' })) + expect(await screen.findByRole('cell', { name: 'query' })).toBeInTheDocument() + }) + + it('opens the detail modal on "View", showing all key fields', async () => { + vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([ragQueryInteraction])) + const user = userEvent.setup() + render() + await screen.findByRole('cell', { name: 'query' }) + + await user.click(screen.getByRole('button', { name: 'View →' })) + + const dialog = screen.getByRole('dialog', { name: 'Interaction detail' }) + expect(within(dialog).getByText(ragQueryInteraction.interaction_id)).toBeInTheDocument() + expect(within(dialog).getByText('trace-1')).toBeInTheDocument() + expect(within(dialog).getByText('rag.query')).toBeInTheDocument() + expect(within(dialog).getByText('study #default')).toBeInTheDocument() + expect(within(dialog).getByText('User #630')).toBeInTheDocument() + expect(within(dialog).getByText('Org #3')).toBeInTheDocument() + }) + + it('renders metadata as pretty-printed JSON in the detail view', async () => { + vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([ragQueryInteraction])) + const user = userEvent.setup() + render() + await screen.findByRole('cell', { name: 'query' }) + + await user.click(screen.getByRole('button', { name: 'View →' })) + + const dialog = screen.getByRole('dialog', { name: 'Interaction detail' }) + expect(within(dialog).getByText(/"mode": "rag"/)).toBeInTheDocument() + expect(within(dialog).getByText(/"top_k": 3/)).toBeInTheDocument() + }) + + it('closes the detail modal', async () => { + vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([ragQueryInteraction])) + const user = userEvent.setup() + render() + await screen.findByRole('cell', { name: 'query' }) + + await user.click(screen.getByRole('button', { name: 'View →' })) + expect(screen.getByRole('dialog', { name: 'Interaction detail' })).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Close' })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('masks sensitive-looking metadata keys in the detail view', async () => { + vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([secretShapedInteraction])) + const user = userEvent.setup() + render() + await screen.findByRole('cell', { name: 'error' }) + + await user.click(screen.getByRole('button', { name: 'View →' })) + + const dialog = screen.getByRole('dialog', { name: 'Interaction detail' }) + expect(within(dialog).queryByText(/should-never-render/)).not.toBeInTheDocument() + expect(within(dialog).getByText(/••••••••/)).toBeInTheDocument() + }) +}) diff --git a/frontend/cc-ui/src/pages/InteractionsPage.tsx b/frontend/cc-ui/src/pages/InteractionsPage.tsx new file mode 100644 index 0000000..cfd0738 --- /dev/null +++ b/frontend/cc-ui/src/pages/InteractionsPage.tsx @@ -0,0 +1,280 @@ +import { useEffect, useState } from 'react' +import { ShieldAlert } from 'lucide-react' +import { + fetchInteractions, maskSensitiveFields, + type Interaction, type InteractionListResponse, +} from '../interactions' +import { fetchPlatformOrgs, type PlatformOrgSummary } from '../organizations' +import { Card, SectionHeader, LoadingState, ErrorState, EmptyState, ActionToolbar, Button, DataTable, Pagination } from '../components/ui' +import { formatDate } from '../format' + +// PR-B5-B (Control Center Interaction Admin View). Mirrors +// AuditLogsPage.tsx's architecture exactly -- both read a paginated, +// filtered, platform-admin-only (manage_all_orgs) ledger with a +// free-form JSON metadata field, unlike SessionsPage.tsx's self-service, +// unpaginated, filter-less shape (a small, inherently bounded dataset +// Interactions has no equivalent of). See this PR's own report for why +// Audit Logs, not Sessions, is the governing precedent here. +// +// Interaction.status/service/interaction_type have no fixed, backend- +// enforced vocabulary (unlike AuditEvent.event_type, which has a +// maintained KNOWN_EVENT_TYPES list) -- confirmed by reading +// app/db/models.py::Interaction directly, plain unconstrained string +// columns. Free-text filters for all three, not dropdowns: a hardcoded +// vocabulary here (e.g. RAG's current "success"/"error"/"timeout") +// would silently go stale the moment a second producer with different +// values exists, and there is no source of truth to build a dropdown +// from the way KNOWN_EVENT_TYPES / fetchPlatformOrgs provide one for +// event_type / organization_id respectively. + +const PAGE_SIZE = 20 + +const selectStyle: React.CSSProperties = { + fontSize: 12, padding: '7px 10px', borderRadius: 8, + border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', +} +const fieldLabel: React.CSSProperties = { fontSize: 12, fontWeight: 600, color: 'var(--text2)', marginBottom: 4, display: 'block' } + +// ── Detail modal -- same page-local Modal shape AuditLogsPage.tsx's +// EventDetailModal / SessionsPage.tsx's SessionDetailModal already +// established; an Interaction isn't a manageable resource with its own +// page (a record to inspect, not edit), so a full navigation is the +// wrong shape here, same reasoning EventDetailModal's own comment +// gives. ────────────────────────────────────────────────────────────── +function InteractionDetailModal({ interaction, onClose }: { interaction: Interaction; onClose: () => void }) { + return ( +
{ if (e.target === e.currentTarget) onClose() }} + > +
+
+ {interaction.interaction_type} + +
+ +
+ {formatDate(interaction.created_at)} + {interaction.interaction_id} + {interaction.service} + {interaction.action} + {interaction.status ?? '—'} + {interaction.decision ?? '—'} + {`Org #${interaction.organization_id}`} + {interaction.user_id != null ? `User #${interaction.user_id}` : '—'} + {interaction.resource_type ? `${interaction.resource_type}${interaction.resource_id ? ` #${interaction.resource_id}` : ''}` : '—'} + {interaction.session_id ?? '—'} + {interaction.trace_id ?? '—'} +
+ + {interaction.metadata && ( + + )} + +
+ Sensitive-looking fields (secrets, tokens, hashes) are masked here as a defense-in-depth + measure -- the backend never writes one into an interaction's metadata in the first place. +
+
+
+ ) +} + +function Field({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ) +} + +function MetadataBlock({ title, value }: { title: string; value: Record | null }) { + if (!value || Object.keys(value).length === 0) return null + return ( +
+
{title}
+
+        {JSON.stringify(value, null, 2)}
+      
+
+ ) +} + +export default function InteractionsPage() { + const [data, setData] = useState(null) + const [denied, setDenied] = useState(false) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + const [page, setPage] = useState(1) + const [orgOptions, setOrgOptions] = useState(null) + const [orgFilter, setOrgFilter] = useState('') + const [userFilter, setUserFilter] = useState('') + const [serviceFilter, setServiceFilter] = useState('') + const [interactionTypeFilter, setInteractionTypeFilter] = useState('') + const [statusFilter, setStatusFilter] = useState('') + const [startDate, setStartDate] = useState('') + const [endDate, setEndDate] = useState('') + const [selected, setSelected] = useState(null) + + useEffect(() => { + fetchPlatformOrgs({ pageSize: 100 }).then(r => setOrgOptions(r.items)).catch(() => setOrgOptions(null)) + }, []) + + const load = () => { + setLoading(true) + setError(null) + setDenied(false) + fetchInteractions({ + page, pageSize: PAGE_SIZE, + organizationId: orgFilter ? Number(orgFilter) : undefined, + userId: userFilter ? Number(userFilter) : undefined, + service: serviceFilter || undefined, + interactionType: interactionTypeFilter || undefined, + status: statusFilter || undefined, + startDate: startDate || undefined, + endDate: endDate || undefined, + }) + .then(setData) + .catch((e: unknown) => { + const message = e instanceof Error ? e.message : String(e) + if (message.endsWith(' 403')) setDenied(true) + else setError(message) + }) + .finally(() => setLoading(false)) + } + + useEffect(load, [page, orgFilter, userFilter, serviceFilter, interactionTypeFilter, statusFilter, startDate, endDate]) + + const hasActiveFilters = !!(orgFilter || userFilter || serviceFilter || interactionTypeFilter || statusFilter || startDate || endDate) + const clearFilters = () => { + setOrgFilter(''); setUserFilter(''); setServiceFilter(''); setInteractionTypeFilter('') + setStatusFilter(''); setStartDate(''); setEndDate(''); setPage(1) + } + + return ( +
+ + + + } + /> + + {denied ? ( + + ) : ( + <> + +
+
+ + +
+
+ + { setUserFilter(e.target.value); setPage(1) }} + placeholder="e.g. 42" inputMode="numeric" + /> +
+
+ + { setServiceFilter(e.target.value); setPage(1) }} + placeholder="e.g. rag" + /> +
+
+ + { setInteractionTypeFilter(e.target.value); setPage(1) }} + placeholder="e.g. query" + /> +
+
+ + { setStatusFilter(e.target.value); setPage(1) }} + placeholder="e.g. success" + /> +
+
+ + { setStartDate(e.target.value); setPage(1) }} + /> +
+
+ + { setEndDate(e.target.value); setPage(1) }} + /> +
+ {hasActiveFilters && ( + + )} +
+
+ + {loading && } + {!loading && error && } + + {!loading && !error && data && ( + <> + i.id} + emptyLabel={hasActiveFilters ? 'No interactions match these filters.' : 'No interactions recorded yet.'} + rows={data.items} + columns={[ + { key: 'interaction_type', header: 'Interaction Type', render: i => i.interaction_type }, + { key: 'action', header: 'Action', render: i => i.action }, + { key: 'status', header: 'Status', render: i => i.status ?? '—' }, + { key: 'organization', header: 'Organization', render: i => `Org #${i.organization_id}` }, + { key: 'user', header: 'User', render: i => i.user_id != null ? `User #${i.user_id}` : '—' }, + { + key: 'details', header: 'Details', render: i => ( + + ), + }, + ]} + /> + + + )} + + )} + + {selected && setSelected(null)} />} +
+ ) +} diff --git a/frontend/cc-ui/tsconfig.app.tsbuildinfo b/frontend/cc-ui/tsconfig.app.tsbuildinfo index 4d37395..69ace59 100644 --- a/frontend/cc-ui/tsconfig.app.tsbuildinfo +++ b/frontend/cc-ui/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/api.ts","./src/audit.ts","./src/auth.test.ts","./src/auth.ts","./src/dashboard.ts","./src/main.tsx","./src/navigation.ts","./src/organizations.ts","./src/roles.ts","./src/security.ts","./src/serviceAccounts.ts","./src/sso.ts","./src/teams.ts","./src/test-setup.ts","./src/users.ts","./src/apps/AdminApp.test.tsx","./src/apps/AdminApp.tsx","./src/apps/AuthGate.tsx","./src/apps/ControlApp.test.tsx","./src/apps/ControlApp.tsx","./src/apps/UnknownModeNotice.test.tsx","./src/apps/UnknownModeNotice.tsx","./src/components/AccessDenied.test.tsx","./src/components/AccessDenied.tsx","./src/components/AdminLogo.tsx","./src/components/Header.tsx","./src/components/LoginScreen.test.tsx","./src/components/LoginScreen.tsx","./src/components/OAuthButtons.tsx","./src/components/StatusBadge.tsx","./src/components/dashboard/AlertCard.tsx","./src/components/dashboard/DashboardCard.tsx","./src/components/dashboard/DashboardGrid.tsx","./src/components/dashboard/HealthCard.tsx","./src/components/dashboard/MetricCard.tsx","./src/components/dashboard/StatusCard.tsx","./src/components/dashboard/TrendCard.tsx","./src/components/dashboard/dashboard-widgets.test.tsx","./src/components/dashboard/index.ts","./src/components/organizations/OrganizationStatusBadge.tsx","./src/components/organizations/OrganizationSummaryCard.tsx","./src/components/organizations/OrganizationTable.tsx","./src/components/organizations/SecuritySummaryCard.tsx","./src/components/roles/PermissionSelector.test.tsx","./src/components/roles/PermissionSelector.tsx","./src/components/roles/RoleAssignmentList.tsx","./src/components/roles/RoleBadge.tsx","./src/components/roles/RoleSelector.test.tsx","./src/components/roles/RoleSelector.tsx","./src/components/shell/AppShell.tsx","./src/components/shell/Breadcrumb.tsx","./src/components/shell/Footer.tsx","./src/components/shell/GlobalSearch.tsx","./src/components/shell/NotificationsMenu.tsx","./src/components/shell/OrgSelector.tsx","./src/components/shell/ProfileMenu.tsx","./src/components/shell/SidebarNav.tsx","./src/components/shell/ThemeToggle.tsx","./src/components/shell/TopAppBar.tsx","./src/components/shell/index.ts","./src/components/teams/TeamMemberSelector.test.tsx","./src/components/teams/TeamMemberSelector.tsx","./src/components/teams/TeamRow.tsx","./src/components/teams/TeamsCard.tsx","./src/components/ui/ActionToolbar.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/ComingSoon.tsx","./src/components/ui/DataTable.tsx","./src/components/ui/EmptyState.tsx","./src/components/ui/ErrorState.tsx","./src/components/ui/LoadingState.tsx","./src/components/ui/PageContainer.tsx","./src/components/ui/SectionHeader.tsx","./src/components/ui/StatCard.tsx","./src/components/ui/icon-type.ts","./src/components/ui/index.ts","./src/components/users/UserMFASecurityCard.test.tsx","./src/components/users/UserMFASecurityCard.tsx","./src/components/users/UserOrgMembershipList.tsx","./src/components/users/UserStatusAction.tsx","./src/pages/CloudPage.tsx","./src/pages/ConfigPage.tsx","./src/pages/DashboardPage.test.tsx","./src/pages/DashboardPage.tsx","./src/pages/DockerPage.tsx","./src/pages/EcosystemPage.tsx","./src/pages/HealthPage.tsx","./src/pages/LlmPage.tsx","./src/pages/OrganizationDetailPage.test.tsx","./src/pages/OrganizationDetailPage.tsx","./src/pages/OrganizationsPage.test.tsx","./src/pages/OrganizationsPage.tsx","./src/pages/UserDetailPage.test.tsx","./src/pages/UserDetailPage.tsx","./src/pages/UsersPage.test.tsx","./src/pages/UsersPage.tsx","./src/pages/audit/AuditLogsPage.test.tsx","./src/pages/audit/AuditLogsPage.tsx","./src/pages/identity/RolesPage.test.tsx","./src/pages/identity/RolesPage.tsx","./src/pages/identity/SSOSettingsPage.test.tsx","./src/pages/identity/SSOSettingsPage.tsx","./src/pages/identity/ServiceAccountsPage.test.tsx","./src/pages/identity/ServiceAccountsPage.tsx","./src/pages/identity/TeamsPage.test.tsx","./src/pages/identity/TeamsPage.tsx","./src/pages/security/OrganizationMFAPolicyPage.test.tsx","./src/pages/security/OrganizationMFAPolicyPage.tsx","./src/pages/security/SecurityDashboardPage.test.tsx","./src/pages/security/SecurityDashboardPage.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/api.ts","./src/audit.ts","./src/auth.test.ts","./src/auth.ts","./src/billing.ts","./src/dashboard.ts","./src/format.ts","./src/interactions.ts","./src/main.tsx","./src/model_registry.ts","./src/navigation.test.ts","./src/navigation.ts","./src/organizations.ts","./src/platform_config.ts","./src/rag.ts","./src/roles.ts","./src/security.ts","./src/serviceAccounts.ts","./src/sessions.ts","./src/sso.ts","./src/teams.ts","./src/tes.ts","./src/test-setup.ts","./src/users.ts","./src/workflows.ts","./src/apps/AdminApp.test.tsx","./src/apps/AdminApp.tsx","./src/apps/AuthGate.tsx","./src/apps/ControlApp.test.tsx","./src/apps/ControlApp.tsx","./src/apps/UnknownModeNotice.test.tsx","./src/apps/UnknownModeNotice.tsx","./src/components/AccessDenied.test.tsx","./src/components/AccessDenied.tsx","./src/components/AdminLogo.tsx","./src/components/Header.tsx","./src/components/LoginScreen.test.tsx","./src/components/LoginScreen.tsx","./src/components/OAuthButtons.tsx","./src/components/StatusBadge.tsx","./src/components/dashboard/AlertCard.tsx","./src/components/dashboard/DashboardCard.tsx","./src/components/dashboard/DashboardGrid.tsx","./src/components/dashboard/HealthCard.tsx","./src/components/dashboard/MetricCard.tsx","./src/components/dashboard/StatusCard.tsx","./src/components/dashboard/TrendCard.tsx","./src/components/dashboard/dashboard-widgets.test.tsx","./src/components/dashboard/index.ts","./src/components/organizations/OrganizationStatusBadge.tsx","./src/components/organizations/OrganizationSummaryCard.tsx","./src/components/organizations/OrganizationTable.tsx","./src/components/organizations/SecuritySummaryCard.tsx","./src/components/roles/PermissionSelector.test.tsx","./src/components/roles/PermissionSelector.tsx","./src/components/roles/RoleAssignmentList.tsx","./src/components/roles/RoleBadge.tsx","./src/components/roles/RoleSelector.test.tsx","./src/components/roles/RoleSelector.tsx","./src/components/shell/AppShell.tsx","./src/components/shell/Breadcrumb.tsx","./src/components/shell/Footer.tsx","./src/components/shell/GlobalSearch.tsx","./src/components/shell/NotificationsMenu.tsx","./src/components/shell/OrgSelector.tsx","./src/components/shell/ProfileMenu.tsx","./src/components/shell/SidebarNav.tsx","./src/components/shell/ThemeToggle.tsx","./src/components/shell/TopAppBar.tsx","./src/components/shell/index.ts","./src/components/teams/TeamMemberSelector.test.tsx","./src/components/teams/TeamMemberSelector.tsx","./src/components/teams/TeamRow.tsx","./src/components/teams/TeamsCard.tsx","./src/components/ui/ActionToolbar.tsx","./src/components/ui/BackLink.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/ComingSoon.tsx","./src/components/ui/DataTable.tsx","./src/components/ui/EmptyState.tsx","./src/components/ui/ErrorState.tsx","./src/components/ui/LoadingState.tsx","./src/components/ui/PageContainer.tsx","./src/components/ui/Pagination.tsx","./src/components/ui/SectionHeader.tsx","./src/components/ui/SessionExpiredState.tsx","./src/components/ui/StatCard.tsx","./src/components/ui/icon-type.ts","./src/components/ui/index.ts","./src/components/users/UserMFASecurityCard.test.tsx","./src/components/users/UserMFASecurityCard.tsx","./src/components/users/UserOrgMembershipList.tsx","./src/components/users/UserStatusAction.tsx","./src/pages/CloudPage.tsx","./src/pages/ConfigPage.tsx","./src/pages/DashboardPage.test.tsx","./src/pages/DashboardPage.tsx","./src/pages/DockerPage.tsx","./src/pages/EcosystemPage.tsx","./src/pages/HealthPage.tsx","./src/pages/InteractionsPage.test.tsx","./src/pages/InteractionsPage.tsx","./src/pages/LlmPage.tsx","./src/pages/OrganizationDetailPage.test.tsx","./src/pages/OrganizationDetailPage.tsx","./src/pages/OrganizationsPage.test.tsx","./src/pages/OrganizationsPage.tsx","./src/pages/PlatformSettingsPage.test.tsx","./src/pages/PlatformSettingsPage.tsx","./src/pages/UserDetailPage.test.tsx","./src/pages/UserDetailPage.tsx","./src/pages/UsersPage.test.tsx","./src/pages/UsersPage.tsx","./src/pages/audit/AuditLogsPage.test.tsx","./src/pages/audit/AuditLogsPage.tsx","./src/pages/billing/BillingPage.test.tsx","./src/pages/billing/BillingPage.tsx","./src/pages/billing/SubscriptionPage.test.tsx","./src/pages/billing/SubscriptionPage.tsx","./src/pages/identity/RolesPage.test.tsx","./src/pages/identity/RolesPage.tsx","./src/pages/identity/SSOSettingsPage.test.tsx","./src/pages/identity/SSOSettingsPage.tsx","./src/pages/identity/ServiceAccountsPage.test.tsx","./src/pages/identity/ServiceAccountsPage.tsx","./src/pages/identity/TeamsPage.test.tsx","./src/pages/identity/TeamsPage.tsx","./src/pages/operations/AIModelsPage.test.tsx","./src/pages/operations/AIModelsPage.tsx","./src/pages/operations/RAGPage.test.tsx","./src/pages/operations/RAGPage.tsx","./src/pages/operations/ToolExecutionPage.test.tsx","./src/pages/operations/ToolExecutionPage.tsx","./src/pages/operations/WorkflowsPage.test.tsx","./src/pages/operations/WorkflowsPage.tsx","./src/pages/security/OrganizationMFAPolicyPage.test.tsx","./src/pages/security/OrganizationMFAPolicyPage.tsx","./src/pages/security/SecurityDashboardPage.test.tsx","./src/pages/security/SecurityDashboardPage.tsx","./src/pages/security/SessionsPage.test.tsx","./src/pages/security/SessionsPage.tsx"],"version":"5.9.3"} \ No newline at end of file