From 10b48a3800f982a0f80acf6e16bce38776a20ab0 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 21 Mar 2026 13:20:15 -0700 Subject: [PATCH] feat(admin-web): add ActionTrail integration page - New /actiontrail page with 4 tabs: Timeline, Agents, Alerts, Approvals - Summary cards: total actions, critical/high count, pending approvals, active alerts - Risk-level filtering on timeline, color-coded risk badges - Server-side API proxy route (/api/actiontrail) to ActionTrail backend (port 4018) - actiontrail-client.ts: typed API client using @bytelyst/api-client - Sidebar nav item with Crosshair icon - ACTIONTRAIL_SERVICE_URL added to .env.example - Graceful fallback when ActionTrail service is unavailable --- dashboards/admin-web/.env.example | 1 + .../src/app/(dashboard)/actiontrail/page.tsx | 585 ++++++++++++++++++ .../src/app/api/actiontrail/route.ts | 49 ++ .../admin-web/src/components/sidebar-nav.tsx | 2 + .../admin-web/src/lib/actiontrail-client.ts | 123 ++++ 5 files changed, 760 insertions(+) create mode 100644 dashboards/admin-web/src/app/(dashboard)/actiontrail/page.tsx create mode 100644 dashboards/admin-web/src/app/api/actiontrail/route.ts create mode 100644 dashboards/admin-web/src/lib/actiontrail-client.ts diff --git a/dashboards/admin-web/.env.example b/dashboards/admin-web/.env.example index d792c57d..487f8155 100644 --- a/dashboards/admin-web/.env.example +++ b/dashboards/admin-web/.env.example @@ -20,6 +20,7 @@ NEXT_PUBLIC_GOOGLE_CLIENT_ID= # ── Microservice URLs (consolidated platform-service) ── PLATFORM_SERVICE_URL=http://localhost:4003 +ACTIONTRAIL_SERVICE_URL=http://localhost:4018 BILLING_INTERNAL_KEY= # ── Stripe ── diff --git a/dashboards/admin-web/src/app/(dashboard)/actiontrail/page.tsx b/dashboards/admin-web/src/app/(dashboard)/actiontrail/page.tsx new file mode 100644 index 00000000..8c1fcea5 --- /dev/null +++ b/dashboards/admin-web/src/app/(dashboard)/actiontrail/page.tsx @@ -0,0 +1,585 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { + Shield, + AlertTriangle, + Bot, + Clock, + Activity, + RefreshCw, + CheckCircle2, + XCircle, + Eye, +} from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { apiFetch } from '@/lib/api'; + +// ── Types (mirrors backend) ──────────────────────────────────────── + +interface ActionItem { + id: string; + agentId: string; + sourceProduct: string; + action: string; + category: string; + description: string; + riskLevel: 'low' | 'medium' | 'high' | 'critical'; + riskScore: number; + status: string; + tags: string[]; + createdAt: string; +} + +interface AgentItem { + id: string; + name: string; + description: string; + sourceProduct: string; + trustLevel: string; + status: string; + actionCount: number; + createdAt: string; +} + +interface AlertItem { + id: string; + actionId: string; + agentId: string; + severity: string; + message: string; + reasons: string[]; + riskScore: number; + acknowledged: boolean; + createdAt: string; +} + +interface ApprovalItem { + id: string; + actionId: string; + agentId: string; + action: string; + riskLevel: string; + riskScore: number; + status: string; + expiresAt: string; + createdAt: string; +} + +// ── Helpers ───────────────────────────────────────────────────────── + +const riskColors: Record = { + low: 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300', + medium: 'bg-yellow-50 text-yellow-700 dark:bg-yellow-950 dark:text-yellow-300', + high: 'bg-orange-50 text-orange-700 dark:bg-orange-950 dark:text-orange-300', + critical: 'bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300', +}; + +const trustColors: Record = { + sandbox: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300', + limited: 'bg-yellow-50 text-yellow-700 dark:bg-yellow-950 dark:text-yellow-300', + standard: 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300', + elevated: 'bg-purple-50 text-purple-700 dark:bg-purple-950 dark:text-purple-300', + admin: 'bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300', +}; + +function formatTime(ts: string): string { + return new Date(ts).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function RiskBadge({ level }: { level: string }) { + return ( + + {level} + + ); +} + +// ── Main Page ─────────────────────────────────────────────────────── + +export default function ActionTrailPage() { + const [actions, setActions] = useState([]); + const [agents, setAgents] = useState([]); + const [alerts, setAlerts] = useState([]); + const [approvals, setApprovals] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [riskFilter, setRiskFilter] = useState('all'); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [actionsRes, agentsRes, alertsRes, approvalsRes] = await Promise.all([ + apiFetch<{ items: ActionItem[] }>('/actiontrail?section=actions&limit=100'), + apiFetch('/actiontrail?section=agents'), + apiFetch<{ items: AlertItem[] }>('/actiontrail?section=alerts&limit=50'), + apiFetch<{ items: ApprovalItem[] }>('/actiontrail?section=approvals'), + ]); + + if (actionsRes.data?.items) setActions(actionsRes.data.items); + // Agents may come as array or wrapped + if (Array.isArray(agentsRes.data)) setAgents(agentsRes.data); + if (alertsRes.data?.items) setAlerts(alertsRes.data.items); + if (approvalsRes.data?.items) setApprovals(approvalsRes.data.items); + + if (actionsRes.error && agentsRes.error && alertsRes.error) { + setError('ActionTrail service unavailable'); + } + } catch { + setError('Failed to connect to ActionTrail'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // ── Computed stats ── + + const criticalCount = actions.filter(a => a.riskLevel === 'critical').length; + const highCount = actions.filter(a => a.riskLevel === 'high').length; + const pendingApprovals = approvals.filter(a => a.status === 'pending').length; + const unacknowledgedAlerts = alerts.filter(a => !a.acknowledged).length; + + const filteredActions = + riskFilter === 'all' ? actions : actions.filter(a => a.riskLevel === riskFilter); + + if (loading) { + return ( +
+
+

ActionTrail

+

AI Agent Activity Oversight

+
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + + + ))} +
+ +
+ ); + } + + if (error && actions.length === 0) { + return ( +
+
+

ActionTrail

+

AI Agent Activity Oversight

+
+ + + +

ActionTrail Unavailable

+

+ Could not connect to the ActionTrail backend at port 4018. Make sure the service is + running. +

+ +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

ActionTrail

+

+ AI Agent Activity Oversight — {actions.length} actions tracked +

+
+ +
+ + {/* Summary Cards */} +
+ + + Total Actions + + + +
{actions.length}
+

{agents.length} registered agents

+
+
+ + + Critical / High + + + +
{criticalCount + highCount}
+

+ {criticalCount} critical, {highCount} high +

+
+
+ + + Pending Approvals + + + +
{pendingApprovals}
+

Awaiting review

+
+
+ + + Active Alerts + + + +
{unacknowledgedAlerts}
+

Unacknowledged

+
+
+
+ + {/* Tabs: Timeline | Agents | Alerts | Approvals */} + + + + + Timeline + + + + Agents ({agents.length}) + + + + Alerts ({unacknowledgedAlerts}) + + + + Approvals ({pendingApprovals}) + + + + {/* ── Timeline Tab ──────────────────────────────────────── */} + +
+ + {filteredActions.length} actions +
+ + + + + + Time + Action + Agent + Category + Risk + Score + Status + + + + {filteredActions.slice(0, 50).map(action => ( + + + {formatTime(action.createdAt)} + + +
{action.action}
+ {action.description && ( +
+ {action.description} +
+ )} +
+ + {action.agentId.slice(0, 8)}… + + + + {action.category} + + + + + + + {action.riskScore} + + + + {action.status} + + +
+ ))} + {filteredActions.length === 0 && ( + + + No actions found. + + + )} +
+
+
+
+
+ + {/* ── Agents Tab ────────────────────────────────────────── */} + +
+ {agents.map(agent => ( + + +
+ {agent.name} + + {agent.status} + +
+
+ + {agent.description && ( +

{agent.description}

+ )} +
+ + {agent.trustLevel} + + {agent.sourceProduct} +
+
+ {agent.actionCount} actions + Since {formatTime(agent.createdAt)} +
+
+
+ ))} + {agents.length === 0 && ( + + + + No agents registered yet. + + + )} +
+
+ + {/* ── Alerts Tab ────────────────────────────────────────── */} + + + + + + + Time + Severity + Message + Score + Status + + + + {alerts.slice(0, 50).map(alert => ( + + + {formatTime(alert.createdAt)} + + + + + +
{alert.message}
+ {alert.reasons.length > 0 && ( +
+ {alert.reasons.join(', ')} +
+ )} +
+ + {alert.riskScore} + + + {alert.acknowledged ? ( + + + Ack + + ) : ( + + + Open + + )} + +
+ ))} + {alerts.length === 0 && ( + + + No alerts triggered. + + + )} +
+
+
+
+
+ + {/* ── Approvals Tab ─────────────────────────────────────── */} + + + + + + + Time + Action + Risk + Score + Status + Expires + + + + {approvals.slice(0, 50).map(approval => ( + + + {formatTime(approval.createdAt)} + + {approval.action} + + + + + {approval.riskScore} + + + + {approval.status === 'approved' && ( + + )} + {approval.status === 'rejected' && } + {approval.status === 'pending' && } + {approval.status} + + + + {formatTime(approval.expiresAt)} + + + ))} + {approvals.length === 0 && ( + + + No pending approvals. + + + )} + +
+
+
+
+
+
+ ); +} diff --git a/dashboards/admin-web/src/app/api/actiontrail/route.ts b/dashboards/admin-web/src/app/api/actiontrail/route.ts new file mode 100644 index 00000000..f7efbbd0 --- /dev/null +++ b/dashboards/admin-web/src/app/api/actiontrail/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logError } from '@/lib/logger'; +import { getCurrentUser } from '@/lib/auth-server'; +import * as actiontrailClient from '@/lib/actiontrail-client'; + +export async function GET(req: NextRequest) { + try { + const caller = await getCurrentUser(req.headers.get('authorization')); + if (!caller) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const url = new URL(req.url); + const section = url.searchParams.get('section') ?? 'actions'; + const userId = url.searchParams.get('userId') ?? caller.id; + const limit = parseInt(url.searchParams.get('limit') ?? '50', 10); + + switch (section) { + case 'actions': { + const result = await actiontrailClient.getActions(userId, limit); + return NextResponse.json(result); + } + case 'agents': { + const result = await actiontrailClient.getAgents(userId); + return NextResponse.json(result); + } + case 'alerts': { + const result = await actiontrailClient.getAlerts(userId, limit); + return NextResponse.json(result); + } + case 'approvals': { + const result = await actiontrailClient.getPendingApprovals(userId); + return NextResponse.json(result); + } + case 'summary': { + const result = await actiontrailClient.getInsightsSummary(userId); + return NextResponse.json(result); + } + case 'bootstrap': { + const result = await actiontrailClient.getBootstrap(); + return NextResponse.json(result); + } + default: + return NextResponse.json({ error: `Unknown section: ${section}` }, { status: 400 }); + } + } catch (error) { + logError('ActionTrail proxy error', error); + return NextResponse.json({ error: 'ActionTrail service unavailable' }, { status: 502 }); + } +} diff --git a/dashboards/admin-web/src/components/sidebar-nav.tsx b/dashboards/admin-web/src/components/sidebar-nav.tsx index 84385010..6b88b4ac 100644 --- a/dashboards/admin-web/src/components/sidebar-nav.tsx +++ b/dashboards/admin-web/src/components/sidebar-nav.tsx @@ -32,6 +32,7 @@ import { Megaphone, ClipboardList, Beaker, + Crosshair, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAuth } from '@/lib/auth-context'; @@ -57,6 +58,7 @@ const navItems = [ { href: '/docs', label: 'Docs & Runbooks', icon: BookOpen }, { href: '/flags', label: 'Feature Flags', icon: Settings }, { href: '/audit', label: 'Audit Log', icon: ScrollText }, + { href: '/actiontrail', label: 'ActionTrail', icon: Crosshair }, { href: '/ops', label: 'Mission Control', icon: Activity }, { href: '/ops/client-logs', label: 'Client Logs', icon: FileText }, { href: '/ops/telemetry-policies', label: 'Telemetry Policies', icon: Shield }, diff --git a/dashboards/admin-web/src/lib/actiontrail-client.ts b/dashboards/admin-web/src/lib/actiontrail-client.ts new file mode 100644 index 00000000..8110444c --- /dev/null +++ b/dashboards/admin-web/src/lib/actiontrail-client.ts @@ -0,0 +1,123 @@ +/** + * ActionTrail backend API client (server-side). + * Used by Next.js API routes to proxy requests to the ActionTrail backend. + */ + +import { createApiClient } from '@bytelyst/api-client'; + +const actiontrailApi = createApiClient({ + baseUrl: `${process.env.ACTIONTRAIL_SERVICE_URL || 'http://localhost:4018'}/api`, + defaultHeaders: { + 'x-product-id': 'actiontrail', + }, +}); + +// ── Types ───────────────────────────────────────────────────────── + +export interface ActionItem { + id: string; + userId: string; + agentId: string; + sourceProduct: string; + action: string; + category: string; + description: string; + riskLevel: 'low' | 'medium' | 'high' | 'critical'; + riskScore: number; + status: string; + correlationId?: string; + tags: string[]; + createdAt: string; + updatedAt: string; +} + +export interface AgentItem { + id: string; + userId: string; + name: string; + description: string; + sourceProduct: string; + trustLevel: string; + status: string; + allowedCategories: string[]; + maxRiskLevel: string; + actionCount: number; + createdAt: string; +} + +export interface AlertItem { + id: string; + userId: string; + actionId: string; + agentId: string; + severity: string; + message: string; + reasons: string[]; + riskScore: number; + acknowledged: boolean; + createdAt: string; +} + +export interface ApprovalItem { + id: string; + userId: string; + actionId: string; + agentId: string; + action: string; + riskLevel: string; + riskScore: number; + status: string; + expiresAt: string; + createdAt: string; +} + +export interface InsightSummary { + totalActions: number; + totalAgents: number; + totalAlerts: number; + riskDistribution: Record; + categoryDistribution: Record; + topAgents: Array<{ agentId: string; name: string; actionCount: number }>; +} + +// ── API Functions ───────────────────────────────────────────────── + +export async function getActions(userId: string, limit = 50) { + return actiontrailApi.fetch<{ items: ActionItem[]; cursor: string | null }>( + `/actions?limit=${limit}`, + { headers: { 'x-user-id': userId } } + ); +} + +export async function getAgents(userId: string) { + return actiontrailApi.fetch('/agents', { headers: { 'x-user-id': userId } }); +} + +export async function getAlerts(userId: string, limit = 50) { + return actiontrailApi.fetch<{ items: AlertItem[]; cursor: string | null }>( + `/alerts?limit=${limit}`, + { headers: { 'x-user-id': userId } } + ); +} + +export async function getPendingApprovals(userId: string) { + return actiontrailApi.fetch<{ items: ApprovalItem[]; cursor: string | null }>( + '/approvals?status=pending', + { headers: { 'x-user-id': userId } } + ); +} + +export async function getInsightsSummary(userId: string) { + return actiontrailApi.fetch('/insights/summary', { + headers: { 'x-user-id': userId }, + }); +} + +export async function getBootstrap() { + return actiontrailApi.fetch<{ + productId: string; + displayName: string; + domain: string; + surfaces: { primary: string; mobile: boolean }; + }>('/bootstrap'); +}