feat(admin-web): Phase 2 — webhooks, knowledge, agent-evals, reviews, marketplace, delivery, jobs pages

This commit is contained in:
saravanakumardb1 2026-03-21 17:53:44 -07:00
parent aa33b12f8c
commit aaceba2ee5
16 changed files with 2406 additions and 0 deletions

View File

@ -0,0 +1,307 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
FlaskConical,
Plus,
MoreHorizontal,
Play,
CheckCircle2,
XCircle,
Clock,
Trash2,
} 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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface EvalSuite {
id: string;
name: string;
description?: string;
agentId?: string;
testCaseCount: number;
lastRunStatus?: string;
lastRunScore?: number;
lastRunAt?: string;
createdAt: string;
}
const statusConfig: Record<string, { color: string; icon: typeof Clock }> = {
passed: { color: 'bg-emerald-50 text-emerald-700', icon: CheckCircle2 },
failed: { color: 'bg-red-50 text-red-700', icon: XCircle },
running: { color: 'bg-blue-50 text-blue-700', icon: Clock },
pending: { color: 'bg-gray-50 text-gray-600', icon: Clock },
};
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
async function apiFetch(path: string, opts?: RequestInit) {
const res = await fetch(`/api/agent-evals/${path}`, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
return res.json();
}
export default function AgentEvalsPage() {
const [suites, setSuites] = useState<EvalSuite[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
const loadData = useCallback(async () => {
setLoading(true);
const data = await apiFetch('suites');
setSuites(Array.isArray(data?.suites) ? data.suites : Array.isArray(data) ? data : []);
setLoading(false);
}, []);
useEffect(() => {
void loadData();
}, [loadData]);
async function handleCreate() {
setCreating(true);
await apiFetch('suites', {
method: 'POST',
body: JSON.stringify({ name: newName, description: newDesc }),
});
setCreating(false);
setShowCreate(false);
setNewName('');
setNewDesc('');
loadData();
}
async function handleRun(id: string) {
await apiFetch(`suites/${id}/run`, { method: 'POST' });
loadData();
}
async function handleDelete(id: string) {
if (!confirm('Delete this evaluation suite?')) return;
await apiFetch(`suites/${id}`, { method: 'DELETE' });
loadData();
}
const passedCount = suites.filter(s => s.lastRunStatus === 'passed').length;
const failedCount = suites.filter(s => s.lastRunStatus === 'failed').length;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Agent Evaluations</h1>
<p className="text-sm text-muted-foreground">
Create and run evaluation suites for AI agents
</p>
</div>
<Button onClick={() => setShowCreate(true)}>
<Plus className="mr-2 h-4 w-4" /> New Eval Suite
</Button>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Suites
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{suites.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Passed</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-emerald-600">{passedCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Failed</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{failedCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Test Cases
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{suites.reduce((sum, s) => sum + (s.testCaseCount ?? 0), 0)}
</div>
</CardContent>
</Card>
</div>
<Card>
<CardContent className="p-0">
{loading ? (
<div className="p-8 text-center text-muted-foreground">Loading...</div>
) : suites.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<FlaskConical className="mx-auto mb-2 h-8 w-8 opacity-40" />
No evaluation suites yet.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Suite</TableHead>
<TableHead>Test Cases</TableHead>
<TableHead>Last Run</TableHead>
<TableHead>Score</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{suites.map(s => {
const cfg = statusConfig[s.lastRunStatus || 'pending'] || statusConfig.pending;
const StatusIcon = cfg.icon;
return (
<TableRow key={s.id}>
<TableCell>
<div className="font-medium">{s.name}</div>
{s.description && (
<p className="text-xs text-muted-foreground truncate max-w-[250px]">
{s.description}
</p>
)}
</TableCell>
<TableCell>{s.testCaseCount}</TableCell>
<TableCell>
{s.lastRunStatus ? (
<Badge className={`${cfg.color} border-0 gap-1`}>
<StatusIcon className="h-3 w-3" />
{s.lastRunStatus}
</Badge>
) : (
<span className="text-xs text-muted-foreground">Never run</span>
)}
</TableCell>
<TableCell>
{s.lastRunScore != null ? (
<span
className={`font-bold ${(s.lastRunScore ?? 0) >= 80 ? 'text-emerald-600' : (s.lastRunScore ?? 0) >= 50 ? 'text-amber-600' : 'text-red-600'}`}
>
{s.lastRunScore}%
</span>
) : (
'—'
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(s.createdAt)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleRun(s.id)}>
<Play className="mr-2 h-4 w-4" />
Run
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(s.id)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogContent>
<DialogHeader>
<DialogTitle>New Evaluation Suite</DialogTitle>
<DialogDescription>Create a test suite to evaluate AI agent quality.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Suite Name</Label>
<Input
placeholder="e.g. Customer Support Agent v2"
value={newName}
onChange={e => setNewName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
placeholder="What this suite tests..."
value={newDesc}
onChange={e => setNewDesc(e.target.value)}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreate(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={creating || !newName.trim()}>
{creating ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,220 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Mail, Search, CheckCircle2, XCircle, Clock, RotateCcw } 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 { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface DeliveryEntry {
id: string;
template: string;
recipient: string;
channel: string;
status: string;
provider?: string;
attempt: number;
error?: string;
createdAt: string;
}
const statusConfig: Record<string, { color: string }> = {
delivered: { color: 'bg-emerald-50 text-emerald-700' },
failed: { color: 'bg-red-50 text-red-700' },
pending: { color: 'bg-amber-50 text-amber-700' },
retrying: { color: 'bg-blue-50 text-blue-700' },
};
function formatDate(iso: string) {
return new Date(iso).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
async function apiFetch(path: string, opts?: RequestInit) {
const res = await fetch(`/api/delivery/${path}`, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
return res.json();
}
export default function DeliveryPage() {
const [entries, setEntries] = useState<DeliveryEntry[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('all');
const loadData = useCallback(async () => {
setLoading(true);
const params = new URLSearchParams();
if (statusFilter !== 'all') params.set('status', statusFilter);
const qs = params.toString() ? `?${params.toString()}` : '';
const data = await apiFetch(`log${qs}`);
setEntries(Array.isArray(data?.entries) ? data.entries : Array.isArray(data) ? data : []);
setLoading(false);
}, [statusFilter]);
useEffect(() => {
void loadData();
}, [loadData]);
async function handleRetry(id: string) {
await apiFetch(`log/${id}/retry`, { method: 'POST' });
loadData();
}
const deliveredCount = entries.filter(e => e.status === 'delivered').length;
const failedCount = entries.filter(e => e.status === 'failed').length;
const rate = entries.length > 0 ? Math.round((deliveredCount / entries.length) * 100) : 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Email Delivery Log</h1>
<p className="text-sm text-muted-foreground">
Monitor transactional email delivery, retry failures
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Sent</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{entries.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Delivered</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-emerald-600">{deliveredCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Failed</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{failedCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Success Rate
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{rate}%</div>
</CardContent>
</Card>
</div>
<div className="flex items-center gap-4">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="delivered">Delivered</SelectItem>
<SelectItem value="failed">Failed</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="retrying">Retrying</SelectItem>
</SelectContent>
</Select>
</div>
<Card>
<CardContent className="p-0">
{loading ? (
<div className="p-8 text-center text-muted-foreground">Loading...</div>
) : entries.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<Mail className="mx-auto mb-2 h-8 w-8 opacity-40" />
No delivery entries found.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Template</TableHead>
<TableHead>Recipient</TableHead>
<TableHead>Channel</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Status</TableHead>
<TableHead>Attempt</TableHead>
<TableHead>Sent</TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map(e => {
const cfg = statusConfig[e.status] || statusConfig.pending;
return (
<TableRow key={e.id}>
<TableCell className="font-medium text-sm">{e.template}</TableCell>
<TableCell className="text-sm">{e.recipient}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs capitalize">
{e.channel}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{e.provider || '—'}
</TableCell>
<TableCell>
<Badge className={`${cfg.color} border-0`}>{e.status}</Badge>
</TableCell>
<TableCell className="text-xs">{e.attempt}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(e.createdAt)}
</TableCell>
<TableCell>
{e.status === 'failed' && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleRetry(e.id)}
title="Retry"
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,317 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Timer, Play, MoreHorizontal, CheckCircle2, XCircle, Clock, RefreshCw } 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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface Job {
id: string;
name: string;
schedule: string;
description?: string;
enabled: boolean;
lastRunAt?: string;
lastRunStatus?: string;
nextRunAt?: string;
}
interface Run {
id: string;
jobId: string;
jobName?: string;
status: string;
duration?: number;
error?: string;
startedAt: string;
completedAt?: string;
}
const statusConfig: Record<string, { color: string }> = {
success: { color: 'bg-emerald-50 text-emerald-700' },
failed: { color: 'bg-red-50 text-red-700' },
running: { color: 'bg-blue-50 text-blue-700' },
skipped: { color: 'bg-gray-50 text-gray-600' },
};
function formatDate(iso: string) {
return new Date(iso).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
async function jobsFetch(path: string, opts?: RequestInit) {
const res = await fetch(`/api/jobs/${path}`, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
return res.json();
}
async function runsFetch(path: string, opts?: RequestInit) {
const res = await fetch(`/api/runs/${path}`, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
return res.json();
}
export default function JobsPage() {
const [jobs, setJobs] = useState<Job[]>([]);
const [runs, setRuns] = useState<Run[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<'jobs' | 'runs'>('jobs');
const loadData = useCallback(async () => {
setLoading(true);
const [jData, rData] = await Promise.all([jobsFetch('list'), runsFetch('list?limit=50')]);
setJobs(Array.isArray(jData?.jobs) ? jData.jobs : Array.isArray(jData) ? jData : []);
setRuns(Array.isArray(rData?.runs) ? rData.runs : Array.isArray(rData) ? rData : []);
setLoading(false);
}, []);
useEffect(() => {
void loadData();
}, [loadData]);
async function handleTrigger(id: string) {
await jobsFetch(`${id}/trigger`, { method: 'POST' });
loadData();
}
async function handleToggle(job: Job) {
await jobsFetch(`${job.id}`, {
method: 'PATCH',
body: JSON.stringify({ enabled: !job.enabled }),
});
loadData();
}
const enabledCount = jobs.filter(j => j.enabled).length;
const recentFailures = runs.filter(r => r.status === 'failed').length;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Scheduled Jobs</h1>
<p className="text-sm text-muted-foreground">
View registered jobs, trigger runs, and inspect execution history
</p>
</div>
<Button variant="outline" onClick={loadData}>
<RefreshCw className="mr-2 h-4 w-4" /> Refresh
</Button>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Jobs</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{jobs.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Enabled</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-emerald-600">{enabledCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Recent Runs</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{runs.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Failures</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{recentFailures}</div>
</CardContent>
</Card>
</div>
<div className="flex gap-2">
<Button
variant={tab === 'jobs' ? 'default' : 'outline'}
size="sm"
onClick={() => setTab('jobs')}
>
Jobs ({jobs.length})
</Button>
<Button
variant={tab === 'runs' ? 'default' : 'outline'}
size="sm"
onClick={() => setTab('runs')}
>
Runs ({runs.length})
</Button>
</div>
{tab === 'jobs' && (
<Card>
<CardContent className="p-0">
{loading ? (
<div className="p-8 text-center text-muted-foreground">Loading...</div>
) : jobs.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<Timer className="mx-auto mb-2 h-8 w-8 opacity-40" />
No scheduled jobs registered.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Job</TableHead>
<TableHead>Schedule</TableHead>
<TableHead>Enabled</TableHead>
<TableHead>Last Run</TableHead>
<TableHead>Next Run</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{jobs.map(j => (
<TableRow key={j.id}>
<TableCell>
<div className="font-medium">{j.name}</div>
{j.description && (
<p className="text-xs text-muted-foreground">{j.description}</p>
)}
</TableCell>
<TableCell>
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">{j.schedule}</code>
</TableCell>
<TableCell>
<Badge
className={
j.enabled
? 'bg-emerald-50 text-emerald-700 border-0'
: 'bg-gray-50 text-gray-600 border-0'
}
>
{j.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</TableCell>
<TableCell>
{j.lastRunAt ? (
<div>
<span className="text-xs text-muted-foreground">
{formatDate(j.lastRunAt)}
</span>
{j.lastRunStatus && (
<Badge
className={`ml-2 text-xs ${(statusConfig[j.lastRunStatus] || statusConfig.skipped).color} border-0`}
>
{j.lastRunStatus}
</Badge>
)}
</div>
) : (
<span className="text-xs text-muted-foreground">Never</span>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{j.nextRunAt ? formatDate(j.nextRunAt) : '—'}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleTrigger(j.id)}>
<Play className="mr-2 h-4 w-4" />
Trigger Now
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleToggle(j)}>
{j.enabled ? 'Disable' : 'Enable'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)}
{tab === 'runs' && (
<Card>
<CardContent className="p-0">
{runs.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">No job runs yet.</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Job</TableHead>
<TableHead>Status</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Started</TableHead>
<TableHead>Error</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map(r => {
const cfg = statusConfig[r.status] || statusConfig.skipped;
return (
<TableRow key={r.id}>
<TableCell className="font-medium text-sm">
{r.jobName || r.jobId}
</TableCell>
<TableCell>
<Badge className={`${cfg.color} border-0`}>{r.status}</Badge>
</TableCell>
<TableCell className="text-xs">
{r.duration != null ? `${r.duration}ms` : '—'}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(r.startedAt)}
</TableCell>
<TableCell className="text-xs text-red-600 max-w-[200px] truncate">
{r.error || '—'}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -0,0 +1,278 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { BookOpen, Plus, MoreHorizontal, Search, FileText, Trash2, Upload } 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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface KnowledgeBase {
id: string;
name: string;
description?: string;
documentCount: number;
status: string;
createdAt: string;
updatedAt: string;
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
async function apiFetch(path: string, opts?: RequestInit) {
const res = await fetch(`/api/knowledge/${path}`, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
return res.json();
}
export default function KnowledgePage() {
const [bases, setBases] = useState<KnowledgeBase[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
const loadData = useCallback(async () => {
setLoading(true);
const qs = search ? `?search=${encodeURIComponent(search)}` : '';
const data = await apiFetch(`bases${qs}`);
setBases(Array.isArray(data?.bases) ? data.bases : Array.isArray(data) ? data : []);
setLoading(false);
}, [search]);
useEffect(() => {
void loadData();
}, [loadData]);
async function handleCreate() {
setCreating(true);
await apiFetch('bases', {
method: 'POST',
body: JSON.stringify({ name: newName, description: newDesc }),
});
setCreating(false);
setShowCreate(false);
setNewName('');
setNewDesc('');
loadData();
}
async function handleDelete(id: string) {
if (!confirm('Delete this knowledge base and all its documents?')) return;
await apiFetch(`bases/${id}`, { method: 'DELETE' });
loadData();
}
const totalDocs = bases.reduce((sum, b) => sum + (b.documentCount ?? 0), 0);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Knowledge Bases</h1>
<p className="text-sm text-muted-foreground">
Manage RAG knowledge bases and documents for AI agents
</p>
</div>
<Button onClick={() => setShowCreate(true)}>
<Plus className="mr-2 h-4 w-4" /> Create Knowledge Base
</Button>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Knowledge Bases
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{bases.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Documents
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalDocs}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Active</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-emerald-600">
{bases.filter(b => b.status === 'active').length}
</div>
</CardContent>
</Card>
</div>
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search knowledge bases..."
value={search}
onChange={e => setSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
<Card>
<CardContent className="p-0">
{loading ? (
<div className="p-8 text-center text-muted-foreground">Loading...</div>
) : bases.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<BookOpen className="mx-auto mb-2 h-8 w-8 opacity-40" />
No knowledge bases yet.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Documents</TableHead>
<TableHead>Status</TableHead>
<TableHead>Updated</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{bases.map(b => (
<TableRow key={b.id}>
<TableCell>
<div className="font-medium">{b.name}</div>
{b.description && (
<p className="text-xs text-muted-foreground truncate max-w-[300px]">
{b.description}
</p>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
{b.documentCount}
</div>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={
b.status === 'active' ? 'bg-emerald-50 text-emerald-700 border-0' : ''
}
>
{b.status}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(b.updatedAt)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Upload className="mr-2 h-4 w-4" />
Upload Documents
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(b.id)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Knowledge Base</DialogTitle>
<DialogDescription>Create a new knowledge base for AI agent RAG.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input
placeholder="e.g. Product Documentation"
value={newName}
onChange={e => setNewName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
placeholder="What this knowledge base contains..."
value={newDesc}
onChange={e => setNewDesc(e.target.value)}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreate(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={creating || !newName.trim()}>
{creating ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,274 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
Store,
MoreHorizontal,
Search,
CheckCircle2,
XCircle,
Clock,
Eye,
ThumbsUp,
ThumbsDown,
} 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 { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface Listing {
id: string;
title: string;
description?: string;
publisherName: string;
category: string;
status: string;
price?: number;
downloadCount: number;
rating?: number;
createdAt: string;
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
async function apiFetch(path: string, opts?: RequestInit) {
const res = await fetch(`/api/marketplace/${path}`, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
return res.json();
}
export default function MarketplacePage() {
const [listings, setListings] = useState<Listing[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('all');
const [search, setSearch] = useState('');
const loadData = useCallback(async () => {
setLoading(true);
const params = new URLSearchParams();
if (statusFilter !== 'all') params.set('status', statusFilter);
if (search) params.set('search', search);
const qs = params.toString() ? `?${params.toString()}` : '';
const data = await apiFetch(`listings${qs}`);
setListings(Array.isArray(data?.listings) ? data.listings : Array.isArray(data) ? data : []);
setLoading(false);
}, [statusFilter, search]);
useEffect(() => {
void loadData();
}, [loadData]);
async function handleApprove(id: string) {
await apiFetch(`listings/${id}/approve`, { method: 'POST' });
loadData();
}
async function handleReject(id: string) {
await apiFetch(`listings/${id}/reject`, { method: 'POST' });
loadData();
}
const pendingCount = listings.filter(l => l.status === 'pending_review').length;
const publishedCount = listings.filter(l => l.status === 'published').length;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Marketplace Admin</h1>
<p className="text-sm text-muted-foreground">
Review, approve, and manage marketplace listings
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Listings
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{listings.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Pending Review
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-amber-600">{pendingCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Published</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-emerald-600">{publishedCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Downloads
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{listings.reduce((sum, l) => sum + (l.downloadCount ?? 0), 0).toLocaleString()}
</div>
</CardContent>
</Card>
</div>
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search listings..."
value={search}
onChange={e => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="pending_review">Pending Review</SelectItem>
<SelectItem value="published">Published</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="suspended">Suspended</SelectItem>
</SelectContent>
</Select>
</div>
<Card>
<CardContent className="p-0">
{loading ? (
<div className="p-8 text-center text-muted-foreground">Loading...</div>
) : listings.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<Store className="mx-auto mb-2 h-8 w-8 opacity-40" />
No marketplace listings found.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Listing</TableHead>
<TableHead>Publisher</TableHead>
<TableHead>Category</TableHead>
<TableHead>Price</TableHead>
<TableHead>Downloads</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{listings.map(l => (
<TableRow key={l.id}>
<TableCell>
<div className="font-medium">{l.title}</div>
{l.description && (
<p className="text-xs text-muted-foreground truncate max-w-[250px]">
{l.description}
</p>
)}
</TableCell>
<TableCell className="text-sm">{l.publisherName}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs capitalize">
{l.category}
</Badge>
</TableCell>
<TableCell>{l.price ? `$${l.price.toFixed(2)}` : 'Free'}</TableCell>
<TableCell>{l.downloadCount.toLocaleString()}</TableCell>
<TableCell>
<Badge
className={
l.status === 'published'
? 'bg-emerald-50 text-emerald-700 border-0'
: l.status === 'pending_review'
? 'bg-amber-50 text-amber-700 border-0'
: l.status === 'rejected'
? 'bg-red-50 text-red-700 border-0'
: 'bg-gray-50 text-gray-600 border-0'
}
>
{l.status.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(l.createdAt)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{l.status === 'pending_review' && (
<DropdownMenuItem onClick={() => handleApprove(l.id)}>
<ThumbsUp className="mr-2 h-4 w-4" />
Approve
</DropdownMenuItem>
)}
{l.status === 'pending_review' && (
<DropdownMenuItem onClick={() => handleReject(l.id)}>
<ThumbsDown className="mr-2 h-4 w-4" />
Reject
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,281 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
Star,
ThumbsUp,
ThumbsDown,
MoreHorizontal,
Search,
CheckCircle2,
Clock,
Flag,
} 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 { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface Review {
id: string;
userId: string;
entityType: string;
entityId: string;
rating: number;
title?: string;
body?: string;
status: string;
flagged: boolean;
createdAt: string;
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
async function apiFetch(path: string, opts?: RequestInit) {
const res = await fetch(`/api/reviews/${path}`, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
return res.json();
}
export default function ReviewsPage() {
const [reviews, setReviews] = useState<Review[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('all');
const loadData = useCallback(async () => {
setLoading(true);
const params = new URLSearchParams();
if (statusFilter !== 'all') params.set('status', statusFilter);
const qs = params.toString() ? `?${params.toString()}` : '';
const data = await apiFetch(`list${qs}`);
setReviews(Array.isArray(data?.reviews) ? data.reviews : Array.isArray(data) ? data : []);
setTotal(data?.total ?? (Array.isArray(data?.reviews) ? data.reviews.length : 0));
setLoading(false);
}, [statusFilter]);
useEffect(() => {
void loadData();
}, [loadData]);
async function handleApprove(id: string) {
await apiFetch(`${id}/approve`, { method: 'POST' });
loadData();
}
async function handleReject(id: string) {
await apiFetch(`${id}/reject`, { method: 'POST' });
loadData();
}
async function handleFlag(id: string) {
await apiFetch(`${id}/flag`, { method: 'POST' });
loadData();
}
const pendingCount = reviews.filter(r => r.status === 'pending').length;
const flaggedCount = reviews.filter(r => r.flagged).length;
const avgRating =
reviews.length > 0
? (reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length).toFixed(1)
: '—';
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Reviews & Ratings</h1>
<p className="text-sm text-muted-foreground">
Moderate user reviews, approve/reject, and handle flagged content
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Reviews
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{total}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Pending</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-amber-600">{pendingCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Flagged</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{flaggedCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Avg Rating</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<div className="text-2xl font-bold">{avgRating}</div>
<Star className="h-5 w-5 text-amber-400 fill-amber-400" />
</div>
</CardContent>
</Card>
</div>
<div className="flex items-center gap-4">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
</SelectContent>
</Select>
</div>
<Card>
<CardContent className="p-0">
{loading ? (
<div className="p-8 text-center text-muted-foreground">Loading...</div>
) : reviews.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<Star className="mx-auto mb-2 h-8 w-8 opacity-40" />
No reviews found.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Review</TableHead>
<TableHead>Rating</TableHead>
<TableHead>Entity</TableHead>
<TableHead>Status</TableHead>
<TableHead>Date</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{reviews.map(r => (
<TableRow key={r.id} className={r.flagged ? 'bg-red-50/50' : ''}>
<TableCell>
<div className="font-medium">
{r.title || 'No title'}
{r.flagged && <Flag className="inline ml-1 h-3 w-3 text-red-500" />}
</div>
{r.body && (
<p className="text-xs text-muted-foreground truncate max-w-[300px]">
{r.body}
</p>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }, (_, i) => (
<Star
key={i}
className={`h-3.5 w-3.5 ${i < r.rating ? 'text-amber-400 fill-amber-400' : 'text-gray-200'}`}
/>
))}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs capitalize">
{r.entityType}
</Badge>
</TableCell>
<TableCell>
<Badge
className={
r.status === 'approved'
? 'bg-emerald-50 text-emerald-700 border-0'
: r.status === 'rejected'
? 'bg-red-50 text-red-700 border-0'
: 'bg-amber-50 text-amber-700 border-0'
}
>
{r.status}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(r.createdAt)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{r.status === 'pending' && (
<DropdownMenuItem onClick={() => handleApprove(r.id)}>
<ThumbsUp className="mr-2 h-4 w-4" />
Approve
</DropdownMenuItem>
)}
{r.status === 'pending' && (
<DropdownMenuItem onClick={() => handleReject(r.id)}>
<ThumbsDown className="mr-2 h-4 w-4" />
Reject
</DropdownMenuItem>
)}
{!r.flagged && (
<DropdownMenuItem onClick={() => handleFlag(r.id)}>
<Flag className="mr-2 h-4 w-4" />
Flag
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,366 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
Webhook,
Plus,
MoreHorizontal,
Search,
CheckCircle2,
XCircle,
Clock,
RotateCcw,
Trash2,
} 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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface WebhookSub {
id: string;
url: string;
events: string[];
status: string;
failureCount: number;
createdAt: string;
}
interface Delivery {
id: string;
subscriptionId: string;
event: string;
status: string;
statusCode?: number;
attempt: number;
createdAt: string;
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
async function apiFetch(path: string, opts?: RequestInit) {
const res = await fetch(`/api/webhooks/${path}`, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
return res.json();
}
export default function WebhooksPage() {
const [subs, setSubs] = useState<WebhookSub[]>([]);
const [deliveries, setDeliveries] = useState<Delivery[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<'subscriptions' | 'deliveries'>('subscriptions');
const [showCreate, setShowCreate] = useState(false);
const [creating, setCreating] = useState(false);
const [newUrl, setNewUrl] = useState('');
const [newEvents, setNewEvents] = useState('');
const loadData = useCallback(async () => {
setLoading(true);
const [sData, dData] = await Promise.all([apiFetch('subscriptions'), apiFetch('deliveries')]);
setSubs(
Array.isArray(sData?.subscriptions) ? sData.subscriptions : Array.isArray(sData) ? sData : []
);
setDeliveries(
Array.isArray(dData?.deliveries) ? dData.deliveries : Array.isArray(dData) ? dData : []
);
setLoading(false);
}, []);
useEffect(() => {
void loadData();
}, [loadData]);
async function handleCreate() {
setCreating(true);
await apiFetch('subscriptions', {
method: 'POST',
body: JSON.stringify({
url: newUrl,
events: newEvents
.split(',')
.map(e => e.trim())
.filter(Boolean),
}),
});
setCreating(false);
setShowCreate(false);
setNewUrl('');
setNewEvents('');
loadData();
}
async function handleDelete(id: string) {
if (!confirm('Delete this webhook subscription?')) return;
await apiFetch(`subscriptions/${id}`, { method: 'DELETE' });
loadData();
}
async function handleTest(id: string) {
await apiFetch(`subscriptions/${id}/test`, { method: 'POST' });
loadData();
}
const activeCount = subs.filter(s => s.status === 'active').length;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Webhooks</h1>
<p className="text-sm text-muted-foreground">
Manage webhook subscriptions and view delivery logs
</p>
</div>
<Button onClick={() => setShowCreate(true)}>
<Plus className="mr-2 h-4 w-4" /> Add Webhook
</Button>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Subscriptions
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{subs.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Active</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-emerald-600">{activeCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Recent Deliveries
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{deliveries.length}</div>
</CardContent>
</Card>
</div>
<div className="flex gap-2">
<Button
variant={tab === 'subscriptions' ? 'default' : 'outline'}
size="sm"
onClick={() => setTab('subscriptions')}
>
Subscriptions
</Button>
<Button
variant={tab === 'deliveries' ? 'default' : 'outline'}
size="sm"
onClick={() => setTab('deliveries')}
>
Delivery Log
</Button>
</div>
{tab === 'subscriptions' && (
<Card>
<CardContent className="p-0">
{loading ? (
<div className="p-8 text-center text-muted-foreground">Loading...</div>
) : subs.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<Webhook className="mx-auto mb-2 h-8 w-8 opacity-40" />
No webhook subscriptions.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>URL</TableHead>
<TableHead>Events</TableHead>
<TableHead>Status</TableHead>
<TableHead>Failures</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{subs.map(s => (
<TableRow key={s.id}>
<TableCell className="font-mono text-xs max-w-[300px] truncate">
{s.url}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{s.events.map(e => (
<Badge key={e} variant="outline" className="text-xs">
{e}
</Badge>
))}
</div>
</TableCell>
<TableCell>
<Badge
className={
s.status === 'active'
? 'bg-emerald-50 text-emerald-700 border-0'
: 'bg-red-50 text-red-700 border-0'
}
>
{s.status}
</Badge>
</TableCell>
<TableCell>{s.failureCount}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(s.createdAt)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleTest(s.id)}>
<RotateCcw className="mr-2 h-4 w-4" />
Test
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(s.id)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)}
{tab === 'deliveries' && (
<Card>
<CardContent className="p-0">
{deliveries.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">No deliveries yet.</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Event</TableHead>
<TableHead>Status</TableHead>
<TableHead>HTTP</TableHead>
<TableHead>Attempt</TableHead>
<TableHead>Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deliveries.map(d => (
<TableRow key={d.id}>
<TableCell>
<Badge variant="outline" className="text-xs">
{d.event}
</Badge>
</TableCell>
<TableCell>
<Badge
className={
d.status === 'delivered'
? 'bg-emerald-50 text-emerald-700 border-0'
: 'bg-red-50 text-red-700 border-0'
}
>
{d.status}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">{d.statusCode || '—'}</TableCell>
<TableCell>{d.attempt}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(d.createdAt)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)}
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Webhook Subscription</DialogTitle>
<DialogDescription>Subscribe to platform events via HTTP callback.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Endpoint URL</Label>
<Input
placeholder="https://example.com/webhook"
value={newUrl}
onChange={e => setNewUrl(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Events (comma-separated)</Label>
<Input
placeholder="user.created, subscription.changed"
value={newEvents}
onChange={e => setNewEvents(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreate(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={creating || !newUrl.trim()}>
{creating ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,46 @@
/**
* Agent Evaluations API proxy forwards requests to platform-service.
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth-server';
import { logError } from '@/lib/logger';
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { path } = await params;
const targetPath = `/api/agent-evals/${path.join('/')}`;
const qs = new URL(req.url).search;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
'x-user-id': caller.id,
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
};
const fetchOptions: RequestInit = { method: req.method, headers };
if (['POST', 'PUT', 'PATCH'].includes(req.method)) fetchOptions.body = await req.text();
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
const data = await res.json().catch(() => null);
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
} catch (error) {
logError('Agent evals proxy error', error);
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
}
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}

View File

@ -0,0 +1,40 @@
/**
* Delivery API proxy forwards requests to platform-service.
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth-server';
import { logError } from '@/lib/logger';
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { path } = await params;
const targetPath = `/api/delivery/${path.join('/')}`;
const qs = new URL(req.url).search;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
'x-user-id': caller.id,
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
};
const fetchOptions: RequestInit = { method: req.method, headers };
if (['POST', 'PUT', 'PATCH'].includes(req.method)) fetchOptions.body = await req.text();
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
const data = await res.json().catch(() => null);
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
} catch (error) {
logError('Delivery proxy error', error);
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
}
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}

View File

@ -0,0 +1,40 @@
/**
* Jobs API proxy forwards requests to platform-service.
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth-server';
import { logError } from '@/lib/logger';
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { path } = await params;
const targetPath = `/api/jobs/${path.join('/')}`;
const qs = new URL(req.url).search;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
'x-user-id': caller.id,
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
};
const fetchOptions: RequestInit = { method: req.method, headers };
if (['POST', 'PUT', 'PATCH'].includes(req.method)) fetchOptions.body = await req.text();
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
const data = await res.json().catch(() => null);
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
} catch (error) {
logError('Jobs proxy error', error);
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
}
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}

View File

@ -0,0 +1,46 @@
/**
* Knowledge Bases API proxy forwards requests to platform-service.
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth-server';
import { logError } from '@/lib/logger';
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { path } = await params;
const targetPath = `/api/knowledge/${path.join('/')}`;
const qs = new URL(req.url).search;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
'x-user-id': caller.id,
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
};
const fetchOptions: RequestInit = { method: req.method, headers };
if (['POST', 'PUT', 'PATCH'].includes(req.method)) fetchOptions.body = await req.text();
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
const data = await res.json().catch(() => null);
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
} catch (error) {
logError('Knowledge proxy error', error);
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
}
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}

View File

@ -0,0 +1,46 @@
/**
* Marketplace API proxy forwards requests to platform-service.
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth-server';
import { logError } from '@/lib/logger';
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { path } = await params;
const targetPath = `/api/marketplace/${path.join('/')}`;
const qs = new URL(req.url).search;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
'x-user-id': caller.id,
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
};
const fetchOptions: RequestInit = { method: req.method, headers };
if (['POST', 'PUT', 'PATCH'].includes(req.method)) fetchOptions.body = await req.text();
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
const data = await res.json().catch(() => null);
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
} catch (error) {
logError('Marketplace proxy error', error);
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
}
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}

View File

@ -0,0 +1,46 @@
/**
* Reviews API proxy forwards requests to platform-service.
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth-server';
import { logError } from '@/lib/logger';
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { path } = await params;
const targetPath = `/api/reviews/${path.join('/')}`;
const qs = new URL(req.url).search;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
'x-user-id': caller.id,
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
};
const fetchOptions: RequestInit = { method: req.method, headers };
if (['POST', 'PUT', 'PATCH'].includes(req.method)) fetchOptions.body = await req.text();
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
const data = await res.json().catch(() => null);
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
} catch (error) {
logError('Reviews proxy error', error);
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
}
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}

View File

@ -0,0 +1,40 @@
/**
* Runs API proxy forwards requests to platform-service.
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth-server';
import { logError } from '@/lib/logger';
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { path } = await params;
const targetPath = `/api/runs/${path.join('/')}`;
const qs = new URL(req.url).search;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
'x-user-id': caller.id,
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
};
const fetchOptions: RequestInit = { method: req.method, headers };
if (['POST', 'PUT', 'PATCH'].includes(req.method)) fetchOptions.body = await req.text();
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
const data = await res.json().catch(() => null);
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
} catch (error) {
logError('Runs proxy error', error);
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
}
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}

View File

@ -0,0 +1,46 @@
/**
* Webhooks API proxy forwards requests to platform-service.
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth-server';
import { logError } from '@/lib/logger';
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { path } = await params;
const targetPath = `/api/webhooks/${path.join('/')}`;
const qs = new URL(req.url).search;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
'x-user-id': caller.id,
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
};
const fetchOptions: RequestInit = { method: req.method, headers };
if (['POST', 'PUT', 'PATCH'].includes(req.method)) fetchOptions.body = await req.text();
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
const data = await res.json().catch(() => null);
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
} catch (error) {
logError('Webhooks proxy error', error);
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
}
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
return proxy(req, ctx);
}

View File

@ -43,6 +43,12 @@ import {
LifeBuoy,
Coins,
ListOrdered,
Webhook,
Database,
Star,
Store,
Mail,
Timer,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/lib/auth-context';
@ -73,6 +79,13 @@ const navItems = [
{ href: '/support', label: 'Support Cases', icon: LifeBuoy },
{ href: '/ai-budgets', label: 'AI Budgets', icon: Coins },
{ href: '/waitlist', label: 'Waitlist', icon: ListOrdered },
{ href: '/webhooks', label: 'Webhooks', icon: Webhook },
{ href: '/knowledge', label: 'Knowledge Bases', icon: Database },
{ href: '/agent-evals', label: 'Agent Evals', icon: FlaskConical },
{ href: '/reviews', label: 'Reviews', icon: Star },
{ href: '/marketplace', label: 'Marketplace', icon: Store },
{ href: '/delivery', label: 'Delivery Log', icon: Mail },
{ href: '/jobs', label: 'Scheduled Jobs', icon: Timer },
{ href: '/experiments', label: 'Experiments', icon: FlaskConical },
{ href: '/predictive/at-risk', label: 'Predictive', icon: TrendingDown },
{ href: '/ops', label: 'Mission Control', icon: Activity },