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
This commit is contained in:
saravanakumardb1 2026-03-21 13:20:15 -07:00
parent c252cfd198
commit 10b48a3800
5 changed files with 760 additions and 0 deletions

View File

@ -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 ──

View File

@ -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<string, string> = {
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<string, string> = {
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 (
<Badge variant="secondary" className={riskColors[level] ?? riskColors.low}>
{level}
</Badge>
);
}
// ── Main Page ───────────────────────────────────────────────────────
export default function ActionTrailPage() {
const [actions, setActions] = useState<ActionItem[]>([]);
const [agents, setAgents] = useState<AgentItem[]>([]);
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [approvals, setApprovals] = useState<ApprovalItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [riskFilter, setRiskFilter] = useState<string>('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<AgentItem[]>('/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 (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">ActionTrail</h1>
<p className="text-muted-foreground">AI Agent Activity Oversight</p>
</div>
<div className="grid gap-4 md:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
))}
</div>
<Skeleton className="h-64 w-full" />
</div>
);
}
if (error && actions.length === 0) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">ActionTrail</h1>
<p className="text-muted-foreground">AI Agent Activity Oversight</p>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<AlertTriangle className="h-12 w-12 text-muted-foreground mb-4" />
<h2 className="text-lg font-semibold mb-2">ActionTrail Unavailable</h2>
<p className="text-sm text-muted-foreground mb-4">
Could not connect to the ActionTrail backend at port 4018. Make sure the service is
running.
</p>
<Button variant="outline" onClick={fetchData}>
<RefreshCw className="mr-2 h-4 w-4" />
Retry
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">ActionTrail</h1>
<p className="text-muted-foreground">
AI Agent Activity Oversight {actions.length} actions tracked
</p>
</div>
<Button variant="outline" size="sm" onClick={fetchData}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
</div>
{/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Total Actions</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{actions.length}</div>
<p className="text-xs text-muted-foreground">{agents.length} registered agents</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Critical / High</CardTitle>
<AlertTriangle className="h-4 w-4 text-destructive" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-destructive">{criticalCount + highCount}</div>
<p className="text-xs text-muted-foreground">
{criticalCount} critical, {highCount} high
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Pending Approvals</CardTitle>
<Clock className="h-4 w-4 text-amber-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{pendingApprovals}</div>
<p className="text-xs text-muted-foreground">Awaiting review</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Active Alerts</CardTitle>
<Shield className="h-4 w-4 text-amber-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{unacknowledgedAlerts}</div>
<p className="text-xs text-muted-foreground">Unacknowledged</p>
</CardContent>
</Card>
</div>
{/* Tabs: Timeline | Agents | Alerts | Approvals */}
<Tabs defaultValue="timeline" className="space-y-4">
<TabsList>
<TabsTrigger value="timeline">
<Activity className="mr-2 h-4 w-4" />
Timeline
</TabsTrigger>
<TabsTrigger value="agents">
<Bot className="mr-2 h-4 w-4" />
Agents ({agents.length})
</TabsTrigger>
<TabsTrigger value="alerts">
<AlertTriangle className="mr-2 h-4 w-4" />
Alerts ({unacknowledgedAlerts})
</TabsTrigger>
<TabsTrigger value="approvals">
<Clock className="mr-2 h-4 w-4" />
Approvals ({pendingApprovals})
</TabsTrigger>
</TabsList>
{/* ── Timeline Tab ──────────────────────────────────────── */}
<TabsContent value="timeline" className="space-y-4">
<div className="flex items-center gap-3">
<Select value={riskFilter} onValueChange={setRiskFilter}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Risk Level" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Risk Levels</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">{filteredActions.length} actions</span>
</div>
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[130px]">Time</TableHead>
<TableHead>Action</TableHead>
<TableHead>Agent</TableHead>
<TableHead>Category</TableHead>
<TableHead>Risk</TableHead>
<TableHead className="text-right">Score</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredActions.slice(0, 50).map(action => (
<TableRow
key={action.id}
className={
action.riskLevel === 'critical'
? 'bg-destructive/5'
: action.riskLevel === 'high'
? 'bg-orange-500/5'
: undefined
}
>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{formatTime(action.createdAt)}
</TableCell>
<TableCell>
<div className="text-sm font-medium">{action.action}</div>
{action.description && (
<div className="text-xs text-muted-foreground truncate max-w-[200px]">
{action.description}
</div>
)}
</TableCell>
<TableCell className="text-sm font-mono text-muted-foreground">
{action.agentId.slice(0, 8)}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{action.category}
</Badge>
</TableCell>
<TableCell>
<RiskBadge level={action.riskLevel} />
</TableCell>
<TableCell className="text-right font-mono text-sm">
{action.riskScore}
</TableCell>
<TableCell>
<Badge
variant="secondary"
className={
action.status === 'approved'
? 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300'
: action.status === 'rejected'
? 'bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300'
: ''
}
>
{action.status}
</Badge>
</TableCell>
</TableRow>
))}
{filteredActions.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
No actions found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
{/* ── Agents Tab ────────────────────────────────────────── */}
<TabsContent value="agents" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{agents.map(agent => (
<Card key={agent.id}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold">{agent.name}</CardTitle>
<Badge
variant={agent.status === 'active' ? 'default' : 'secondary'}
className={agent.status === 'active' ? 'bg-green-600' : ''}
>
{agent.status}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-2">
{agent.description && (
<p className="text-sm text-muted-foreground">{agent.description}</p>
)}
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className={trustColors[agent.trustLevel] ?? ''}>
{agent.trustLevel}
</Badge>
<Badge variant="outline">{agent.sourceProduct}</Badge>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground pt-1">
<span>{agent.actionCount} actions</span>
<span>Since {formatTime(agent.createdAt)}</span>
</div>
</CardContent>
</Card>
))}
{agents.length === 0 && (
<Card className="md:col-span-3">
<CardContent className="flex items-center justify-center py-12 text-muted-foreground">
<Bot className="mr-2 h-5 w-5" />
No agents registered yet.
</CardContent>
</Card>
)}
</div>
</TabsContent>
{/* ── Alerts Tab ────────────────────────────────────────── */}
<TabsContent value="alerts" className="space-y-4">
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[130px]">Time</TableHead>
<TableHead>Severity</TableHead>
<TableHead>Message</TableHead>
<TableHead className="text-right">Score</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{alerts.slice(0, 50).map(alert => (
<TableRow
key={alert.id}
className={alert.severity === 'critical' ? 'bg-destructive/5' : undefined}
>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{formatTime(alert.createdAt)}
</TableCell>
<TableCell>
<RiskBadge level={alert.severity} />
</TableCell>
<TableCell>
<div className="text-sm">{alert.message}</div>
{alert.reasons.length > 0 && (
<div className="text-xs text-muted-foreground">
{alert.reasons.join(', ')}
</div>
)}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{alert.riskScore}
</TableCell>
<TableCell>
{alert.acknowledged ? (
<Badge
variant="secondary"
className="bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300"
>
<CheckCircle2 className="mr-1 h-3 w-3" />
Ack
</Badge>
) : (
<Badge
variant="secondary"
className="bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300"
>
<Eye className="mr-1 h-3 w-3" />
Open
</Badge>
)}
</TableCell>
</TableRow>
))}
{alerts.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
No alerts triggered.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
{/* ── Approvals Tab ─────────────────────────────────────── */}
<TabsContent value="approvals" className="space-y-4">
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[130px]">Time</TableHead>
<TableHead>Action</TableHead>
<TableHead>Risk</TableHead>
<TableHead className="text-right">Score</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[130px]">Expires</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{approvals.slice(0, 50).map(approval => (
<TableRow key={approval.id}>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{formatTime(approval.createdAt)}
</TableCell>
<TableCell className="text-sm font-medium">{approval.action}</TableCell>
<TableCell>
<RiskBadge level={approval.riskLevel} />
</TableCell>
<TableCell className="text-right font-mono text-sm">
{approval.riskScore}
</TableCell>
<TableCell>
<Badge
variant="secondary"
className={
approval.status === 'approved'
? 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300'
: approval.status === 'rejected'
? 'bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300'
: 'bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300'
}
>
{approval.status === 'approved' && (
<CheckCircle2 className="mr-1 h-3 w-3" />
)}
{approval.status === 'rejected' && <XCircle className="mr-1 h-3 w-3" />}
{approval.status === 'pending' && <Clock className="mr-1 h-3 w-3" />}
{approval.status}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{formatTime(approval.expiresAt)}
</TableCell>
</TableRow>
))}
{approvals.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
No pending approvals.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -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 });
}
}

View File

@ -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 },

View File

@ -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<string, number>;
categoryDistribution: Record<string, number>;
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<AgentItem[]>('/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<InsightSummary>('/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');
}