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