feat: Autotask webhook integration, TicketNotes, Datto RMM, workflow engine, Veeam agents/alarms, AI triage, misc improvements
This commit is contained in:
parent
347cf4e298
commit
d7c3dc7168
74 changed files with 37844 additions and 322 deletions
|
|
@ -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' },
|
||||
];
|
||||
|
|
|
|||
211
app/admin/data-browser/ticket-notes/page.tsx
Normal file
211
app/admin/data-browser/ticket-notes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
app/admin/sync/addigy/page.tsx
Normal file
103
app/admin/sync/addigy/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
app/admin/sync/autotask/page.tsx
Normal file
89
app/admin/sync/autotask/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
app/admin/sync/auvik/page.tsx
Normal file
103
app/admin/sync/auvik/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
243
app/admin/sync/datto-rmm/page.tsx
Normal file
243
app/admin/sync/datto-rmm/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
465
app/admin/sync/veeam/page.tsx
Normal file
465
app/admin/sync/veeam/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
510
app/admin/workflow/classification/page.tsx
Normal file
510
app/admin/workflow/classification/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
314
app/admin/workflow/history/page.tsx
Normal file
314
app/admin/workflow/history/page.tsx
Normal 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
291
app/admin/workflow/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
365
app/admin/workflow/rules/page.tsx
Normal file
365
app/admin/workflow/rules/page.tsx
Normal 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 "skip" 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>
|
||||
);
|
||||
}
|
||||
274
app/admin/workflow/settings/page.tsx
Normal file
274
app/admin/workflow/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
261
app/admin/workflow/templates/page.tsx
Normal file
261
app/admin/workflow/templates/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue