feat(admin-web): Phase 2 — webhooks, knowledge, agent-evals, reviews, marketplace, delivery, jobs pages
This commit is contained in:
parent
aa33b12f8c
commit
aaceba2ee5
307
dashboards/admin-web/src/app/(dashboard)/agent-evals/page.tsx
Normal file
307
dashboards/admin-web/src/app/(dashboard)/agent-evals/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
dashboards/admin-web/src/app/(dashboard)/delivery/page.tsx
Normal file
220
dashboards/admin-web/src/app/(dashboard)/delivery/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
317
dashboards/admin-web/src/app/(dashboard)/jobs/page.tsx
Normal file
317
dashboards/admin-web/src/app/(dashboard)/jobs/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
278
dashboards/admin-web/src/app/(dashboard)/knowledge/page.tsx
Normal file
278
dashboards/admin-web/src/app/(dashboard)/knowledge/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
274
dashboards/admin-web/src/app/(dashboard)/marketplace/page.tsx
Normal file
274
dashboards/admin-web/src/app/(dashboard)/marketplace/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
281
dashboards/admin-web/src/app/(dashboard)/reviews/page.tsx
Normal file
281
dashboards/admin-web/src/app/(dashboard)/reviews/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
366
dashboards/admin-web/src/app/(dashboard)/webhooks/page.tsx
Normal file
366
dashboards/admin-web/src/app/(dashboard)/webhooks/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
40
dashboards/admin-web/src/app/api/delivery/[...path]/route.ts
Normal file
40
dashboards/admin-web/src/app/api/delivery/[...path]/route.ts
Normal 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);
|
||||||
|
}
|
||||||
40
dashboards/admin-web/src/app/api/jobs/[...path]/route.ts
Normal file
40
dashboards/admin-web/src/app/api/jobs/[...path]/route.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
46
dashboards/admin-web/src/app/api/reviews/[...path]/route.ts
Normal file
46
dashboards/admin-web/src/app/api/reviews/[...path]/route.ts
Normal 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);
|
||||||
|
}
|
||||||
40
dashboards/admin-web/src/app/api/runs/[...path]/route.ts
Normal file
40
dashboards/admin-web/src/app/api/runs/[...path]/route.ts
Normal 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);
|
||||||
|
}
|
||||||
46
dashboards/admin-web/src/app/api/webhooks/[...path]/route.ts
Normal file
46
dashboards/admin-web/src/app/api/webhooks/[...path]/route.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -43,6 +43,12 @@ import {
|
|||||||
LifeBuoy,
|
LifeBuoy,
|
||||||
Coins,
|
Coins,
|
||||||
ListOrdered,
|
ListOrdered,
|
||||||
|
Webhook,
|
||||||
|
Database,
|
||||||
|
Star,
|
||||||
|
Store,
|
||||||
|
Mail,
|
||||||
|
Timer,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
import { useAuth } from '@/lib/auth-context';
|
||||||
@ -73,6 +79,13 @@ const navItems = [
|
|||||||
{ href: '/support', label: 'Support Cases', icon: LifeBuoy },
|
{ href: '/support', label: 'Support Cases', icon: LifeBuoy },
|
||||||
{ href: '/ai-budgets', label: 'AI Budgets', icon: Coins },
|
{ href: '/ai-budgets', label: 'AI Budgets', icon: Coins },
|
||||||
{ href: '/waitlist', label: 'Waitlist', icon: ListOrdered },
|
{ 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: '/experiments', label: 'Experiments', icon: FlaskConical },
|
||||||
{ href: '/predictive/at-risk', label: 'Predictive', icon: TrendingDown },
|
{ href: '/predictive/at-risk', label: 'Predictive', icon: TrendingDown },
|
||||||
{ href: '/ops', label: 'Mission Control', icon: Activity },
|
{ href: '/ops', label: 'Mission Control', icon: Activity },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user