feat: Autotask webhook integration, TicketNotes, Datto RMM, workflow engine, Veeam agents/alarms, AI triage, misc improvements

This commit is contained in:
lorentz 2026-02-20 10:28:15 -05:00
parent 347cf4e298
commit d7c3dc7168
74 changed files with 37844 additions and 322 deletions

View file

@ -3,7 +3,7 @@
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Database, Table2, Users, Ticket, CheckSquare, FolderKanban, Wrench, Tag, ArrowLeft, Home, Clock } from 'lucide-react';
import { Database, Table2, Users, Ticket, CheckSquare, FolderKanban, Wrench, Tag, ArrowLeft, Home, Clock, MessageSquare } from 'lucide-react';
import Link from 'next/link';
const entities = [
@ -16,6 +16,7 @@ const entities = [
{ name: 'Configuration Items', icon: Wrench, path: '/admin/data-browser/configuration-items', description: 'Browse config items' },
{ name: 'Contacts', icon: Users, path: '/admin/data-browser/contacts', description: 'View contacts' },
{ name: 'Contracts', icon: Table2, path: '/admin/data-browser/contracts', description: 'Browse contracts' },
{ name: 'Ticket Notes', icon: MessageSquare, path: '/admin/data-browser/ticket-notes', description: 'Browse notes on tickets' },
{ name: 'Issue Types', icon: Tag, path: '/admin/data-browser/issue-types', description: 'Browse issue types' },
{ name: 'Sub-Issue Types', icon: Tag, path: '/admin/data-browser/sub-issue-types', description: 'Browse sub-issue types' },
];

View file

@ -0,0 +1,211 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import DataTable from '@/components/admin/DataTable';
import DetailModal from '@/components/admin/DetailModal';
import { MessageSquare, ArrowLeft, RefreshCw, Filter, Calendar, User } from 'lucide-react';
import Link from 'next/link';
const NOTE_TYPE: Record<number, string> = {
1: 'Task Detail', 2: 'Time Entry Note', 3: 'Ticket Detail',
};
const PUBLISH_MAP: Record<number, { label: string; cls: string }> = {
1: { label: 'All Users', cls: 'bg-green-500/15 text-green-600 border border-green-500/30' },
2: { label: 'Internal', cls: 'bg-amber-500/15 text-amber-700 border border-amber-500/30' },
4: { label: 'Internal Only', cls: 'bg-slate-500/15 text-slate-500 border border-slate-500/30' },
};
export default function TicketNotesPage() {
const [notes, setNotes] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<any | null>(null);
const [showModal, setShowModal] = useState(false);
const [totalCount, setTotalCount] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(100);
const [search, setSearch] = useState('');
const [sortBy, setSortBy] = useState('create_date_time');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const fetchNotes = async (page = 1, overrideSortBy = sortBy, overrideSortOrder = sortOrder) => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (search) params.append('search', search);
params.append('limit', pageSize.toString());
params.append('offset', ((page - 1) * pageSize).toString());
params.append('sort_by', overrideSortBy);
params.append('sort_order', overrideSortOrder);
const res = await fetch(`/api/data/ticket-notes?${params}`);
if (!res.ok) throw new Error(res.statusText);
const data = await res.json();
setNotes(data.ticketNotes || []);
setTotalCount(data.pagination?.total || 0);
setCurrentPage(page);
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error');
} finally {
setLoading(false);
}
};
useEffect(() => { fetchNotes(1); }, [pageSize]);
const handleSort = (col: string, dir: 'asc' | 'desc') => {
setSortBy(col);
setSortOrder(dir);
fetchNotes(1, col, dir);
};
const columns = [
{
key: 'ticket_number',
label: 'Ticket',
sortable: false,
render: (v: string) => v
? <Badge variant="outline" className="font-mono text-xs">{v}</Badge>
: <span className="text-muted-foreground"></span>,
},
{
key: 'create_date_time',
label: 'Date',
sortable: true,
render: (v: string) => v
? <span className="inline-flex items-center gap-1 text-xs"><Calendar className="w-3 h-3" />{new Date(v).toLocaleDateString()}</span>
: <span className="text-muted-foreground"></span>,
},
{
key: 'creator_name',
label: 'Creator',
sortable: false,
render: (v: string) => v
? <span className="inline-flex items-center gap-1 text-xs"><User className="w-3 h-3" />{v}</span>
: <span className="text-muted-foreground"></span>,
},
{
key: 'publish',
label: 'Visibility',
sortable: true,
render: (v: number) => {
const p = PUBLISH_MAP[v];
return p
? <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${p.cls}`}>{p.label}</span>
: <span className="text-muted-foreground text-xs">{v}</span>;
},
},
{
key: 'note_type',
label: 'Type',
sortable: true,
render: (v: number) => <span className="text-xs text-muted-foreground">{NOTE_TYPE[v] ?? `Type ${v}`}</span>,
},
{
key: 'title',
label: 'Title',
sortable: true,
render: (v: string) => <div className="max-w-xs truncate text-sm" title={v}>{v || <span className="text-muted-foreground italic">No title</span>}</div>,
},
{
key: 'description',
label: 'Preview',
sortable: false,
render: (v: string) => <div className="max-w-sm truncate text-xs text-muted-foreground" title={v}>{v || '—'}</div>,
},
];
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/admin/data-browser">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
<span className="hidden sm:inline">Back</span>
</Button>
</Link>
<MessageSquare className="w-8 h-8 text-blue-600" />
<div>
<h1 className="text-3xl font-bold">Ticket Notes</h1>
<p className="text-muted-foreground">Browse notes attached to tickets</p>
</div>
</div>
<Button variant="outline" onClick={() => fetchNotes(currentPage)} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Filter className="w-5 h-5" />Filters</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Search title / description</Label>
<Input placeholder="Search..." value={search} onChange={e => setSearch(e.target.value)}
onKeyDown={e => e.key === 'Enter' && fetchNotes(1)} />
</div>
<div className="space-y-2">
<Label>Page Size</Label>
<Select value={pageSize.toString()} onValueChange={v => setPageSize(parseInt(v))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{['50','100','250','500'].map(v => <SelectItem key={v} value={v}>{v}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2">
<Button onClick={() => fetchNotes(1)} disabled={loading}>Apply</Button>
<Button variant="outline" onClick={() => { setSearch(''); fetchNotes(1); }}>Clear</Button>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Ticket Notes ({totalCount.toLocaleString()} total)</CardTitle>
<CardDescription>Showing {notes.length} of {totalCount.toLocaleString()} Click a row to view details</CardDescription>
</CardHeader>
<CardContent>
{error && (
<div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-md p-4 mb-4">
<p className="text-red-800 dark:text-red-200">{error}</p>
</div>
)}
<DataTable
data={notes}
columns={columns}
isLoading={loading}
onRowClick={row => { setSelected(row); setShowModal(true); }}
totalCount={totalCount}
page={currentPage}
pageSize={pageSize}
onPageChange={p => fetchNotes(p)}
onSort={handleSort}
/>
</CardContent>
</Card>
<DetailModal
open={showModal}
onOpenChange={setShowModal}
title={selected?.title || `Note #${selected?.id}`}
data={selected}
/>
</div>
);
}

View file

@ -0,0 +1,103 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ArrowLeft, Activity, Apple, Loader2, ExternalLink, CheckCircle2 } from 'lucide-react';
export default function AddigyPage() {
const [status, setStatus] = useState<any>(null);
useEffect(() => {
fetch('/api/integrations/status')
.then(r => r.json())
.then(d => setStatus(d.addigy))
.catch(() => {});
}, []);
return (
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
<div className="flex items-center gap-4">
<Link href="/admin/sync">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
Integrations
</Button>
</Link>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg border bg-muted/30">
<Apple className="w-5 h-5 text-muted-foreground" />
</div>
<div>
<h1 className="text-2xl font-bold">Apple RMM Addigy</h1>
<p className="text-sm text-muted-foreground">macOS/iOS device management, policies, compliance</p>
</div>
</div>
<div className="ml-auto">
<a href="https://app.addigy.com" target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm" className="gap-2">
<ExternalLink className="w-4 h-4" />Open Portal
</Button>
</a>
</div>
</div>
<Tabs defaultValue="status" className="w-full">
<TabsList className="grid w-full max-w-xs grid-cols-2">
<TabsTrigger value="status" className="gap-2">
<Activity className="h-4 w-4" />Status
</TabsTrigger>
<TabsTrigger value="about" className="gap-2">
<Apple className="h-4 w-4" />About
</TabsTrigger>
</TabsList>
<TabsContent value="status" className="mt-6">
{!status ? (
<div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>
) : (
<div className="space-y-4">
<div className="rounded-lg border p-4 bg-muted/30 flex items-center justify-between">
<div className="flex items-center gap-3">
<CheckCircle2 className={`w-5 h-5 ${status.configured ? 'text-green-500' : 'text-muted-foreground'}`} />
<div>
<p className="text-sm font-medium">{status.configured ? 'Connected' : 'Not configured'}</p>
<p className="text-xs text-muted-foreground">{status.apiUrl}</p>
</div>
</div>
</div>
<div className="rounded-lg border p-4 bg-muted/30 text-sm text-muted-foreground">
Full sync and database integration is planned. Device and policy data is currently available on-demand via the Addigy API.
Use the org mappings page to link Addigy organizations to Autotask companies.
</div>
<div className="flex gap-2">
<Link href="/addigy-mappings">
<Button variant="outline" size="sm">Org Mappings</Button>
</Link>
</div>
</div>
)}
</TabsContent>
<TabsContent value="about" className="mt-6">
<div className="space-y-4 text-sm text-muted-foreground">
<div className="rounded-lg border p-4 space-y-2">
<p className="font-medium text-foreground">API Endpoints Available</p>
<ul className="space-y-1 list-disc list-inside">
<li><code className="text-xs bg-muted px-1 rounded">GET /api/addigy-devices</code> managed Apple devices</li>
<li><code className="text-xs bg-muted px-1 rounded">GET /api/addigy-policies</code> device policies</li>
<li><code className="text-xs bg-muted px-1 rounded">GET /api/addigy/org-mappings</code> org to company mappings</li>
</ul>
</div>
<div className="rounded-lg border p-4 space-y-2">
<p className="font-medium text-foreground">Authentication</p>
<p>Bearer token auth via <code className="text-xs bg-muted px-1 rounded">ADDIGY_API_TOKEN</code></p>
<p>Base URL: <code className="text-xs bg-muted px-1 rounded">https://api.addigy.com/api/v2</code></p>
</div>
</div>
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -0,0 +1,89 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ArrowLeft, Activity, History, Calendar, Database } from 'lucide-react';
import SyncControlPanel from '@/components/admin/SyncControlPanel';
import SyncDashboard from '@/components/admin/SyncDashboard';
import SyncHistoryTable from '@/components/admin/SyncHistoryTable';
import SyncScheduler from '@/components/admin/SyncScheduler';
import { EntityType } from '@/lib/types/sync';
export default function AutotaskSyncPage() {
const [selectedEntities, setSelectedEntities] = useState<EntityType[]>([]);
const [isSyncing, setIsSyncing] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
if (!isSyncing) return;
const interval = setInterval(async () => {
setRefreshKey(prev => prev + 1);
try {
const res = await fetch('/api/sync/status');
if (res.ok) {
const d = await res.json();
if (!d.inProgress) setIsSyncing(false);
}
} catch {}
}, 5000);
return () => clearInterval(interval);
}, [isSyncing]);
return (
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
<div className="flex items-center gap-4">
<Link href="/admin/sync">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
Integrations
</Button>
</Link>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg border border-blue-500/30 bg-blue-500/5">
<Database className="w-5 h-5 text-blue-500" />
</div>
<div>
<h1 className="text-2xl font-bold">PSA Autotask</h1>
<p className="text-sm text-muted-foreground">Tickets, companies, contacts, time entries, configuration items</p>
</div>
</div>
</div>
<SyncControlPanel
selectedEntities={selectedEntities}
onSelectedEntitiesChange={setSelectedEntities}
onSyncStart={() => setIsSyncing(true)}
onSyncComplete={() => { setIsSyncing(false); setRefreshKey(k => k + 1); }}
isSyncing={isSyncing}
/>
<Tabs defaultValue="status" className="w-full">
<TabsList className="grid w-full max-w-lg grid-cols-3">
<TabsTrigger value="status" className="gap-2">
<Activity className="h-4 w-4" />
Status
</TabsTrigger>
<TabsTrigger value="history" className="gap-2">
<History className="h-4 w-4" />
History
</TabsTrigger>
<TabsTrigger value="schedules" className="gap-2">
<Calendar className="h-4 w-4" />
Schedules
</TabsTrigger>
</TabsList>
<TabsContent value="status" className="mt-6">
<SyncDashboard refreshKey={refreshKey} />
</TabsContent>
<TabsContent value="history" className="mt-6">
<SyncHistoryTable refreshKey={refreshKey} />
</TabsContent>
<TabsContent value="schedules" className="mt-6">
<SyncScheduler />
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -0,0 +1,103 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ArrowLeft, Activity, Network, Loader2, ExternalLink, CheckCircle2 } from 'lucide-react';
export default function AuvikPage() {
const [status, setStatus] = useState<any>(null);
useEffect(() => {
fetch('/api/integrations/status')
.then(r => r.json())
.then(d => setStatus(d.auvik))
.catch(() => {});
}, []);
return (
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
<div className="flex items-center gap-4">
<Link href="/admin/sync">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
Integrations
</Button>
</Link>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg border border-purple-500/30 bg-purple-500/5">
<Network className="w-5 h-5 text-purple-500" />
</div>
<div>
<h1 className="text-2xl font-bold">NMS Auvik</h1>
<p className="text-sm text-muted-foreground">Network devices, topology, tenant mappings</p>
</div>
</div>
<div className="ml-auto">
<a href="https://auvikapi.us5.my.auvik.com" target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm" className="gap-2">
<ExternalLink className="w-4 h-4" />Open Portal
</Button>
</a>
</div>
</div>
<Tabs defaultValue="status" className="w-full">
<TabsList className="grid w-full max-w-xs grid-cols-2">
<TabsTrigger value="status" className="gap-2">
<Activity className="h-4 w-4" />Status
</TabsTrigger>
<TabsTrigger value="about" className="gap-2">
<Network className="h-4 w-4" />About
</TabsTrigger>
</TabsList>
<TabsContent value="status" className="mt-6">
{!status ? (
<div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>
) : (
<div className="space-y-4">
<div className="rounded-lg border p-4 bg-muted/30 flex items-center justify-between">
<div className="flex items-center gap-3">
<CheckCircle2 className={`w-5 h-5 ${status.configured ? 'text-green-500' : 'text-muted-foreground'}`} />
<div>
<p className="text-sm font-medium">{status.configured ? 'Connected' : 'Not configured'}</p>
<p className="text-xs text-muted-foreground">{status.apiUrl}</p>
</div>
</div>
</div>
<div className="rounded-lg border p-4 bg-muted/30 text-sm text-muted-foreground">
Full sync and database integration is planned. Data is currently available on-demand via the Auvik API.
Use the tenant mappings page to link Auvik tenants to Autotask companies.
</div>
<div className="flex gap-2">
<Link href="/auvik-mappings">
<Button variant="outline" size="sm">Tenant Mappings</Button>
</Link>
</div>
</div>
)}
</TabsContent>
<TabsContent value="about" className="mt-6">
<div className="space-y-4 text-sm text-muted-foreground">
<div className="rounded-lg border p-4 space-y-2">
<p className="font-medium text-foreground">API Endpoints Available</p>
<ul className="space-y-1 list-disc list-inside">
<li><code className="text-xs bg-muted px-1 rounded">GET /api/auvik/devices</code> network devices</li>
<li><code className="text-xs bg-muted px-1 rounded">GET /api/auvik/tenant-mappings</code> tenant to company mappings</li>
<li><code className="text-xs bg-muted px-1 rounded">GET /api/auvik/device-config</code> device configurations</li>
</ul>
</div>
<div className="rounded-lg border p-4 space-y-2">
<p className="font-medium text-foreground">Authentication</p>
<p>Basic auth API user + API key (Base64 encoded)</p>
<p>Region: US5 (<code className="text-xs bg-muted px-1 rounded">auvikapi.us5.my.auvik.com</code>)</p>
</div>
</div>
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -0,0 +1,243 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
ArrowLeft, Activity, History, Monitor, Loader2, RefreshCw,
ExternalLink, Server, Wifi, WifiOff, AlertTriangle, Bell, XCircle, CheckCircle2, Clock,
} from 'lucide-react';
function fmtDate(d: string | null) {
if (!d) return 'Never';
return new Date(d).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function StatCard({ label, value, sub, icon: Icon, cls }: { label: string; value: string | number; sub?: string; icon?: React.ElementType; cls?: string }) {
return (
<div className={`rounded-lg border p-4 flex flex-col gap-1 ${cls ?? ''}`}>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{Icon && <Icon className="w-3.5 h-3.5" />}{label}
</div>
<div className="text-2xl font-bold tabular-nums">{value}</div>
{sub && <div className="text-xs text-muted-foreground">{sub}</div>}
</div>
);
}
function StatusTab({ data, onSync, syncing }: { data: any; onSync: () => void; syncing: boolean }) {
if (!data) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
const devs = data.devices ?? {};
const alerts = data.openAlerts ?? {};
return (
<div className="space-y-6">
<div className="flex items-center justify-between rounded-lg border p-4 bg-muted/30">
<div className="space-y-0.5">
<p className="text-sm font-medium">{data.configured ? 'Connected' : 'Not configured'}</p>
<p className="text-xs text-muted-foreground">Last sync: {fmtDate(data.lastSync)}</p>
</div>
<div className="flex gap-2">
<a href="https://concord.rmm.datto.com" target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm" className="gap-2">
<ExternalLink className="w-4 h-4" />Portal
</Button>
</a>
<Button size="sm" onClick={onSync} disabled={syncing || !data.configured}>
{syncing ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Full Sync
</Button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="Sites" value={data.sites ?? 0} icon={Server} />
<StatCard label="Devices" value={devs.total ?? 0} icon={Monitor} />
<StatCard label="Online" value={devs.online ?? 0} icon={Wifi}
cls="border-green-500/30 bg-green-500/5" />
<StatCard label="Offline" value={devs.offline ?? 0} icon={WifiOff}
cls={(devs.offline ?? 0) > 0 ? 'border-yellow-500/30 bg-yellow-500/5' : ''} />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="Open Alerts" value={alerts.total ?? 0} icon={Bell}
cls={(alerts.total ?? 0) > 0 ? 'border-red-500/30 bg-red-500/5' : ''} />
<StatCard label="Critical / High" value={`${alerts.critical ?? 0} / ${alerts.high ?? 0}`} icon={XCircle}
cls={(alerts.critical ?? 0) > 0 ? 'border-red-500/30 bg-red-500/5' : ''} />
<StatCard label="Moderate" value={alerts.moderate ?? 0} icon={AlertTriangle} />
<StatCard label="With Ticket" value={alerts.withTicket ?? 0} icon={CheckCircle2}
sub="linked to Autotask" />
</div>
{(alerts.critical > 0 || alerts.high > 0) && (
<div className="rounded-lg border p-4 space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Attention Required</p>
{alerts.critical > 0 && (
<div className="flex items-center gap-2 text-sm text-red-600">
<XCircle className="w-4 h-4" />{alerts.critical} critical alert{alerts.critical !== 1 ? 's' : ''}
</div>
)}
{alerts.high > 0 && (
<div className="flex items-center gap-2 text-sm text-orange-600">
<AlertTriangle className="w-4 h-4" />{alerts.high} high priority alert{alerts.high !== 1 ? 's' : ''}
</div>
)}
</div>
)}
</div>
);
}
function HistoryTab({ refreshKey }: { refreshKey: number }) {
const [rows, setRows] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch('/api/sync/history?entityType=datto_rmm&limit=50')
.then(r => r.json())
.then(d => setRows(d.history ?? []))
.catch(() => setRows([]))
.finally(() => setLoading(false));
}, [refreshKey]);
if (loading) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
if (!rows.length) return <div className="text-center py-12 text-muted-foreground text-sm">No sync history yet run a sync to populate</div>;
return (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Records</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Started</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Duration</th>
</tr>
</thead>
<tbody>
{rows.map((row: any, i: number) => {
const dur = row.completed_at && row.started_at
? Math.round((new Date(row.completed_at).getTime() - new Date(row.started_at).getTime()) / 1000)
: null;
return (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 capitalize">{row.sync_type}</td>
<td className="px-4 py-2">
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
row.status === 'completed' ? 'bg-green-500/15 text-green-700' :
row.status === 'failed' ? 'bg-red-500/15 text-red-600' :
'bg-yellow-500/15 text-yellow-700'
}`}>{row.status}</span>
</td>
<td className="px-4 py-2 tabular-nums">{row.records_added ?? 0}</td>
<td className="px-4 py-2 text-muted-foreground">{fmtDate(row.started_at)}</td>
<td className="px-4 py-2 text-muted-foreground">{dur != null ? `${dur}s` : '—'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
export default function DattoRmmPage() {
const [status, setStatus] = useState<any>(null);
const [syncing, setSyncing] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const fetchStatus = async () => {
const res = await fetch('/api/integrations/status');
if (res.ok) { const d = await res.json(); setStatus(d.dattoRmm); }
};
useEffect(() => { fetchStatus(); }, [refreshKey]);
const handleSync = async () => {
setSyncing(true);
try {
await fetch('/api/datto-rmm/sync', {
method: 'POST',
body: JSON.stringify({ syncType: 'full' }),
headers: { 'Content-Type': 'application/json' },
});
const poll = setInterval(async () => {
const r = await fetch('/api/datto-rmm/sync');
if (r.ok) {
const d = await r.json();
if (!d.isSyncing) {
clearInterval(poll);
setSyncing(false);
setRefreshKey(k => k + 1);
}
}
}, 5000);
} catch {
setSyncing(false);
}
};
return (
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
<div className="flex items-center gap-4">
<Link href="/admin/sync">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
Integrations
</Button>
</Link>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg border border-orange-500/30 bg-orange-500/5">
<Monitor className="w-5 h-5 text-orange-500" />
</div>
<div>
<h1 className="text-2xl font-bold">RMM Datto RMM</h1>
<p className="text-sm text-muted-foreground">Sites, devices, alerts, patch management</p>
</div>
</div>
</div>
<Tabs defaultValue="status" className="w-full">
<TabsList className="grid w-full max-w-md grid-cols-3">
<TabsTrigger value="status" className="gap-2">
<Activity className="h-4 w-4" />Status
</TabsTrigger>
<TabsTrigger value="history" className="gap-2">
<History className="h-4 w-4" />History
</TabsTrigger>
<TabsTrigger value="about" className="gap-2">
<Monitor className="h-4 w-4" />About
</TabsTrigger>
</TabsList>
<TabsContent value="status" className="mt-6">
<StatusTab data={status} onSync={handleSync} syncing={syncing} />
</TabsContent>
<TabsContent value="history" className="mt-6">
<HistoryTab refreshKey={refreshKey} />
</TabsContent>
<TabsContent value="about" className="mt-6">
<div className="space-y-4 text-sm text-muted-foreground">
<div className="rounded-lg border p-4 space-y-2">
<p className="font-medium text-foreground">Synced Entities</p>
<ul className="space-y-1 list-disc list-inside">
<li><strong>Sites</strong> RMM sites with device counts, mapped to Autotask companies</li>
<li><strong>Devices</strong> all managed devices with OS, IP, AV, patch status, UDFs</li>
<li><strong>Open Alerts</strong> active alerts with priority, device context, ticket linkage</li>
<li><strong>Resolved Alerts</strong> recent resolved alerts with response action history</li>
</ul>
</div>
<div className="rounded-lg border p-4 space-y-2">
<p className="font-medium text-foreground">Authentication</p>
<p>OAuth2 password grant API key + secret Bearer token (100h TTL, refreshed at 50min)</p>
<p>Rate limit: 600 requests / 60 seconds across the account</p>
</div>
</div>
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -1,118 +1,263 @@
/**
* Admin Sync Page
* Main page for controlling and monitoring Autotask PostgreSQL sync operations
*/
'use client';
import { useState, useEffect } from 'react';
import SyncControlPanel from '@/components/admin/SyncControlPanel';
import SyncDashboard from '@/components/admin/SyncDashboard';
import SyncHistoryTable from '@/components/admin/SyncHistoryTable';
import SyncScheduler from '@/components/admin/SyncScheduler';
import { EntityType } from '@/lib/types/sync';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ArrowLeft, Home, Activity, History, Calendar } from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { RefreshCw, CheckCircle2, XCircle, AlertTriangle, Clock, Loader2, ChevronRight } from 'lucide-react';
export default function AdminSyncPage() {
const [selectedEntities, setSelectedEntities] = useState<EntityType[]>([]);
const [isSyncing, setIsSyncing] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
interface IntegrationCard {
id: string;
category: string;
product: string;
description: string;
href: string;
logo: string;
color: string;
}
// Auto-refresh during sync and check if sync completed
useEffect(() => {
if (isSyncing) {
const interval = setInterval(async () => {
setRefreshKey(prev => prev + 1);
// Check if sync is still in progress
try {
const response = await fetch('/api/sync/status');
if (response.ok) {
const data = await response.json();
// If no sync in progress, mark as complete
if (!data.inProgress) {
setIsSyncing(false);
}
}
} catch (error) {
console.error('Failed to check sync status:', error);
}
}, 5000); // Refresh every 5 seconds
const INTEGRATIONS: IntegrationCard[] = [
{ id: 'autotask', category: 'PSA', product: 'Autotask', description: 'Tickets, companies, contacts, time entries, configuration items', href: '/admin/sync/autotask', logo: '/logos/autotask.ico', color: 'red' },
{ id: 'veeam', category: 'Backup', product: 'Veeam VSPC', description: 'Agent jobs, backup jobs, protected workloads, compliance', href: '/admin/sync/veeam', logo: '/logos/veeam.ico', color: 'green' },
{ id: 'datto-rmm', category: 'RMM', product: 'Datto RMM', description: 'Device monitoring, alerts, patch management, remote access', href: '/admin/sync/datto-rmm', logo: '/logos/datto-rmm.ico', color: 'blue' },
{ id: 'auvik', category: 'NMS', product: 'Auvik', description: 'Network devices, topology, tenant mappings', href: '/admin/sync/auvik', logo: '/logos/auvik.ico', color: 'purple' },
{ id: 'addigy', category: 'Apple RMM', product: 'Addigy', description: 'macOS/iOS device management, policies, compliance', href: '/admin/sync/addigy', logo: '/logos/addigy.ico', color: 'gray' },
];
return () => clearInterval(interval);
const COLOR_MAP: Record<string, { bg: string; border: string }> = {
red: { bg: 'bg-red-500/5', border: 'border-red-500/20' },
green: { bg: 'bg-green-500/5', border: 'border-green-500/20' },
blue: { bg: 'bg-blue-500/5', border: 'border-blue-500/20' },
purple: { bg: 'bg-purple-500/5', border: 'border-purple-500/20' },
gray: { bg: 'bg-muted/20', border: 'border-border' },
};
function fmtDate(d: string | null) {
if (!d) return 'Never';
const date = new Date(d);
const diff = Date.now() - date.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
export default function SyncOverviewPage() {
const [status, setStatus] = useState<any>(null);
const [autotaskSync, setAutotaskSync] = useState<any>(null);
const [loading, setLoading] = useState(true);
const fetchAll = async () => {
try {
const [intRes, atRes] = await Promise.all([
fetch('/api/integrations/status'),
fetch('/api/sync/last-sync'),
]);
if (intRes.ok) setStatus(await intRes.json());
if (atRes.ok) {
const d = await atRes.json();
setAutotaskSync(d.lastSync || {});
}
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, [isSyncing]);
const handleSyncStart = () => {
setIsSyncing(true);
};
const handleSyncComplete = () => {
setIsSyncing(false);
setRefreshKey(prev => prev + 1);
useEffect(() => { fetchAll(); }, []);
const getAutotaskSummary = () => {
if (!autotaskSync) return null;
const entries = Object.values(autotaskSync) as any[];
if (!entries.length) return null;
const latest = entries.reduce((a: any, b: any) =>
new Date(a.completed_at) > new Date(b.completed_at) ? a : b
);
const failed = entries.filter((e: any) => e.status === 'failed').length;
return { lastSync: latest.completed_at, failed, total: entries.length };
};
const atSummary = getAutotaskSummary();
const getSummary = (id: string) => {
if (!status) return null;
if (id === 'autotask') return atSummary;
if (id === 'veeam') {
const v = status.veeam;
if (!v) return null;
const aj = v.agentJobs ?? {};
const bj = v.backupJobs ?? {};
return {
lastSync: v.lastSync,
failed: (aj.failed ?? 0) + (bj.failed ?? 0),
warning: (aj.warning ?? 0) + (bj.warning ?? 0),
running: aj.running ?? 0,
total: (aj.total ?? 0) + (bj.total ?? 0),
};
}
if (id === 'datto-rmm') {
const d = status.dattoRmm;
if (!d) return null;
return {
lastSync: d.lastSync,
sites: d.sites ?? 0,
devices: d.devices?.total ?? 0,
online: d.devices?.online ?? 0,
offline: d.devices?.offline ?? 0,
openAlerts: d.openAlerts?.total ?? 0,
critical: d.openAlerts?.critical ?? 0,
};
}
if (id === 'auvik') return { lastSync: status.auvik?.lastSync, configured: status.auvik?.configured };
if (id === 'addigy') return { lastSync: status.addigy?.lastSync, configured: status.addigy?.configured };
return null;
};
const getStatusIcon = (id: string, summary: any) => {
if (!summary) return <Clock className="w-4 h-4 text-muted-foreground" />;
if (id === 'veeam') {
if (summary.failed > 0) return <XCircle className="w-4 h-4 text-red-500" />;
if (summary.warning > 0 || summary.running > 0) return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
}
if (id === 'autotask') {
if (summary.failed > 0) return <XCircle className="w-4 h-4 text-red-500" />;
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
}
if (id === 'datto-rmm') {
if (summary.critical > 0) return <XCircle className="w-4 h-4 text-red-500" />;
if (summary.openAlerts > 0) return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
}
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
};
return (
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6 md:space-y-8">
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
<Home className="w-4 h-4" />
<span className="hidden sm:inline">Back to Dashboard</span>
</Button>
</Link>
<div>
<h1 className="text-2xl md:text-3xl font-bold">Autotask Sync</h1>
<p className="text-sm md:text-base text-muted-foreground mt-1">
Sync Autotask data to PostgreSQL database
</p>
</div>
<div>
<h1 className="text-2xl md:text-3xl font-bold">Integrations & Sync</h1>
<p className="text-sm text-muted-foreground mt-0.5">Manage data sync across all connected platforms</p>
</div>
<Button variant="outline" size="sm" onClick={fetchAll} className="gap-2">
<RefreshCw className="w-4 h-4" />
Refresh
</Button>
</div>
{/* Sync Control Panel */}
<SyncControlPanel
selectedEntities={selectedEntities}
onSelectedEntitiesChange={setSelectedEntities}
onSyncStart={handleSyncStart}
onSyncComplete={handleSyncComplete}
isSyncing={isSyncing}
/>
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 items-stretch">
{INTEGRATIONS.map((intg) => {
const colors = COLOR_MAP[intg.color];
const summary = getSummary(intg.id);
return (
<Link key={intg.id} href={intg.href} className="flex">
<div className={`flex flex-col w-full rounded-xl border ${colors.border} ${colors.bg} p-5 hover:shadow-md transition-all cursor-pointer group`}>
{/* Tabs for Sync Status, History, and Schedules */}
<Tabs defaultValue="status" className="w-full">
<TabsList className="grid w-full max-w-2xl grid-cols-3">
<TabsTrigger value="status" className="gap-2">
<Activity className="h-4 w-4" />
Sync Status
</TabsTrigger>
<TabsTrigger value="history" className="gap-2">
<History className="h-4 w-4" />
Sync History
</TabsTrigger>
<TabsTrigger value="schedules" className="gap-2">
<Calendar className="h-4 w-4" />
Schedules
</TabsTrigger>
</TabsList>
<TabsContent value="status" className="mt-6">
<SyncDashboard refreshKey={refreshKey} />
</TabsContent>
<TabsContent value="history" className="mt-6">
<SyncHistoryTable refreshKey={refreshKey} />
</TabsContent>
<TabsContent value="schedules" className="mt-6">
<SyncScheduler />
</TabsContent>
</Tabs>
{/* Header: logo + names + chevron */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<img src={intg.logo} alt={intg.product} className="w-10 h-10 shrink-0 rounded-lg object-contain" />
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground leading-none mb-1">
{intg.category}
</p>
<div className="flex items-center gap-2">
<span className="font-bold text-base leading-tight">{intg.product}</span>
{summary && getStatusIcon(intg.id, summary)}
</div>
</div>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors shrink-0 mt-1" />
</div>
{/* Description */}
<p className="text-xs text-muted-foreground leading-relaxed mb-4">{intg.description}</p>
{/* Stats — pushed to bottom */}
<div className="mt-auto pt-3 border-t border-border/50 space-y-1.5 text-xs text-muted-foreground">
{intg.id === 'autotask' && summary && (
<>
<div className="flex justify-between">
<span>Last sync</span>
<span className="font-medium text-foreground">{fmtDate(summary.lastSync)}</span>
</div>
<div className="flex justify-between">
<span>Entities tracked</span>
<span className="font-medium text-foreground">{summary.total}</span>
</div>
</>
)}
{intg.id === 'veeam' && summary && (
<>
<div className="flex justify-between">
<span>Last sync</span>
<span className="font-medium text-foreground">{fmtDate(summary.lastSync)}</span>
</div>
<div className="flex justify-between">
<span>Total jobs</span>
<span className="font-medium text-foreground">{summary.total}</span>
</div>
{(summary as any).failed > 0 && (
<div className="flex justify-between text-red-600">
<span>Failed</span><span className="font-medium">{(summary as any).failed}</span>
</div>
)}
{(summary as any).running > 0 && (
<div className="flex justify-between text-blue-600">
<span>Running (stalled?)</span><span className="font-medium">{(summary as any).running}</span>
</div>
)}
{(summary as any).warning > 0 && (
<div className="flex justify-between text-yellow-700">
<span>Warning</span><span className="font-medium">{(summary as any).warning}</span>
</div>
)}
</>
)}
{intg.id === 'datto-rmm' && summary && (
<>
<div className="flex justify-between">
<span>Last sync</span>
<span className="font-medium text-foreground">{fmtDate(summary.lastSync)}</span>
</div>
<div className="flex justify-between">
<span>Sites / Devices</span>
<span className="font-medium text-foreground">{(summary as any).sites} / {(summary as any).devices}</span>
</div>
<div className="flex justify-between">
<span>Online / Offline</span>
<span className="font-medium text-foreground">{(summary as any).online} / {(summary as any).offline}</span>
</div>
{(summary as any).openAlerts > 0 && (
<div className={`flex justify-between ${(summary as any).critical > 0 ? 'text-red-600' : 'text-yellow-700'}`}>
<span>Open alerts</span>
<span className="font-medium">{(summary as any).openAlerts}</span>
</div>
)}
</>
)}
{(intg.id === 'auvik' || intg.id === 'addigy') && (
<div className="flex justify-between">
<span>Status</span>
<span className={`font-medium ${(summary as any)?.configured ? 'text-green-600' : 'text-muted-foreground'}`}>
{(summary as any)?.configured ? 'Connected' : 'Not configured'}
</span>
</div>
)}
</div>
</div>
</Link>
);
})}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,465 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
ArrowLeft, Activity, History, Calendar, Shield, RefreshCw, Loader2,
CheckCircle2, XCircle, AlertTriangle, Clock, Server, HardDrive,
Bot, Bell, ChevronDown, ChevronRight,
} from 'lucide-react';
import SyncScheduler from '@/components/admin/SyncScheduler';
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmtDate(d: string | null | undefined) {
if (!d) return 'Never';
return new Date(d).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function fmtDur(ms: number) {
if (ms < 60000) return `${Math.round(ms / 1000)}s`;
return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`;
}
function StatCard({ label, value, sub, icon: Icon, cls }: {
label: string; value: string | number; sub?: string; icon?: React.ElementType; cls?: string;
}) {
return (
<div className={`rounded-lg border p-4 flex flex-col gap-1 ${cls ?? ''}`}>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{Icon && <Icon className="w-3.5 h-3.5" />}{label}
</div>
<div className="text-2xl font-bold tabular-nums">{value}</div>
{sub && <div className="text-xs text-muted-foreground">{sub}</div>}
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const cls =
status === 'completed' ? 'bg-green-500/15 text-green-700' :
status === 'failed' ? 'bg-red-500/15 text-red-600' :
status === 'started' ? 'bg-blue-500/15 text-blue-700' :
'bg-yellow-500/15 text-yellow-700';
return <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}`}>{status}</span>;
}
// ── Status Tab ────────────────────────────────────────────────────────────────
function VeeamStatusTab({ data, onSync, syncing }: { data: any; onSync: (t: string) => void; syncing: boolean }) {
if (!data) return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
);
const aj = data.agentJobs ?? {};
const bj = data.backupJobs ?? {};
const totalFailed = (aj.failed ?? 0) + (bj.failed ?? 0);
const totalWarning = (aj.warning ?? 0) + (bj.warning ?? 0);
const totalRunning = aj.running ?? 0;
const totalSuccess = (aj.success ?? 0) + (bj.success ?? 0);
return (
<div className="space-y-6">
<div className="flex items-center justify-between rounded-lg border p-4 bg-muted/30">
<div className="space-y-0.5">
<p className="text-sm font-medium">{data.configured ? 'Connected to VSPC' : 'Not configured'}</p>
<p className="text-xs text-muted-foreground">Last sync: {fmtDate(data.lastSync)}</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => onSync('incremental')} disabled={syncing || !data.configured}>
{syncing ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Incremental
</Button>
<Button size="sm" onClick={() => onSync('full')} disabled={syncing || !data.configured}>
{syncing ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Full Sync
</Button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="Organizations" value={data.organizations ?? 0} icon={Server} />
<StatCard label="Protected Workloads" value={data.protectedWorkloads ?? 0} icon={HardDrive} />
<StatCard label="Agent Jobs" value={aj.total ?? 0} sub={`${aj.success ?? 0} success`} icon={Shield} />
<StatCard label="Backup Jobs" value={bj.total ?? 0} sub={`${bj.success ?? 0} success`} icon={Shield} />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="Running" value={totalRunning} icon={Clock}
cls={totalRunning > 0 ? 'border-blue-500/30 bg-blue-500/5' : ''} />
<StatCard label="Failed" value={totalFailed} icon={XCircle}
cls={totalFailed > 0 ? 'border-red-500/30 bg-red-500/5' : ''} />
<StatCard label="Warning" value={totalWarning} icon={AlertTriangle}
cls={totalWarning > 0 ? 'border-yellow-500/30 bg-yellow-500/5' : ''} />
<StatCard label="Success" value={totalSuccess} icon={CheckCircle2}
cls="border-green-500/30 bg-green-500/5" />
</div>
{(totalFailed > 0 || totalWarning > 0 || totalRunning > 0) && (
<div className="rounded-lg border p-4 space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Attention Required</p>
{totalFailed > 0 && <div className="flex items-center gap-2 text-sm text-red-600"><XCircle className="w-4 h-4" />{totalFailed} job{totalFailed !== 1 ? 's' : ''} failed</div>}
{totalWarning > 0 && <div className="flex items-center gap-2 text-sm text-yellow-700"><AlertTriangle className="w-4 h-4" />{totalWarning} job{totalWarning !== 1 ? 's' : ''} with warnings</div>}
{totalRunning > 0 && <div className="flex items-center gap-2 text-sm text-blue-600"><Loader2 className="w-4 h-4 animate-spin" />{totalRunning} job{totalRunning !== 1 ? 's' : ''} running</div>}
</div>
)}
</div>
);
}
// ── History Tab ───────────────────────────────────────────────────────────────
const ENTITY_LABELS: Record<string, string> = {
organizations: 'Organizations',
backup_servers: 'Backup Servers',
repositories: 'Repositories',
backup_jobs: 'Backup Jobs',
backup_agent_jobs: 'Agent Jobs',
protected_workloads:'Protected Workloads',
backup_agents: 'Agents',
alarms: 'Alarms',
};
function HistoryRow({ row }: { row: any }) {
const [expanded, setExpanded] = useState(false);
const dur = row.completed_at && row.started_at
? new Date(row.completed_at).getTime() - new Date(row.started_at).getTime()
: null;
let entities: Array<{ entity: string; success: boolean; recordsUpserted: number; duration: number; error?: string }> = [];
try {
if (row.entity_details) {
entities = typeof row.entity_details === 'string' ? JSON.parse(row.entity_details) : row.entity_details;
}
} catch { /* ignore parse errors */ }
return (
<>
<tr
className={`border-b hover:bg-muted/30 ${entities.length > 0 ? 'cursor-pointer' : ''}`}
onClick={() => entities.length > 0 && setExpanded(e => !e)}
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-1.5">
{entities.length > 0
? (expanded
? <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
: <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />)
: <span className="w-3.5" />}
<span className="capitalize">{row.sync_type}</span>
</div>
</td>
<td className="px-4 py-2.5"><StatusBadge status={row.status} /></td>
<td className="px-4 py-2.5 tabular-nums font-medium">{(row.records_added ?? 0).toLocaleString()}</td>
<td className="px-4 py-2.5 text-muted-foreground text-xs">{fmtDate(row.started_at)}</td>
<td className="px-4 py-2.5 text-muted-foreground">{dur != null ? fmtDur(dur) : '—'}</td>
<td className="px-4 py-2.5 text-muted-foreground capitalize">{row.triggered_by ?? '—'}</td>
</tr>
{expanded && entities.length > 0 && (
<tr className="border-b bg-muted/20">
<td colSpan={6} className="px-8 py-3">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">Entity Breakdown</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{entities.map((e) => (
<div key={e.entity} className={`rounded border px-3 py-2 text-xs ${e.success ? '' : 'border-red-300 bg-red-50 dark:bg-red-950/20'}`}>
<div className="font-medium text-foreground">{ENTITY_LABELS[e.entity] ?? e.entity}</div>
<div className="text-muted-foreground mt-0.5">
{e.success
? <><span className="text-green-700 font-semibold">{e.recordsUpserted.toLocaleString()}</span> records · {fmtDur(e.duration)}</>
: <span className="text-red-600">Failed: {e.error?.substring(0, 60)}</span>}
</div>
</div>
))}
</div>
{row.error_message && (
<div className="mt-2 text-xs text-red-600 bg-red-50 dark:bg-red-950/20 border border-red-200 rounded px-3 py-2">
{row.error_message}
</div>
)}
</td>
</tr>
)}
</>
);
}
function VeeamHistoryTab({ refreshKey }: { refreshKey: number }) {
const [rows, setRows] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch('/api/sync/history?entityType=veeam&limit=50')
.then(r => r.json())
.then(d => setRows(d.history ?? []))
.catch(() => setRows([]))
.finally(() => setLoading(false));
}, [refreshKey]);
if (loading) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
if (!rows.length) return <div className="text-center py-12 text-muted-foreground text-sm">No sync history yet run a sync to populate</div>;
return (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Records</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Started</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Duration</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Triggered By</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => <HistoryRow key={i} row={row} />)}
</tbody>
</table>
</div>
);
}
// ── Agents Tab ────────────────────────────────────────────────────────────────
function AgentsTab({ refreshKey }: { refreshKey: number }) {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch('/api/veeam/agents')
.then(r => r.json())
.then(d => setData(d))
.catch(() => setData(null))
.finally(() => setLoading(false));
}, [refreshKey]);
if (loading) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
if (!data || !data.agents?.length) return <div className="text-center py-12 text-muted-foreground text-sm">No agent data run a sync first</div>;
const s = data.summary ?? {};
const agents: any[] = data.agents ?? [];
return (
<div className="space-y-6">
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<StatCard label="Total Agents" value={s.total ?? 0} icon={Bot} />
<StatCard label="Active" value={s.active ?? 0} icon={CheckCircle2} cls="border-green-500/30 bg-green-500/5" />
<StatCard label="Inaccessible" value={s.inaccessible ?? 0} icon={XCircle}
cls={(s.inaccessible ?? 0) > 0 ? 'border-red-500/30 bg-red-500/5' : ''} />
<StatCard label="Outdated" value={s.outdated ?? 0} icon={AlertTriangle}
cls={(s.outdated ?? 0) > 0 ? 'border-yellow-500/30 bg-yellow-500/5' : ''} />
<StatCard label="Win / Linux / Mac" value={`${s.windows ?? 0} / ${s.linux ?? 0} / ${s.mac ?? 0}`} icon={Server} />
</div>
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Name</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Organization</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Platform</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Agent Status</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Version</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Mode</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Jobs</th>
</tr>
</thead>
<tbody>
{agents.map((a: any) => (
<tr key={a.instance_uid} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 font-medium">{a.name}</td>
<td className="px-4 py-2 text-muted-foreground text-xs">{a.organization_name ?? '—'}</td>
<td className="px-4 py-2 text-xs">{a.agent_platform ?? '—'}</td>
<td className="px-4 py-2">
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
a.status === 'Active' ? 'bg-green-500/15 text-green-700' : 'bg-muted text-muted-foreground'
}`}>{a.status ?? '—'}</span>
</td>
<td className="px-4 py-2">
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
a.management_agent_status === 'Inaccessible' ? 'bg-red-500/15 text-red-600' :
a.management_agent_status === 'Accessible' ? 'bg-green-500/15 text-green-700' :
'bg-muted text-muted-foreground'
}`}>{a.management_agent_status ?? '—'}</span>
</td>
<td className="px-4 py-2 text-xs">
<span className={a.version_status === 'Outdated' ? 'text-yellow-700 font-medium' : 'text-muted-foreground'}>
{a.version ?? '—'}
{a.version_status === 'Outdated' && ' ⚠'}
</span>
</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{a.operation_mode ?? '—'}</td>
<td className="px-4 py-2 text-xs tabular-nums">
<span className="text-green-700">{a.success_jobs_count ?? 0}</span>
{(a.running_jobs_count ?? 0) > 0 && <span className="text-blue-600 ml-1">{a.running_jobs_count}</span>}
{(a.total_jobs_count ?? 0) - (a.success_jobs_count ?? 0) - (a.running_jobs_count ?? 0) > 0 && (
<span className="text-red-600 ml-1">
{(a.total_jobs_count ?? 0) - (a.success_jobs_count ?? 0) - (a.running_jobs_count ?? 0)}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ── Alarms Tab ────────────────────────────────────────────────────────────────
function AlarmsTab({ refreshKey }: { refreshKey: number }) {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch('/api/veeam/alarms')
.then(r => r.json())
.then(d => setData(d))
.catch(() => setData(null))
.finally(() => setLoading(false));
}, [refreshKey]);
if (loading) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
if (!data || !data.alarms?.length) return <div className="text-center py-12 text-muted-foreground text-sm">No alarm data run a sync first</div>;
const s = data.summary ?? {};
const alarms: any[] = data.alarms ?? [];
return (
<div className="space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="Total Alarms" value={s.total ?? 0} icon={Bell} />
<StatCard label="Active" value={s.statusActive ?? 0} icon={XCircle}
cls={(s.statusActive ?? 0) > 0 ? 'border-red-500/30 bg-red-500/5' : ''} />
<StatCard label="Warning" value={s.statusWarning ?? 0} icon={AlertTriangle}
cls={(s.statusWarning ?? 0) > 0 ? 'border-yellow-500/30 bg-yellow-500/5' : ''} />
<StatCard label="Resolved" value={s.statusResolved ?? 0} icon={CheckCircle2}
cls="border-green-500/30 bg-green-500/5" />
</div>
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Object</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Organization</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Repeats</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Last Activation</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Message</th>
</tr>
</thead>
<tbody>
{alarms.map((a: any) => (
<tr key={a.instance_uid} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 font-medium">{a.object_computer_name || a.object_name || '—'}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{a.object_type ?? '—'}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{a.organization_name ?? '—'}</td>
<td className="px-4 py-2">
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
a.last_activation_status === 'Active' ? 'bg-red-500/15 text-red-600' :
a.last_activation_status === 'Warning' ? 'bg-yellow-500/15 text-yellow-700' :
a.last_activation_status === 'Resolved' ? 'bg-green-500/15 text-green-700' :
'bg-muted text-muted-foreground'
}`}>{a.last_activation_status ?? '—'}</span>
</td>
<td className="px-4 py-2 tabular-nums text-xs">{a.repeat_count ?? 0}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{fmtDate(a.last_activation_time)}</td>
<td className="px-4 py-2 text-xs text-muted-foreground max-w-xs truncate" title={a.last_activation_message ?? ''}>
{a.last_activation_message?.trim() || '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function VeeamSyncPage() {
const [status, setStatus] = useState<any>(null);
const [syncing, setSyncing] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const fetchStatus = async () => {
const res = await fetch('/api/integrations/status');
if (res.ok) { const d = await res.json(); setStatus(d.veeam); }
};
useEffect(() => { fetchStatus(); }, [refreshKey]);
const handleSync = async (syncType = 'full') => {
setSyncing(true);
try {
await fetch('/api/veeam/sync', {
method: 'POST',
body: JSON.stringify({ syncType }),
headers: { 'Content-Type': 'application/json' },
});
const poll = setInterval(async () => {
try {
const r = await fetch('/api/veeam/sync');
if (r.ok) {
const d = await r.json();
if (!d.isSyncing) {
clearInterval(poll);
setSyncing(false);
setRefreshKey(k => k + 1);
}
}
} catch { /* keep polling */ }
}, 4000);
} catch {
setSyncing(false);
}
};
return (
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
<div className="flex items-center gap-4">
<Link href="/admin/sync">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />Integrations
</Button>
</Link>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg border border-green-500/30 bg-green-500/5">
<Shield className="w-5 h-5 text-green-500" />
</div>
<div>
<h1 className="text-2xl font-bold">Backup Veeam VSPC</h1>
<p className="text-sm text-muted-foreground">Agent jobs, backup jobs, protected workloads, agents, alarms</p>
</div>
</div>
<div className="ml-auto">
<Link href="/backup-status">
<Button variant="outline" size="sm">View Backup Status</Button>
</Link>
</div>
</div>
<Tabs defaultValue="status" className="w-full">
<TabsList className="grid w-full max-w-2xl grid-cols-5">
<TabsTrigger value="status" className="gap-1.5"><Activity className="h-4 w-4" />Status</TabsTrigger>
<TabsTrigger value="history" className="gap-1.5"><History className="h-4 w-4" />History</TabsTrigger>
<TabsTrigger value="agents" className="gap-1.5"><Bot className="h-4 w-4" />Agents</TabsTrigger>
<TabsTrigger value="alarms" className="gap-1.5"><Bell className="h-4 w-4" />Alarms</TabsTrigger>
<TabsTrigger value="schedules" className="gap-1.5"><Calendar className="h-4 w-4" />Schedules</TabsTrigger>
</TabsList>
<TabsContent value="status" className="mt-6"><VeeamStatusTab data={status} onSync={handleSync} syncing={syncing} /></TabsContent>
<TabsContent value="history" className="mt-6"><VeeamHistoryTab refreshKey={refreshKey} /></TabsContent>
<TabsContent value="agents" className="mt-6"><AgentsTab refreshKey={refreshKey} /></TabsContent>
<TabsContent value="alarms" className="mt-6"><AlarmsTab refreshKey={refreshKey} /></TabsContent>
<TabsContent value="schedules" className="mt-6"><SyncScheduler /></TabsContent>
</Tabs>
</div>
);
}

View file

@ -0,0 +1,510 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
ArrowLeft,
Bot,
Plus,
Pencil,
Trash2,
Loader2,
GitBranch,
Tag,
AlertTriangle,
Layers,
Route,
} from 'lucide-react';
import { toast } from 'sonner';
import { ClassificationRule, RuleType, MatchField, MatchOperator, ConfidenceLevel } from '@/lib/types/workflow';
const RULE_TYPES: { value: RuleType; label: string; icon: any; description: string }[] = [
{ value: 'branch_routing', label: 'Branch Routing', icon: GitBranch, description: 'Route tickets to NOC, SOC, or Service Desk' },
{ value: 'ticket_type', label: 'Ticket Type', icon: Tag, description: 'Classify as Incident or Service Request' },
{ value: 'issue_classification', label: 'Issue Classification', icon: Layers, description: 'Assign Issue Type and Sub-Issue Type' },
{ value: 'priority', label: 'Priority', icon: AlertTriangle, description: 'Set ticket priority level' },
{ value: 'queue_routing', label: 'Queue Routing', icon: Route, description: 'Route to the appropriate queue' },
];
const MATCH_FIELDS: { value: MatchField; label: string }[] = [
{ value: 'title', label: 'Title' },
{ value: 'description', label: 'Description' },
{ value: 'title_or_description', label: 'Title or Description' },
{ value: 'ticket_category', label: 'Ticket Category' },
{ value: 'ticket_type', label: 'Ticket Type' },
{ value: 'priority', label: 'Priority' },
{ value: 'policy_name', label: 'Policy Name' },
{ value: 'device_name', label: 'Device Name' },
{ value: 'creator_resource_id', label: 'Creator Resource ID' },
{ value: 'person_id', label: 'Person ID' },
{ value: 'company_id', label: 'Company ID' },
];
const MATCH_OPERATORS: { value: MatchOperator; label: string }[] = [
{ value: 'contains', label: 'Contains (any of)' },
{ value: 'starts_with', label: 'Starts With' },
{ value: 'regex', label: 'Regex' },
{ value: 'equals', label: 'Equals' },
{ value: 'in', label: 'In List' },
{ value: 'not_in', label: 'Not In List' },
];
const defaultRule: Partial<ClassificationRule> = {
name: '',
description: '',
rule_type: 'branch_routing',
sort_order: 0,
is_active: true,
match_field: 'title_or_description',
match_operator: 'contains',
match_value: [],
match_case_sensitive: false,
result_field: 'branch',
result_value: '',
result_field_2: null,
result_value_2: null,
confidence: 'high',
stop_on_match: true,
};
export default function ClassificationRulesPage() {
const [rules, setRules] = useState<ClassificationRule[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<RuleType>('branch_routing');
const [editingRule, setEditingRule] = useState<Partial<ClassificationRule> | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [matchValueText, setMatchValueText] = useState('');
useEffect(() => {
loadRules();
}, []);
const loadRules = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/workflow/classification-rules');
if (res.ok) {
setRules(await res.json());
}
} catch (error) {
toast.error('Failed to load classification rules');
} finally {
setIsLoading(false);
}
};
const handleCreate = () => {
const newRule = { ...defaultRule, rule_type: activeTab };
// Set default result field based on rule type
switch (activeTab) {
case 'branch_routing': newRule.result_field = 'branch'; break;
case 'ticket_type': newRule.result_field = 'ticket_type'; break;
case 'issue_classification': newRule.result_field = 'issue_type'; newRule.result_field_2 = 'sub_issue_type'; break;
case 'priority': newRule.result_field = 'priority'; break;
case 'queue_routing': newRule.result_field = 'queue_id'; break;
}
setEditingRule(newRule);
setMatchValueText(Array.isArray(newRule.match_value) ? newRule.match_value.join('\n') : String(newRule.match_value || ''));
};
const handleEdit = (rule: ClassificationRule) => {
setEditingRule({ ...rule });
const mv = rule.match_value;
setMatchValueText(Array.isArray(mv) ? mv.join('\n') : String(mv || ''));
};
const handleSave = async () => {
if (!editingRule) return;
setIsSaving(true);
try {
// Parse match value based on operator
let parsedMatchValue: any = matchValueText;
if (['contains', 'in', 'not_in'].includes(editingRule.match_operator || '')) {
parsedMatchValue = matchValueText.split('\n').map(s => s.trim()).filter(Boolean);
}
const payload = {
...editingRule,
match_value: parsedMatchValue,
};
const isUpdate = 'id' in editingRule && editingRule.id;
const url = isUpdate
? `/api/workflow/classification-rules/${editingRule.id}`
: '/api/workflow/classification-rules';
const res = await fetch(url, {
method: isUpdate ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.ok) {
toast.success(isUpdate ? 'Rule updated' : 'Rule created');
setEditingRule(null);
loadRules();
} else {
toast.error('Failed to save rule');
}
} catch (error) {
toast.error('Failed to save rule');
} finally {
setIsSaving(false);
}
};
const handleDelete = async (id: number) => {
if (!confirm('Delete this classification rule?')) return;
try {
const res = await fetch(`/api/workflow/classification-rules/${id}`, { method: 'DELETE' });
if (res.ok) {
toast.success('Rule deleted');
loadRules();
}
} catch {
toast.error('Failed to delete rule');
}
};
const handleToggleActive = async (rule: ClassificationRule) => {
try {
await fetch(`/api/workflow/classification-rules/${rule.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !rule.is_active }),
});
loadRules();
} catch {
toast.error('Failed to toggle rule');
}
};
const filteredRules = rules.filter(r => r.rule_type === activeTab);
const formatMatchValue = (value: any): string => {
if (Array.isArray(value)) return value.join(', ');
return String(value);
};
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/workflow">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<Bot className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">Classification Rules</h1>
<p className="text-sm text-muted-foreground">DB-driven keyword classification for ticket triage</p>
</div>
</div>
</div>
{/* Tabs by rule type */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as RuleType)}>
<TabsList className="grid grid-cols-5 w-full">
{RULE_TYPES.map((rt) => (
<TabsTrigger key={rt.value} value={rt.value} className="text-xs sm:text-sm">
<rt.icon className="w-4 h-4 mr-1 hidden sm:inline" />
{rt.label}
</TabsTrigger>
))}
</TabsList>
{RULE_TYPES.map((rt) => (
<TabsContent key={rt.value} value={rt.value}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<rt.icon className="w-5 h-5" />
{rt.label} Rules
</CardTitle>
<CardDescription>{rt.description}</CardDescription>
</div>
<Button onClick={handleCreate} size="sm">
<Plus className="w-4 h-4 mr-2" />
New Rule
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
) : filteredRules.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No {rt.label.toLowerCase()} rules configured.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Order</TableHead>
<TableHead>Name</TableHead>
<TableHead>Pattern</TableHead>
<TableHead>Result</TableHead>
<TableHead className="w-24">Confidence</TableHead>
<TableHead className="w-20">Active</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRules.map((rule) => (
<TableRow key={rule.id}>
<TableCell className="font-mono text-sm">{rule.sort_order}</TableCell>
<TableCell className="font-medium">{rule.name}</TableCell>
<TableCell>
<div className="text-xs space-y-1">
<Badge variant="outline" className="text-xs">{rule.match_field}</Badge>
<Badge variant="secondary" className="text-xs ml-1">{rule.match_operator}</Badge>
<div className="text-muted-foreground truncate max-w-xs" title={formatMatchValue(rule.match_value)}>
{formatMatchValue(rule.match_value)}
</div>
</div>
</TableCell>
<TableCell>
<div className="text-sm">
<span className="font-medium">{rule.result_field}:</span> {String(rule.result_value)}
{rule.result_field_2 && (
<div className="text-xs text-muted-foreground">
{rule.result_field_2}: {String(rule.result_value_2)}
</div>
)}
</div>
</TableCell>
<TableCell>
<Badge variant={
rule.confidence === 'high' ? 'default' :
rule.confidence === 'medium' ? 'secondary' : 'outline'
}>
{rule.confidence}
</Badge>
</TableCell>
<TableCell>
<Switch
checked={rule.is_active}
onCheckedChange={() => handleToggleActive(rule)}
/>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => handleEdit(rule)}>
<Pencil className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(rule.id)}>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
))}
</Tabs>
{/* Edit/Create Dialog */}
<Dialog open={!!editingRule} onOpenChange={(open) => !open && setEditingRule(null)}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingRule?.id ? 'Edit' : 'New'} Classification Rule</DialogTitle>
<DialogDescription>Configure pattern matching and classification result</DialogDescription>
</DialogHeader>
{editingRule && (
<div className="space-y-4">
{/* Basic Info */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={editingRule.name || ''}
onChange={(e) => setEditingRule({ ...editingRule, name: e.target.value })}
placeholder="Rule name"
/>
</div>
<div className="space-y-2">
<Label>Sort Order</Label>
<Input
type="number"
value={editingRule.sort_order ?? 0}
onChange={(e) => setEditingRule({ ...editingRule, sort_order: parseInt(e.target.value) })}
/>
</div>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input
value={editingRule.description || ''}
onChange={(e) => setEditingRule({ ...editingRule, description: e.target.value })}
placeholder="Optional description"
/>
</div>
{/* Pattern Matching */}
<div className="border rounded-lg p-4 space-y-4">
<h4 className="font-medium">Pattern Matching</h4>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Match Field</Label>
<Select
value={editingRule.match_field}
onValueChange={(v) => setEditingRule({ ...editingRule, match_field: v as MatchField })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{MATCH_FIELDS.map((f) => (
<SelectItem key={f.value} value={f.value}>{f.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Operator</Label>
<Select
value={editingRule.match_operator}
onValueChange={(v) => setEditingRule({ ...editingRule, match_operator: v as MatchOperator })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{MATCH_OPERATORS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>
Match Values
{['contains', 'in', 'not_in'].includes(editingRule.match_operator || '') && (
<span className="text-xs text-muted-foreground ml-2">(one per line)</span>
)}
</Label>
<Textarea
value={matchValueText}
onChange={(e) => setMatchValueText(e.target.value)}
placeholder={['contains', 'in', 'not_in'].includes(editingRule.match_operator || '')
? 'keyword1\nkeyword2\nkeyword3'
: 'value'
}
rows={4}
/>
</div>
</div>
{/* Result */}
<div className="border rounded-lg p-4 space-y-4">
<h4 className="font-medium">Classification Result</h4>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Result Field</Label>
<Input
value={editingRule.result_field || ''}
onChange={(e) => setEditingRule({ ...editingRule, result_field: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Result Value</Label>
<Input
value={String(editingRule.result_value || '')}
onChange={(e) => setEditingRule({ ...editingRule, result_value: e.target.value })}
/>
</div>
</div>
{editingRule.rule_type === 'issue_classification' && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Secondary Field</Label>
<Input
value={editingRule.result_field_2 || ''}
onChange={(e) => setEditingRule({ ...editingRule, result_field_2: e.target.value })}
placeholder="sub_issue_type"
/>
</div>
<div className="space-y-2">
<Label>Secondary Value</Label>
<Input
value={String(editingRule.result_value_2 || '')}
onChange={(e) => setEditingRule({ ...editingRule, result_value_2: e.target.value })}
/>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Confidence</Label>
<Select
value={editingRule.confidence}
onValueChange={(v) => setEditingRule({ ...editingRule, confidence: v as ConfidenceLevel })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2 pb-2">
<Switch
checked={editingRule.stop_on_match ?? true}
onCheckedChange={(v) => setEditingRule({ ...editingRule, stop_on_match: v })}
/>
<Label>Stop on match</Label>
</div>
</div>
</div>
{/* Save */}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setEditingRule(null)}>Cancel</Button>
<Button onClick={handleSave} disabled={isSaving || !editingRule.name}>
{isSaving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{editingRule.id ? 'Update' : 'Create'} Rule
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,314 @@
'use client';
import { useState, useEffect, Suspense } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
ArrowLeft,
History,
CheckCircle2,
XCircle,
Clock,
Activity,
Loader2,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { WorkflowExecutionWithSteps } from '@/lib/types/workflow';
export default function ExecutionHistoryPage() {
return (
<Suspense>
<ExecutionHistoryContent />
</Suspense>
);
}
function ExecutionHistoryContent() {
const searchParams = useSearchParams();
const highlightId = searchParams.get('id');
const [executions, setExecutions] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [methodFilter, setMethodFilter] = useState<string>('all');
const [isLoading, setIsLoading] = useState(true);
const [selectedExecution, setSelectedExecution] = useState<WorkflowExecutionWithSteps | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const pageSize = 25;
useEffect(() => { loadExecutions(); }, [page, statusFilter, methodFilter]);
const loadExecutions = async () => {
setIsLoading(true);
try {
const params = new URLSearchParams({
limit: pageSize.toString(),
offset: (page * pageSize).toString(),
});
if (statusFilter !== 'all') params.set('status', statusFilter);
if (methodFilter !== 'all') params.set('method', methodFilter);
const res = await fetch(`/api/workflow/executions?${params}`);
if (res.ok) {
const data = await res.json();
setExecutions(data.data || []);
setTotal(data.total || 0);
}
} catch (error) {
console.error('Failed to load executions:', error);
} finally {
setIsLoading(false);
}
};
const loadExecutionDetail = async (id: number) => {
setDetailLoading(true);
try {
const res = await fetch(`/api/workflow/executions/${id}`);
if (res.ok) {
setSelectedExecution(await res.json());
}
} catch (error) {
console.error('Failed to load execution detail:', error);
} finally {
setDetailLoading(false);
}
};
const statusIcon = (status: string) => {
switch (status) {
case 'completed': return <CheckCircle2 className="w-4 h-4 text-green-500" />;
case 'failed': return <XCircle className="w-4 h-4 text-red-500" />;
case 'skipped': return <Clock className="w-4 h-4 text-gray-500" />;
case 'running': return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />;
default: return <Clock className="w-4 h-4 text-gray-400" />;
}
};
const methodBadge = (method: string | null) => {
if (!method) return <Badge variant="outline" className="text-xs">n/a</Badge>;
const variant = method === 'robotic' ? 'default' : method === 'ai' ? 'secondary' : 'outline';
return <Badge variant={variant} className="text-xs">{method}</Badge>;
};
const totalPages = Math.ceil(total / pageSize);
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-3">
<Link href="/admin/workflow">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<History className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">Execution History</h1>
<p className="text-sm text-muted-foreground">{total} total executions</p>
</div>
</div>
{/* Filters */}
<div className="flex gap-4">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-40"><SelectValue placeholder="Status" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="failed">Failed</SelectItem>
<SelectItem value="skipped">Skipped</SelectItem>
<SelectItem value="running">Running</SelectItem>
</SelectContent>
</Select>
<Select value={methodFilter} onValueChange={setMethodFilter}>
<SelectTrigger className="w-40"><SelectValue placeholder="Method" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Methods</SelectItem>
<SelectItem value="robotic">Robotic</SelectItem>
<SelectItem value="ai">AI</SelectItem>
<SelectItem value="hybrid">Hybrid</SelectItem>
</SelectContent>
</Select>
</div>
<Card>
<CardContent className="pt-6">
{isLoading ? (
<div className="flex justify-center py-8"><Loader2 className="w-6 h-6 animate-spin" /></div>
) : executions.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No executions found.</p>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">Status</TableHead>
<TableHead>Ticket</TableHead>
<TableHead>Branch</TableHead>
<TableHead>Method</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{executions.map((exec) => (
<TableRow
key={exec.id}
className={`cursor-pointer hover:bg-muted/50 ${highlightId === String(exec.id) ? 'bg-muted' : ''}`}
onClick={() => loadExecutionDetail(exec.id)}
>
<TableCell>{statusIcon(exec.status)}</TableCell>
<TableCell className="font-medium">
{exec.ticket_number ? `#${exec.ticket_number}` : `ID ${exec.entity_id}`}
</TableCell>
<TableCell>
{exec.branch && <Badge variant="outline">{exec.branch}</Badge>}
</TableCell>
<TableCell>{methodBadge(exec.classification_method)}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{exec.duration_ms ? `${exec.duration_ms}ms` : '-'}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(exec.created_at).toLocaleString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* Pagination */}
<div className="flex items-center justify-between mt-4">
<span className="text-sm text-muted-foreground">
Page {page + 1} of {totalPages}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page === 0} onClick={() => setPage(p => p - 1)}>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages - 1} onClick={() => setPage(p => p + 1)}>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
{/* Execution Detail Dialog */}
<Dialog open={!!selectedExecution} onOpenChange={(open) => !open && setSelectedExecution(null)}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
Execution #{selectedExecution?.id}
{selectedExecution?.ticket_number && ` — Ticket #${selectedExecution.ticket_number}`}
</DialogTitle>
</DialogHeader>
{detailLoading ? (
<div className="flex justify-center py-8"><Loader2 className="w-6 h-6 animate-spin" /></div>
) : selectedExecution && (
<div className="space-y-4">
{/* Summary */}
<div className="grid grid-cols-4 gap-4">
<div>
<p className="text-xs text-muted-foreground">Status</p>
<div className="flex items-center gap-1 mt-1">
{statusIcon(selectedExecution.status)}
<span className="font-medium text-sm">{selectedExecution.status}</span>
</div>
</div>
<div>
<p className="text-xs text-muted-foreground">Method</p>
<div className="mt-1">{methodBadge(selectedExecution.classification_method)}</div>
</div>
<div>
<p className="text-xs text-muted-foreground">Branch</p>
<p className="font-medium text-sm mt-1">{selectedExecution.branch || '-'}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Duration</p>
<p className="font-medium text-sm mt-1">{selectedExecution.duration_ms}ms</p>
</div>
</div>
{selectedExecution.error_message && (
<div className="bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded p-3">
<p className="text-sm text-red-600 dark:text-red-400">{selectedExecution.error_message}</p>
</div>
)}
{/* Steps */}
<div>
<h4 className="font-medium mb-2">Processing Steps</h4>
<div className="space-y-2">
{selectedExecution.steps?.map((step, i) => (
<div key={step.id || i} className="border rounded-lg p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{statusIcon(step.status)}
<span className="font-medium text-sm">{step.step_name}</span>
{step.method && (
<Badge variant={step.method === 'robotic' ? 'default' : 'secondary'} className="text-xs">
{step.method}
</Badge>
)}
{step.confidence && (
<Badge variant="outline" className="text-xs">{step.confidence}</Badge>
)}
</div>
{step.duration_ms != null && (
<span className="text-xs text-muted-foreground">{step.duration_ms}ms</span>
)}
</div>
{step.output_data && (
<pre className="text-xs text-muted-foreground mt-2 bg-muted p-2 rounded overflow-x-auto">
{JSON.stringify(step.output_data, null, 2)}
</pre>
)}
{step.field_changes && (
<div className="mt-2 text-xs">
{Object.entries(step.field_changes).map(([field, change]) => (
<div key={field}>
<span className="font-medium">{field}:</span>{' '}
<span className="text-red-500">{String((change as any).before)}</span>
{' → '}
<span className="text-green-500">{String((change as any).after)}</span>
</div>
))}
</div>
)}
{step.error_message && (
<p className="text-xs text-red-500 mt-1">{step.error_message}</p>
)}
</div>
))}
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}

291
app/admin/workflow/page.tsx Normal file
View file

@ -0,0 +1,291 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import {
ArrowLeft,
Workflow,
Bot,
Cog,
ListFilter,
History,
FileText,
Settings,
CheckCircle2,
XCircle,
Clock,
Zap,
Brain,
Activity,
} from 'lucide-react';
interface ExecutionStats {
total: number;
completed: number;
failed: number;
skipped: number;
robotic: number;
ai: number;
hybrid: number;
}
export default function WorkflowDashboardPage() {
const [enabled, setEnabled] = useState(false);
const [stats, setStats] = useState<ExecutionStats>({ total: 0, completed: 0, failed: 0, skipped: 0, robotic: 0, ai: 0, hybrid: 0 });
const [recentExecutions, setRecentExecutions] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadDashboard();
}, []);
const loadDashboard = async () => {
setIsLoading(true);
try {
const [settingsRes, execRes] = await Promise.all([
fetch('/api/workflow/settings'),
fetch('/api/workflow/executions?limit=10'),
]);
if (settingsRes.ok) {
const settings = await settingsRes.json();
setEnabled(settings.workflow_engine_enabled?.value ?? false);
}
if (execRes.ok) {
const execData = await execRes.json();
setRecentExecutions(execData.data || []);
// Calculate stats from recent executions
const all = execData.data || [];
setStats({
total: execData.total || 0,
completed: all.filter((e: any) => e.status === 'completed').length,
failed: all.filter((e: any) => e.status === 'failed').length,
skipped: all.filter((e: any) => e.status === 'skipped').length,
robotic: all.filter((e: any) => e.classification_method === 'robotic').length,
ai: all.filter((e: any) => e.classification_method === 'ai').length,
hybrid: all.filter((e: any) => e.classification_method === 'hybrid').length,
});
}
} catch (error) {
console.error('Failed to load dashboard:', error);
} finally {
setIsLoading(false);
}
};
const toggleEngine = async () => {
try {
const newValue = !enabled;
await fetch('/api/workflow/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow_engine_enabled: newValue }),
});
setEnabled(newValue);
} catch (error) {
console.error('Failed to toggle engine:', error);
}
};
const navCards = [
{
title: 'Classification Rules',
description: 'Keyword-based rules for ticket classification',
href: '/admin/workflow/classification',
icon: Bot,
color: 'text-blue-500',
},
{
title: 'Filter Rules',
description: 'Exclusion/inclusion filters for ticket processing',
href: '/admin/workflow/rules',
icon: ListFilter,
color: 'text-orange-500',
},
{
title: 'AI Templates',
description: 'Prompt templates for AI-assisted classification',
href: '/admin/workflow/templates',
icon: Brain,
color: 'text-purple-500',
},
{
title: 'Execution History',
description: 'View past workflow executions and results',
href: '/admin/workflow/history',
icon: History,
color: 'text-green-500',
},
{
title: 'Settings',
description: 'AI providers, thresholds, and delays',
href: '/admin/workflow/settings',
icon: Settings,
color: 'text-gray-500',
},
];
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/sync">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<Workflow className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">Workflow Engine</h1>
<p className="text-sm text-muted-foreground">Automated ticket triage and classification</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
{enabled ? 'Engine Active' : 'Engine Disabled'}
</span>
<Switch checked={enabled} onCheckedChange={toggleEngine} />
<Badge variant={enabled ? 'default' : 'secondary'}>
{enabled ? 'ON' : 'OFF'}
</Badge>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Total Executions</p>
<p className="text-2xl font-bold">{stats.total}</p>
</div>
<Activity className="w-8 h-8 text-muted-foreground/50" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Completed</p>
<p className="text-2xl font-bold text-green-600">{stats.completed}</p>
</div>
<CheckCircle2 className="w-8 h-8 text-green-500/50" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Robotic</p>
<p className="text-2xl font-bold text-blue-600">{stats.robotic}</p>
</div>
<Zap className="w-8 h-8 text-blue-500/50" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">AI/Hybrid</p>
<p className="text-2xl font-bold text-purple-600">{stats.ai + stats.hybrid}</p>
</div>
<Brain className="w-8 h-8 text-purple-500/50" />
</div>
</CardContent>
</Card>
</div>
{/* Navigation Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{navCards.map((card) => (
<Link key={card.href} href={card.href}>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
<CardHeader>
<div className="flex items-center gap-3">
<card.icon className={`w-5 h-5 ${card.color}`} />
<CardTitle className="text-lg">{card.title}</CardTitle>
</div>
<CardDescription>{card.description}</CardDescription>
</CardHeader>
</Card>
</Link>
))}
</div>
{/* Recent Executions */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Recent Executions</CardTitle>
<CardDescription>Last 10 workflow runs</CardDescription>
</div>
<Link href="/admin/workflow/history">
<Button variant="outline" size="sm">View All</Button>
</Link>
</div>
</CardHeader>
<CardContent>
{recentExecutions.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No executions yet. Workflow engine will process incoming tickets when enabled.
</p>
) : (
<div className="space-y-2">
{recentExecutions.map((exec) => (
<Link key={exec.id} href={`/admin/workflow/history?id=${exec.id}`}>
<div className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors cursor-pointer">
<div className="flex items-center gap-3">
{exec.status === 'completed' && <CheckCircle2 className="w-4 h-4 text-green-500" />}
{exec.status === 'failed' && <XCircle className="w-4 h-4 text-red-500" />}
{exec.status === 'skipped' && <Clock className="w-4 h-4 text-gray-500" />}
{exec.status === 'running' && <Activity className="w-4 h-4 text-blue-500 animate-pulse" />}
<div>
<span className="font-medium text-sm">
{exec.ticket_number ? `Ticket #${exec.ticket_number}` : `Entity ${exec.entity_id}`}
</span>
<span className="text-xs text-muted-foreground ml-2">
{new Date(exec.created_at).toLocaleString()}
</span>
</div>
</div>
<div className="flex items-center gap-2">
{exec.branch && (
<Badge variant="outline" className="text-xs">{exec.branch}</Badge>
)}
<Badge variant={
exec.classification_method === 'robotic' ? 'default' :
exec.classification_method === 'ai' ? 'secondary' : 'outline'
} className="text-xs">
{exec.classification_method || 'n/a'}
</Badge>
{exec.duration_ms && (
<span className="text-xs text-muted-foreground">{exec.duration_ms}ms</span>
)}
</div>
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,365 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
ArrowLeft,
ListFilter,
Plus,
Pencil,
Trash2,
Loader2,
} from 'lucide-react';
import { toast } from 'sonner';
import { WorkflowRuleWithDetails, ConditionOperator } from '@/lib/types/workflow';
const CONDITION_FIELDS = [
'person_id', 'creator_resource_id', 'ticket_category', 'company_id',
'title', 'description', 'priority', 'ticket_type', 'status', 'queue_id',
];
const CONDITION_OPERATORS: { value: ConditionOperator; label: string }[] = [
{ value: 'equals', label: 'Equals' },
{ value: 'not_equals', label: 'Not Equals' },
{ value: 'in', label: 'In List' },
{ value: 'not_in', label: 'Not In List' },
{ value: 'contains', label: 'Contains' },
{ value: 'not_contains', label: 'Not Contains' },
{ value: 'is_null', label: 'Is Null' },
{ value: 'is_not_null', label: 'Is Not Null' },
];
export default function WorkflowRulesPage() {
const [rules, setRules] = useState<WorkflowRuleWithDetails[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [editingRule, setEditingRule] = useState<any | null>(null);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => { loadRules(); }, []);
const loadRules = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/workflow/rules');
if (res.ok) setRules(await res.json());
} catch { toast.error('Failed to load rules'); }
finally { setIsLoading(false); }
};
const handleCreate = () => {
setEditingRule({
name: '',
description: '',
trigger_event: 'ticket.created',
is_active: true,
sort_order: 0,
stop_processing: true,
conditions: [{ condition_group: 0, field: 'ticket_category', operator: 'in', value: [] }],
actions: [{ sort_order: 0, action_type: 'skip', config: { reason: '' } }],
});
};
const handleEdit = (rule: WorkflowRuleWithDetails) => {
setEditingRule({
...rule,
conditions: rule.conditions.map(c => ({ ...c, value: c.value })),
actions: rule.actions.map(a => ({ ...a })),
});
};
const handleSave = async () => {
if (!editingRule) return;
setIsSaving(true);
try {
const isUpdate = editingRule.id;
const url = isUpdate ? `/api/workflow/rules/${editingRule.id}` : '/api/workflow/rules';
const res = await fetch(url, {
method: isUpdate ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingRule),
});
if (res.ok) {
toast.success(isUpdate ? 'Rule updated' : 'Rule created');
setEditingRule(null);
loadRules();
} else {
toast.error('Failed to save rule');
}
} catch { toast.error('Failed to save rule'); }
finally { setIsSaving(false); }
};
const handleDelete = async (id: number) => {
if (!confirm('Delete this workflow rule and all its conditions?')) return;
try {
const res = await fetch(`/api/workflow/rules/${id}`, { method: 'DELETE' });
if (res.ok) { toast.success('Rule deleted'); loadRules(); }
} catch { toast.error('Failed to delete rule'); }
};
const handleToggle = async (rule: WorkflowRuleWithDetails) => {
try {
await fetch(`/api/workflow/rules/${rule.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !rule.is_active }),
});
loadRules();
} catch { toast.error('Failed to toggle rule'); }
};
const summarizeConditions = (rule: WorkflowRuleWithDetails): string => {
return rule.conditions.map(c => `${c.field} ${c.operator} ${JSON.stringify(c.value)}`).join(' AND ');
};
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/workflow">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<ListFilter className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">Filter Rules</h1>
<p className="text-sm text-muted-foreground">Exclusion/inclusion filters for ticket processing</p>
</div>
</div>
<Button onClick={handleCreate} size="sm">
<Plus className="w-4 h-4 mr-2" />
New Rule
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Active Filter Rules</CardTitle>
<CardDescription>Rules are evaluated in sort order. Matching a &quot;skip&quot; action stops processing.</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center py-8"><Loader2 className="w-6 h-6 animate-spin" /></div>
) : rules.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No filter rules configured.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Order</TableHead>
<TableHead>Name</TableHead>
<TableHead>Trigger</TableHead>
<TableHead>Conditions</TableHead>
<TableHead>Action</TableHead>
<TableHead className="w-20">Active</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rules.map((rule) => (
<TableRow key={rule.id}>
<TableCell className="font-mono">{rule.sort_order}</TableCell>
<TableCell className="font-medium">{rule.name}</TableCell>
<TableCell><Badge variant="outline">{rule.trigger_event}</Badge></TableCell>
<TableCell>
<div className="text-xs text-muted-foreground max-w-xs truncate" title={summarizeConditions(rule)}>
{summarizeConditions(rule)}
</div>
</TableCell>
<TableCell>
{rule.actions.map(a => (
<Badge key={a.id} variant="secondary" className="text-xs">{a.action_type}</Badge>
))}
</TableCell>
<TableCell>
<Switch checked={rule.is_active} onCheckedChange={() => handleToggle(rule)} />
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => handleEdit(rule)}>
<Pencil className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(rule.id)}>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Edit Dialog */}
<Dialog open={!!editingRule} onOpenChange={(open) => !open && setEditingRule(null)}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingRule?.id ? 'Edit' : 'New'} Filter Rule</DialogTitle>
<DialogDescription>Configure conditions that determine which tickets to process or skip</DialogDescription>
</DialogHeader>
{editingRule && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Name</Label>
<Input value={editingRule.name} onChange={(e) => setEditingRule({ ...editingRule, name: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Sort Order</Label>
<Input type="number" value={editingRule.sort_order} onChange={(e) => setEditingRule({ ...editingRule, sort_order: parseInt(e.target.value) })} />
</div>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input value={editingRule.description || ''} onChange={(e) => setEditingRule({ ...editingRule, description: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Trigger Event</Label>
<Select value={editingRule.trigger_event} onValueChange={(v) => setEditingRule({ ...editingRule, trigger_event: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="ticket.created">Ticket Created</SelectItem>
<SelectItem value="ticket.updated">Ticket Updated</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2 pb-2">
<Switch checked={editingRule.stop_processing} onCheckedChange={(v) => setEditingRule({ ...editingRule, stop_processing: v })} />
<Label>Stop processing on match</Label>
</div>
</div>
{/* Conditions */}
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<h4 className="font-medium">Conditions</h4>
<Button variant="outline" size="sm" onClick={() => {
setEditingRule({
...editingRule,
conditions: [...editingRule.conditions, { condition_group: 0, field: 'ticket_category', operator: 'equals', value: '' }],
});
}}>
<Plus className="w-3 h-3 mr-1" /> Add
</Button>
</div>
{editingRule.conditions.map((cond: any, i: number) => (
<div key={i} className="grid grid-cols-4 gap-2 items-end">
<Select value={cond.field} onValueChange={(v) => {
const updated = [...editingRule.conditions];
updated[i] = { ...updated[i], field: v };
setEditingRule({ ...editingRule, conditions: updated });
}}>
<SelectTrigger className="text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{CONDITION_FIELDS.map(f => <SelectItem key={f} value={f}>{f}</SelectItem>)}
</SelectContent>
</Select>
<Select value={cond.operator} onValueChange={(v) => {
const updated = [...editingRule.conditions];
updated[i] = { ...updated[i], operator: v };
setEditingRule({ ...editingRule, conditions: updated });
}}>
<SelectTrigger className="text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{CONDITION_OPERATORS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<Input
className="text-xs"
value={typeof cond.value === 'string' ? cond.value : JSON.stringify(cond.value)}
onChange={(e) => {
const updated = [...editingRule.conditions];
let val: any = e.target.value;
try { val = JSON.parse(val); } catch {}
updated[i] = { ...updated[i], value: val };
setEditingRule({ ...editingRule, conditions: updated });
}}
placeholder="value or [1,2,3]"
/>
<Button variant="ghost" size="sm" onClick={() => {
setEditingRule({
...editingRule,
conditions: editingRule.conditions.filter((_: any, j: number) => j !== i),
});
}}>
<Trash2 className="w-3 h-3 text-red-500" />
</Button>
</div>
))}
</div>
{/* Action */}
<div className="border rounded-lg p-4 space-y-3">
<h4 className="font-medium">Action</h4>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Action Type</Label>
<Select value={editingRule.actions[0]?.action_type || 'skip'} onValueChange={(v) => {
const actions = [{ ...editingRule.actions[0], action_type: v }];
setEditingRule({ ...editingRule, actions });
}}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="skip">Skip (exclude from triage)</SelectItem>
<SelectItem value="classify">Classify</SelectItem>
<SelectItem value="set_field">Set Field</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Reason / Config</Label>
<Input
value={editingRule.actions[0]?.config?.reason || ''}
onChange={(e) => {
const actions = [{ ...editingRule.actions[0], config: { reason: e.target.value } }];
setEditingRule({ ...editingRule, actions });
}}
placeholder="Skip reason"
/>
</div>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setEditingRule(null)}>Cancel</Button>
<Button onClick={handleSave} disabled={isSaving || !editingRule.name}>
{isSaving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{editingRule.id ? 'Update' : 'Create'} Rule
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,274 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ArrowLeft, Settings, Loader2, Save } from 'lucide-react';
import { toast } from 'sonner';
interface SettingEntry {
value: any;
description: string;
}
export default function WorkflowSettingsPage() {
const [settings, setSettings] = useState<Record<string, SettingEntry>>({});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [modified, setModified] = useState<Record<string, any>>({});
useEffect(() => { loadSettings(); }, []);
const loadSettings = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/workflow/settings');
if (res.ok) {
setSettings(await res.json());
setModified({});
}
} catch { toast.error('Failed to load settings'); }
finally { setIsLoading(false); }
};
const getValue = (key: string): any => {
if (key in modified) return modified[key];
return settings[key]?.value;
};
const setValue = (key: string, value: any) => {
setModified({ ...modified, [key]: value });
};
const handleSave = async () => {
if (Object.keys(modified).length === 0) return;
setIsSaving(true);
try {
const res = await fetch('/api/workflow/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(modified),
});
if (res.ok) {
toast.success('Settings saved');
setSettings(await res.json());
setModified({});
}
} catch { toast.error('Failed to save settings'); }
finally { setIsSaving(false); }
};
if (isLoading) {
return (
<div className="container mx-auto p-6 flex justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
);
}
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/workflow">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<Settings className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">Workflow Settings</h1>
<p className="text-sm text-muted-foreground">Configure AI providers, thresholds, and behavior</p>
</div>
</div>
{Object.keys(modified).length > 0 && (
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Save className="w-4 h-4 mr-2" />}
Save Changes
</Button>
)}
</div>
{/* Engine Control */}
<Card>
<CardHeader>
<CardTitle>Engine Control</CardTitle>
<CardDescription>Master enable/disable for the workflow engine</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<Label>Workflow Engine</Label>
<p className="text-sm text-muted-foreground">When enabled, new tickets are automatically classified</p>
</div>
<Switch
checked={getValue('workflow_engine_enabled') ?? false}
onCheckedChange={(v) => setValue('workflow_engine_enabled', v)}
/>
</div>
</CardContent>
</Card>
{/* AI Provider Config */}
<Card>
<CardHeader>
<CardTitle>AI Provider Configuration</CardTitle>
<CardDescription>Configure API keys and models for AI-assisted classification</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>Default AI Provider</Label>
<Select value={getValue('default_ai_provider') || 'openai'} onValueChange={(v) => setValue('default_ai_provider', v)}>
<SelectTrigger className="w-60"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="anthropic">Anthropic</SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
<div className="grid grid-cols-2 gap-6">
<div className="space-y-4">
<h4 className="font-medium">OpenAI</h4>
<div className="space-y-2">
<Label>API Key</Label>
<Input
type="password"
value={getValue('openai_api_key') || ''}
onChange={(e) => setValue('openai_api_key', e.target.value)}
placeholder="sk-..."
/>
</div>
<div className="space-y-2">
<Label>Model</Label>
<Input
value={getValue('openai_model') || 'gpt-4o'}
onChange={(e) => setValue('openai_model', e.target.value)}
/>
</div>
</div>
<div className="space-y-4">
<h4 className="font-medium">Anthropic</h4>
<div className="space-y-2">
<Label>API Key</Label>
<Input
type="password"
value={getValue('anthropic_api_key') || ''}
onChange={(e) => setValue('anthropic_api_key', e.target.value)}
placeholder="sk-ant-..."
/>
</div>
<div className="space-y-2">
<Label>Model</Label>
<Input
value={getValue('anthropic_model') || 'claude-sonnet-4-20250514'}
onChange={(e) => setValue('anthropic_model', e.target.value)}
/>
</div>
</div>
</div>
</CardContent>
</Card>
{/* AI Feature Toggles */}
<Card>
<CardHeader>
<CardTitle>AI Feature Toggles</CardTitle>
<CardDescription>Control when AI is used during triage</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{[
{ key: 'ai_for_title_cleanup', label: 'Title Cleanup', desc: 'Use AI to clean up messy ticket titles (email subjects, long titles)' },
{ key: 'ai_for_description_rewrite', label: 'Description Rewrite', desc: 'Use AI to restructure unstructured ticket descriptions' },
{ key: 'ai_for_ambiguous_classification', label: 'Ambiguous Classification', desc: 'Use AI when robotic classifier has no confident match' },
{ key: 'ai_for_troubleshooting', label: 'Troubleshooting Steps', desc: 'Generate AI troubleshooting steps for incident tickets' },
].map(({ key, label, desc }) => (
<div key={key} className="flex items-center justify-between">
<div>
<Label>{label}</Label>
<p className="text-sm text-muted-foreground">{desc}</p>
</div>
<Switch
checked={getValue(key) ?? true}
onCheckedChange={(v) => setValue(key, v)}
/>
</div>
))}
</CardContent>
</Card>
{/* Processing Config */}
<Card>
<CardHeader>
<CardTitle>Processing Configuration</CardTitle>
<CardDescription>Thresholds, delays, and retry settings</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<Label>Confidence Threshold</Label>
<p className="text-xs text-muted-foreground">Minimum confidence to skip AI classification</p>
<Select
value={getValue('classification_confidence_threshold') || 'medium'}
onValueChange={(v) => setValue('classification_confidence_threshold', v)}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="high">High (AI for medium + low)</SelectItem>
<SelectItem value="medium">Medium (AI only for low)</SelectItem>
<SelectItem value="low">Low (AI rarely used)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Autotask Update Delay (ms)</Label>
<p className="text-xs text-muted-foreground">Delay before writing back to Autotask to avoid WF rule conflicts</p>
<Input
type="number"
value={getValue('autotask_update_delay_ms') ?? 30000}
onChange={(e) => setValue('autotask_update_delay_ms', parseInt(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label>Max AI Retries</Label>
<p className="text-xs text-muted-foreground">Max retry attempts when AI validation fails</p>
<Input
type="number"
value={getValue('max_ai_retries') ?? 2}
onChange={(e) => setValue('max_ai_retries', parseInt(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label>Log Retention (days)</Label>
<p className="text-xs text-muted-foreground">Days to retain execution logs</p>
<Input
type="number"
value={getValue('log_retention_days') ?? 90}
onChange={(e) => setValue('log_retention_days', parseInt(e.target.value))}
/>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,261 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ArrowLeft, Brain, Plus, Pencil, Trash2, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { AiPromptTemplate, PromptPurpose } from '@/lib/types/workflow';
const PURPOSES: { value: PromptPurpose; label: string }[] = [
{ value: 'title_cleanup', label: 'Title Cleanup' },
{ value: 'description_rewrite', label: 'Description Rewrite' },
{ value: 'ambiguous_classification', label: 'Ambiguous Classification' },
{ value: 'troubleshooting_steps', label: 'Troubleshooting Steps' },
{ value: 'noc_format', label: 'NOC Format' },
{ value: 'soc_analysis', label: 'SOC Analysis' },
];
export default function TemplatesPage() {
const [templates, setTemplates] = useState<AiPromptTemplate[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [editing, setEditing] = useState<Partial<AiPromptTemplate> | null>(null);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => { loadTemplates(); }, []);
const loadTemplates = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/workflow/templates');
if (res.ok) setTemplates(await res.json());
} catch { toast.error('Failed to load templates'); }
finally { setIsLoading(false); }
};
const handleCreate = () => {
setEditing({
name: '',
purpose: 'title_cleanup',
system_prompt: '',
user_prompt_template: '',
provider: 'openai',
model: 'gpt-4o',
temperature: 0.3,
max_tokens: 4000,
is_active: true,
});
};
const handleSave = async () => {
if (!editing) return;
setIsSaving(true);
try {
const isUpdate = editing.id;
const url = isUpdate ? `/api/workflow/templates/${editing.id}` : '/api/workflow/templates';
const res = await fetch(url, {
method: isUpdate ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editing),
});
if (res.ok) {
toast.success(isUpdate ? 'Template updated' : 'Template created');
setEditing(null);
loadTemplates();
}
} catch { toast.error('Failed to save template'); }
finally { setIsSaving(false); }
};
const handleDelete = async (id: number) => {
if (!confirm('Delete this template?')) return;
try {
const res = await fetch(`/api/workflow/templates/${id}`, { method: 'DELETE' });
if (res.ok) { toast.success('Template deleted'); loadTemplates(); }
} catch { toast.error('Failed to delete'); }
};
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/workflow">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<Brain className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">AI Prompt Templates</h1>
<p className="text-sm text-muted-foreground">Configure prompts for AI-assisted triage</p>
</div>
</div>
<Button onClick={handleCreate} size="sm">
<Plus className="w-4 h-4 mr-2" />
New Template
</Button>
</div>
<Card>
<CardContent className="pt-6">
{isLoading ? (
<div className="flex justify-center py-8"><Loader2 className="w-6 h-6 animate-spin" /></div>
) : templates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No templates configured. Default prompts will be used.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Purpose</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Model</TableHead>
<TableHead className="w-20">Active</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templates.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell><Badge variant="outline">{t.purpose}</Badge></TableCell>
<TableCell>{t.provider}</TableCell>
<TableCell className="text-sm text-muted-foreground">{t.model}</TableCell>
<TableCell>
<Badge variant={t.is_active ? 'default' : 'secondary'}>
{t.is_active ? 'Yes' : 'No'}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => setEditing({ ...t })}>
<Pencil className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(t.id)}>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Edit Dialog */}
<Dialog open={!!editing} onOpenChange={(open) => !open && setEditing(null)}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing?.id ? 'Edit' : 'New'} AI Template</DialogTitle>
<DialogDescription>
Templates support {'{{field}}'} interpolation. Available variables: title, description, ticket_category, failed_fields, issueTypes, subIssueTypes, priorities.
</DialogDescription>
</DialogHeader>
{editing && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Name</Label>
<Input value={editing.name || ''} onChange={(e) => setEditing({ ...editing, name: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Purpose</Label>
<Select value={editing.purpose} onValueChange={(v) => setEditing({ ...editing, purpose: v as PromptPurpose })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{PURPOSES.map(p => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Provider</Label>
<Select value={editing.provider || 'openai'} onValueChange={(v) => setEditing({ ...editing, provider: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="anthropic">Anthropic</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Model</Label>
<Input value={editing.model || ''} onChange={(e) => setEditing({ ...editing, model: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Temperature</Label>
<Input type="number" step="0.1" min="0" max="2" value={editing.temperature ?? 0.3} onChange={(e) => setEditing({ ...editing, temperature: parseFloat(e.target.value) })} />
</div>
</div>
<div className="space-y-2">
<Label>System Prompt</Label>
<Textarea
value={editing.system_prompt || ''}
onChange={(e) => setEditing({ ...editing, system_prompt: e.target.value })}
rows={6}
className="font-mono text-sm"
/>
</div>
<div className="space-y-2">
<Label>User Prompt Template</Label>
<Textarea
value={editing.user_prompt_template || ''}
onChange={(e) => setEditing({ ...editing, user_prompt_template: e.target.value })}
rows={8}
className="font-mono text-sm"
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={editing.is_active ?? true}
onCheckedChange={(v) => setEditing({ ...editing, is_active: v })}
/>
<Label>Active</Label>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setEditing(null)}>Cancel</Button>
<Button onClick={handleSave} disabled={isSaving || !editing.name}>
{isSaving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{editing.id ? 'Update' : 'Create'}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}