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:
parent
c252cfd198
commit
10b48a3800
@ -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 ──
|
||||
|
||||
585
dashboards/admin-web/src/app/(dashboard)/actiontrail/page.tsx
Normal file
585
dashboards/admin-web/src/app/(dashboard)/actiontrail/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
dashboards/admin-web/src/app/api/actiontrail/route.ts
Normal file
49
dashboards/admin-web/src/app/api/actiontrail/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
|
||||
123
dashboards/admin-web/src/lib/actiontrail-client.ts
Normal file
123
dashboards/admin-web/src/lib/actiontrail-client.ts
Normal 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');
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user