feat: Autotask webhook integration, TicketNotes, Datto RMM, workflow engine, Veeam agents/alarms, AI triage, misc improvements
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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);
|
||||
|
||||
// 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);
|
||||
interface IntegrationCard {
|
||||
id: string;
|
||||
category: string;
|
||||
product: string;
|
||||
description: string;
|
||||
href: string;
|
||||
logo: string;
|
||||
color: string;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check sync status:', error);
|
||||
}
|
||||
}, 5000); // Refresh every 5 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [isSyncing]);
|
||||
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' },
|
||||
];
|
||||
|
||||
const handleSyncStart = () => {
|
||||
setIsSyncing(true);
|
||||
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' },
|
||||
};
|
||||
|
||||
const handleSyncComplete = () => {
|
||||
setIsSyncing(false);
|
||||
setRefreshKey(prev => prev + 1);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
<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>
|
||||
|
||||
{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`}>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Sync Control Panel */}
|
||||
<SyncControlPanel
|
||||
selectedEntities={selectedEntities}
|
||||
onSelectedEntitiesChange={setSelectedEntities}
|
||||
onSyncStart={handleSyncStart}
|
||||
onSyncComplete={handleSyncComplete}
|
||||
isSyncing={isSyncing}
|
||||
/>
|
||||
{/* Description */}
|
||||
<p className="text-xs text-muted-foreground leading-relaxed mb-4">{intg.description}</p>
|
||||
|
||||
{/* 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>
|
||||
{/* 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
36
app/api/data/lookups/route.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const [statuses, resources, companies, issueTypes, subIssueTypes, queues, configItems, priorities, ticketCategories] = await Promise.all([
|
||||
postgresClient.query(`SELECT value, label FROM statuses WHERE is_deleted = false ORDER BY value`),
|
||||
postgresClient.query(`SELECT id, first_name, last_name FROM resources WHERE is_deleted = false ORDER BY last_name, first_name`),
|
||||
postgresClient.query(`SELECT id, company_name FROM companies WHERE is_deleted = false ORDER BY company_name`),
|
||||
postgresClient.query(`SELECT value, label FROM issue_types WHERE is_deleted = false ORDER BY label`),
|
||||
postgresClient.query(`SELECT value, label, parent_value FROM sub_issue_types WHERE is_deleted = false ORDER BY label`),
|
||||
postgresClient.query(`SELECT value, label FROM queues WHERE is_active = true ORDER BY label`),
|
||||
postgresClient.query(`SELECT id, reference_title FROM configuration_items WHERE is_deleted = false AND reference_title IS NOT NULL ORDER BY reference_title`),
|
||||
postgresClient.query(`SELECT value, label FROM priorities WHERE is_active = true ORDER BY sort_order, value`),
|
||||
postgresClient.query(`SELECT value, label FROM ticket_categories WHERE is_active = true ORDER BY sort_order, value`),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
statuses: statuses.rows,
|
||||
resources: resources.rows.map((r: any) => ({
|
||||
id: r.id,
|
||||
name: [r.first_name, r.last_name].filter(Boolean).join(' ').trim() || `Resource ${r.id}`,
|
||||
})),
|
||||
companies: companies.rows.map((c: any) => ({ id: c.id, name: c.company_name })),
|
||||
issueTypes: issueTypes.rows,
|
||||
subIssueTypes: subIssueTypes.rows,
|
||||
queues: queues.rows,
|
||||
configItems: configItems.rows.map((r: any) => ({ id: r.id, name: r.reference_title })),
|
||||
priorities: priorities.rows,
|
||||
ticketCategories: ticketCategories.rows,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch lookups:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch lookups' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
84
app/api/data/ticket-notes/route.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const limit = parseInt(searchParams.get('limit') || '100');
|
||||
const offset = parseInt(searchParams.get('offset') || '0');
|
||||
const search = searchParams.get('search') || '';
|
||||
const ticketId = searchParams.get('ticket_id');
|
||||
const creatorId = searchParams.get('creator_resource_id');
|
||||
const sortBy = searchParams.get('sort_by') || 'create_date_time';
|
||||
const sortOrder = searchParams.get('sort_order')?.toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
const conditions: string[] = ['tn.is_deleted = false'];
|
||||
const params: any[] = [];
|
||||
let p = 1;
|
||||
|
||||
if (search) {
|
||||
conditions.push(`(tn.title ILIKE $${p} OR tn.description ILIKE $${p})`);
|
||||
params.push(`%${search}%`);
|
||||
p++;
|
||||
}
|
||||
if (ticketId) {
|
||||
conditions.push(`tn.ticket_id = $${p}`);
|
||||
params.push(ticketId);
|
||||
p++;
|
||||
}
|
||||
if (creatorId) {
|
||||
conditions.push(`tn.creator_resource_id = $${p}`);
|
||||
params.push(creatorId);
|
||||
p++;
|
||||
}
|
||||
|
||||
const validSort = ['create_date_time', 'last_activity_date', 'ticket_id', 'creator_resource_id', 'note_type'];
|
||||
const safeSort = validSort.includes(sortBy) ? sortBy : 'create_date_time';
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
tn.id,
|
||||
tn.ticket_id,
|
||||
t.ticket_number,
|
||||
tn.title,
|
||||
tn.description,
|
||||
tn.note_type,
|
||||
tn.publish,
|
||||
tn.creator_resource_id,
|
||||
r.first_name || ' ' || r.last_name AS creator_name,
|
||||
tn.creator_type,
|
||||
tn.create_date_time,
|
||||
tn.last_activity_date,
|
||||
tn.synced_at
|
||||
FROM ticket_notes tn
|
||||
LEFT JOIN tickets t ON tn.ticket_id = t.id
|
||||
LEFT JOIN resources r ON tn.creator_resource_id = r.id
|
||||
${where}
|
||||
ORDER BY tn.${safeSort} ${sortOrder}
|
||||
LIMIT $${p} OFFSET $${p + 1}
|
||||
`;
|
||||
params.push(limit, offset);
|
||||
|
||||
const countQuery = `SELECT COUNT(*) AS total FROM ticket_notes tn ${where}`;
|
||||
|
||||
const [result, countResult] = await Promise.all([
|
||||
postgresClient.query(query, params),
|
||||
postgresClient.query(countQuery, params.slice(0, -2)),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
ticketNotes: result.rows,
|
||||
pagination: {
|
||||
total: parseInt(countResult.rows[0].total),
|
||||
limit,
|
||||
offset,
|
||||
hasMore: offset + limit < parseInt(countResult.rows[0].total),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching ticket notes:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch ticket notes' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
60
app/api/datto-rmm/sync/route.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { DattoRMMSyncService } from '@/lib/services/datto-rmm-sync-service';
|
||||
|
||||
let syncServiceInstance: DattoRMMSyncService | null = null;
|
||||
|
||||
function getSyncService(): DattoRMMSyncService {
|
||||
if (!syncServiceInstance) {
|
||||
syncServiceInstance = new DattoRMMSyncService();
|
||||
}
|
||||
return syncServiceInstance;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const syncService = getSyncService();
|
||||
|
||||
if (syncService.isSyncInProgress()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A Datto RMM sync is already in progress' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const syncType = body.syncType === 'full' ? 'full' : 'incremental';
|
||||
|
||||
const resultPromise = syncType === 'full'
|
||||
? syncService.fullSync('manual')
|
||||
: syncService.incrementalSync('manual');
|
||||
|
||||
resultPromise.catch((err) => {
|
||||
console.error('[DATTO-RMM-SYNC-API] Background sync failed:', err);
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Datto RMM ${syncType} sync started`,
|
||||
syncType,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[DATTO-RMM-SYNC-API] Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to start Datto RMM sync' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const syncService = getSyncService();
|
||||
return NextResponse.json({
|
||||
isSyncing: syncService.isSyncInProgress(),
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get sync status' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
114
app/api/integrations/status/route.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
export async function GET() {
|
||||
const results: Record<string, any> = {};
|
||||
|
||||
// ── Veeam VSPC ──────────────────────────────────────────────────────────────
|
||||
try {
|
||||
const [orgs, agentJobs, backupJobs, workloads] = await Promise.all([
|
||||
postgresClient.query('SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM veeam_organizations'),
|
||||
postgresClient.query(`SELECT COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE status='Success') as success,
|
||||
COUNT(*) FILTER (WHERE status='Running') as running,
|
||||
COUNT(*) FILTER (WHERE status='Failed') as failed,
|
||||
COUNT(*) FILTER (WHERE status='Warning') as warning
|
||||
FROM veeam_backup_agent_jobs WHERE is_enabled = true`),
|
||||
postgresClient.query(`SELECT COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE status='Success') as success,
|
||||
COUNT(*) FILTER (WHERE status='Failed') as failed,
|
||||
COUNT(*) FILTER (WHERE status='Warning') as warning
|
||||
FROM veeam_backup_jobs WHERE is_enabled = true`),
|
||||
postgresClient.query('SELECT COUNT(*) as count FROM veeam_protected_workloads'),
|
||||
]);
|
||||
results.veeam = {
|
||||
configured: !!(process.env.VEEAM_VSPC_URL && process.env.VEEAM_VSPC_API_KEY),
|
||||
organizations: parseInt(orgs.rows[0].count),
|
||||
lastSync: orgs.rows[0].last_sync,
|
||||
agentJobs: {
|
||||
total: parseInt(agentJobs.rows[0].total),
|
||||
success: parseInt(agentJobs.rows[0].success),
|
||||
running: parseInt(agentJobs.rows[0].running),
|
||||
failed: parseInt(agentJobs.rows[0].failed),
|
||||
warning: parseInt(agentJobs.rows[0].warning),
|
||||
},
|
||||
backupJobs: {
|
||||
total: parseInt(backupJobs.rows[0].total),
|
||||
success: parseInt(backupJobs.rows[0].success),
|
||||
failed: parseInt(backupJobs.rows[0].failed),
|
||||
warning: parseInt(backupJobs.rows[0].warning),
|
||||
},
|
||||
protectedWorkloads: parseInt(workloads.rows[0].count),
|
||||
};
|
||||
} catch (e) {
|
||||
results.veeam = { configured: false, error: String(e) };
|
||||
}
|
||||
|
||||
// ── Datto RMM ───────────────────────────────────────────────────────────────
|
||||
try {
|
||||
const [sites, devices, openAlerts, lastSync] = await Promise.all([
|
||||
postgresClient.query(`SELECT COUNT(*) as total,
|
||||
COALESCE(SUM(number_of_devices),0) as total_devices,
|
||||
COALESCE(SUM(number_of_online_devices),0) as online_devices,
|
||||
COALESCE(SUM(number_of_offline_devices),0) as offline_devices
|
||||
FROM datto_rmm_sites`),
|
||||
postgresClient.query(`SELECT COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE online = true) as online,
|
||||
COUNT(*) FILTER (WHERE online = false AND deleted = false) as offline
|
||||
FROM datto_rmm_devices WHERE deleted = false`),
|
||||
postgresClient.query(`SELECT COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE priority = 'Critical') as critical,
|
||||
COUNT(*) FILTER (WHERE priority = 'High') as high,
|
||||
COUNT(*) FILTER (WHERE priority = 'Moderate') as moderate,
|
||||
COUNT(*) FILTER (WHERE priority = 'Low') as low,
|
||||
COUNT(*) FILTER (WHERE ticket_number IS NOT NULL) as with_ticket
|
||||
FROM datto_rmm_alerts WHERE resolved = false`),
|
||||
postgresClient.query(`SELECT MAX(synced_at) as last_sync FROM datto_rmm_sites`),
|
||||
]);
|
||||
results.dattoRmm = {
|
||||
configured: !!(process.env.DATTO_RMM_API_KEY && process.env.DATTO_RMM_API_SECRET),
|
||||
apiUrl: process.env.DATTO_RMM_API_URL || 'https://concord-api.centrastage.net',
|
||||
lastSync: lastSync.rows[0].last_sync,
|
||||
sites: parseInt(sites.rows[0].total),
|
||||
devices: {
|
||||
total: parseInt(devices.rows[0].total),
|
||||
online: parseInt(devices.rows[0].online),
|
||||
offline: parseInt(devices.rows[0].offline),
|
||||
},
|
||||
openAlerts: {
|
||||
total: parseInt(openAlerts.rows[0].total),
|
||||
critical: parseInt(openAlerts.rows[0].critical),
|
||||
high: parseInt(openAlerts.rows[0].high),
|
||||
moderate: parseInt(openAlerts.rows[0].moderate),
|
||||
low: parseInt(openAlerts.rows[0].low),
|
||||
withTicket: parseInt(openAlerts.rows[0].with_ticket),
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
results.dattoRmm = { configured: !!(process.env.DATTO_RMM_API_KEY && process.env.DATTO_RMM_API_SECRET), error: String(e) };
|
||||
}
|
||||
|
||||
// ── Auvik ───────────────────────────────────────────────────────────────────
|
||||
try {
|
||||
results.auvik = {
|
||||
configured: !!(process.env.AUVIK_API_KEY && process.env.AUVIK_API_USER),
|
||||
apiUrl: process.env.AUVIK_API_URL || 'https://auvikapi.us5.my.auvik.com',
|
||||
lastSync: null,
|
||||
};
|
||||
} catch (e) {
|
||||
results.auvik = { configured: false, error: String(e) };
|
||||
}
|
||||
|
||||
// ── Addigy ──────────────────────────────────────────────────────────────────
|
||||
try {
|
||||
results.addigy = {
|
||||
configured: !!(process.env.ADDIGY_API_TOKEN),
|
||||
apiUrl: process.env.ADDIGY_API_URL || 'https://api.addigy.com/api/v2',
|
||||
lastSync: null,
|
||||
};
|
||||
} catch (e) {
|
||||
results.addigy = { configured: false, error: String(e) };
|
||||
}
|
||||
|
||||
return NextResponse.json(results);
|
||||
}
|
||||
51
app/api/veeam/agents/route.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const [agents, summary] = await Promise.all([
|
||||
postgresClient.query(`
|
||||
SELECT a.instance_uid, a.name, a.agent_platform, a.status, a.management_agent_status,
|
||||
a.operation_mode, a.platform, a.version, a.version_status, a.management_mode,
|
||||
a.total_jobs_count, a.running_jobs_count, a.success_jobs_count,
|
||||
a.activation_time, a.synced_at,
|
||||
o.name as organization_name
|
||||
FROM veeam_backup_agents a
|
||||
LEFT JOIN veeam_organizations o ON o.instance_uid = a.organization_uid
|
||||
ORDER BY a.name ASC
|
||||
`),
|
||||
postgresClient.query(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE status = 'Active') as active,
|
||||
COUNT(*) FILTER (WHERE status != 'Active') as inactive,
|
||||
COUNT(*) FILTER (WHERE management_agent_status = 'Inaccessible') as inaccessible,
|
||||
COUNT(*) FILTER (WHERE version_status = 'Outdated') as outdated,
|
||||
COUNT(*) FILTER (WHERE agent_platform = 'Windows') as windows,
|
||||
COUNT(*) FILTER (WHERE agent_platform = 'Linux') as linux,
|
||||
COUNT(*) FILTER (WHERE agent_platform = 'Mac') as mac,
|
||||
MAX(synced_at) as last_sync
|
||||
FROM veeam_backup_agents
|
||||
`),
|
||||
]);
|
||||
|
||||
const s = summary.rows[0];
|
||||
return NextResponse.json({
|
||||
agents: agents.rows,
|
||||
summary: {
|
||||
total: parseInt(s.total),
|
||||
active: parseInt(s.active),
|
||||
inactive: parseInt(s.inactive),
|
||||
inaccessible: parseInt(s.inaccessible),
|
||||
outdated: parseInt(s.outdated),
|
||||
windows: parseInt(s.windows),
|
||||
linux: parseInt(s.linux),
|
||||
mac: parseInt(s.mac),
|
||||
lastSync: s.last_sync,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[VEEAM-AGENTS-API]', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch agents' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
47
app/api/veeam/alarms/route.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const [alarms, summary] = await Promise.all([
|
||||
postgresClient.query(`
|
||||
SELECT a.instance_uid, a.object_type, a.object_name, a.object_computer_name,
|
||||
a.last_activation_status, a.last_activation_time, a.last_activation_message,
|
||||
a.last_activation_remark, a.repeat_count, a.area, a.resolved, a.synced_at,
|
||||
o.name as organization_name
|
||||
FROM veeam_alarms a
|
||||
LEFT JOIN veeam_organizations o ON o.instance_uid = a.organization_uid
|
||||
ORDER BY a.last_activation_time DESC NULLS LAST
|
||||
LIMIT 500
|
||||
`),
|
||||
postgresClient.query(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE resolved = false) as active,
|
||||
COUNT(*) FILTER (WHERE resolved = true) as resolved,
|
||||
COUNT(*) FILTER (WHERE last_activation_status = 'Resolved') as status_resolved,
|
||||
COUNT(*) FILTER (WHERE last_activation_status = 'Active') as status_active,
|
||||
COUNT(*) FILTER (WHERE last_activation_status = 'Warning') as status_warning,
|
||||
MAX(synced_at) as last_sync
|
||||
FROM veeam_alarms
|
||||
`),
|
||||
]);
|
||||
|
||||
const s = summary.rows[0];
|
||||
return NextResponse.json({
|
||||
alarms: alarms.rows,
|
||||
summary: {
|
||||
total: parseInt(s.total),
|
||||
active: parseInt(s.active),
|
||||
resolved: parseInt(s.resolved),
|
||||
statusResolved: parseInt(s.status_resolved),
|
||||
statusActive: parseInt(s.status_active),
|
||||
statusWarning: parseInt(s.status_warning),
|
||||
lastSync: s.last_sync,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[VEEAM-ALARMS-API]', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch alarms' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
/**
|
||||
* Autotask Webhook Receiver Endpoint
|
||||
* Receives and processes real-time webhook events from Autotask
|
||||
*
|
||||
* Actual Autotask payload format:
|
||||
* { Action, Guid, EntityType (singular), Id, Fields, EventTime, SequenceNumber, PersonId }
|
||||
* Signature header: x-hook-signature: sha1=<base64>
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { webhookService } from '@/lib/services/webhook-service';
|
||||
import { AutotaskWebhookPayload } from '@/lib/types/webhook';
|
||||
import { AutotaskRawWebhookPayload, normalizeWebhookPayload } from '@/lib/types/webhook';
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/autotask
|
||||
|
|
@ -13,28 +17,44 @@ import { AutotaskWebhookPayload } from '@/lib/types/webhook';
|
|||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Extract source IP and user agent for logging
|
||||
const sourceIp = request.headers.get('x-forwarded-for')?.split(',')[0].trim()
|
||||
// Extract source IP — Autotask sends from 8.34.161.x via Cloudflare/Pangolin
|
||||
const sourceIp = request.headers.get('cf-connecting-ip')
|
||||
|| request.headers.get('x-forwarded-for')?.split(',')[0].trim()
|
||||
|| request.headers.get('x-real-ip')
|
||||
|| 'unknown';
|
||||
const userAgent = request.headers.get('user-agent') || 'unknown';
|
||||
|
||||
// Parse webhook payload
|
||||
const payload: AutotaskWebhookPayload = await request.json();
|
||||
// Read raw body for signature verification
|
||||
const rawBody = await request.text();
|
||||
|
||||
console.log(`[WEBHOOK API] Received ${payload.eventType} event for ${payload.entityType} #${payload.entityId} from IP: ${sourceIp}`);
|
||||
// Verify webhook signature (x-hook-signature: sha1=<base64>)
|
||||
const signatureHeader = request.headers.get('x-hook-signature');
|
||||
if (!webhookService.verifySignature(rawBody, signatureHeader)) {
|
||||
console.warn(`[WEBHOOK API] Invalid signature from IP: ${sourceIp}`);
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid webhook signature' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse raw Autotask payload
|
||||
const rawPayload: AutotaskRawWebhookPayload = JSON.parse(rawBody);
|
||||
|
||||
// Validate required fields
|
||||
if (!payload.eventId || !payload.eventType || !payload.entityType || !payload.entityId) {
|
||||
if (!rawPayload.Guid || !rawPayload.Action || !rawPayload.EntityType || !rawPayload.Id) {
|
||||
console.warn(`[WEBHOOK API] Invalid payload from IP: ${sourceIp}`, rawBody.substring(0, 200));
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid webhook payload: missing required fields' },
|
||||
{ error: 'Invalid webhook payload: missing required fields (Guid, Action, EntityType, Id)' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Process the webhook asynchronously
|
||||
// Note: We return 200 immediately to Autotask, then process in background
|
||||
// This prevents timeouts for slow processing
|
||||
// Normalize to our internal format
|
||||
const payload = normalizeWebhookPayload(rawPayload);
|
||||
|
||||
console.log(`[WEBHOOK API] Received ${rawPayload.Action} event for ${rawPayload.EntityType} #${rawPayload.Id} from IP: ${sourceIp}`);
|
||||
|
||||
// Process the webhook — return 200 quickly to avoid Autotask timeouts
|
||||
const result = await webhookService.processWebhook(payload, sourceIp, userAgent);
|
||||
|
||||
if (result.success) {
|
||||
|
|
@ -45,8 +65,7 @@ export async function POST(request: NextRequest) {
|
|||
processingTime: result.processingTime,
|
||||
});
|
||||
} else {
|
||||
// Even if processing failed, we return 200 to Autotask
|
||||
// The failure is logged in webhook_logs table
|
||||
// Return 200 even on processing failure to prevent Autotask retries/deactivation
|
||||
console.error(`[WEBHOOK API] Processing failed: ${result.error}`);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
|
|
@ -59,10 +78,10 @@ export async function POST(request: NextRequest) {
|
|||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('[WEBHOOK API] Error processing webhook:', errorMessage);
|
||||
|
||||
// Return 500 for unexpected errors
|
||||
// Return 200 to prevent Autotask from deactivating the webhook on errors
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error', details: errorMessage },
|
||||
{ status: 500 }
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
93
app/api/webhooks/register/route.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* Webhook Registration API
|
||||
* POST /api/webhooks/register — Register all webhooks with Autotask
|
||||
* POST /api/webhooks/register?entity=Tickets — Register a single entity
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { AutotaskWebhookManager } from '@/lib/services/autotask-webhook-manager';
|
||||
import { WebhookEntityType, WEBHOOK_SUPPORTED_ENTITIES } from '@/lib/types/webhook';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const manager = new AutotaskWebhookManager();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const entityParam = searchParams.get('entity');
|
||||
|
||||
if (entityParam) {
|
||||
// Register a single entity type
|
||||
const entityType = entityParam as WebhookEntityType;
|
||||
if (!WEBHOOK_SUPPORTED_ENTITIES.includes(entityType)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unsupported entity type: ${entityParam}. Supported: ${WEBHOOK_SUPPORTED_ENTITIES.join(', ')}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await manager.registerWebhook(entityType);
|
||||
return NextResponse.json({ success: true, results: [result] });
|
||||
}
|
||||
|
||||
// Register all supported entities
|
||||
const results = await manager.registerAll();
|
||||
const allSuccess = results.every(r => r.success);
|
||||
|
||||
return NextResponse.json({
|
||||
success: allSuccess,
|
||||
results,
|
||||
summary: {
|
||||
total: results.length,
|
||||
succeeded: results.filter(r => r.success).length,
|
||||
failed: results.filter(r => !r.success).length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error('[WEBHOOK-REGISTER] Error:', msg);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/webhooks/register — Deregister all webhooks
|
||||
* DELETE /api/webhooks/register?entity=Tickets — Deregister a single entity
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const manager = new AutotaskWebhookManager();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const entityParam = searchParams.get('entity');
|
||||
|
||||
if (entityParam) {
|
||||
const entityType = entityParam as WebhookEntityType;
|
||||
if (!WEBHOOK_SUPPORTED_ENTITIES.includes(entityType)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unsupported entity type: ${entityParam}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find the webhook ID from DB
|
||||
const { postgresClient } = await import('@/lib/services/postgres-client');
|
||||
const result = await postgresClient.query<{ autotask_webhook_id: string }>(
|
||||
`SELECT autotask_webhook_id FROM webhook_configs WHERE entity_type = $1 AND autotask_webhook_id IS NOT NULL`,
|
||||
[entityType]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0 || !result.rows[0].autotask_webhook_id) {
|
||||
return NextResponse.json({ error: `No webhook registered for ${entityType}` }, { status: 404 });
|
||||
}
|
||||
|
||||
await manager.deleteWebhook(entityType, parseInt(result.rows[0].autotask_webhook_id));
|
||||
return NextResponse.json({ success: true, entityType });
|
||||
}
|
||||
|
||||
// Deregister all
|
||||
await manager.deregisterAll();
|
||||
return NextResponse.json({ success: true, message: 'All webhooks deregistered' });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error('[WEBHOOK-DEREGISTER] Error:', msg);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
30
app/api/webhooks/status/route.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Webhook Status API
|
||||
* GET /api/webhooks/status — Show registered webhooks and their Autotask status
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { AutotaskWebhookManager } from '@/lib/services/autotask-webhook-manager';
|
||||
import { webhookService } from '@/lib/services/webhook-service';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const manager = new AutotaskWebhookManager();
|
||||
|
||||
const [registrationStatus, stats] = await Promise.all([
|
||||
manager.getStatus(),
|
||||
webhookService.getWebhookStats(24),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
registrations: registrationStatus,
|
||||
stats,
|
||||
webhookUrl: `${process.env.WEBHOOK_BASE_URL || ''}/api/webhooks/autotask`,
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error('[WEBHOOK-STATUS] Error:', msg);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
108
app/api/workflow/classification-rules/[id]/route.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { ClassificationRuleInput } from '@/lib/types/workflow';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const result = await postgresClient.query(
|
||||
`SELECT * FROM classification_rules WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch classification rule:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch classification rule' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body: Partial<ClassificationRuleInput> = await request.json();
|
||||
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
const fieldMap: Record<string, (v: any) => any> = {
|
||||
name: v => v,
|
||||
description: v => v,
|
||||
rule_type: v => v,
|
||||
sort_order: v => v,
|
||||
is_active: v => v,
|
||||
match_field: v => v,
|
||||
match_operator: v => v,
|
||||
match_value: v => JSON.stringify(v),
|
||||
match_case_sensitive: v => v,
|
||||
result_field: v => v,
|
||||
result_value: v => JSON.stringify(v),
|
||||
result_field_2: v => v,
|
||||
result_value_2: v => v != null ? JSON.stringify(v) : null,
|
||||
confidence: v => v,
|
||||
stop_on_match: v => v,
|
||||
};
|
||||
|
||||
for (const [key, transform] of Object.entries(fieldMap)) {
|
||||
if (key in body) {
|
||||
fields.push(`${key} = $${paramIndex}`);
|
||||
values.push(transform((body as any)[key]));
|
||||
paramIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
|
||||
}
|
||||
|
||||
fields.push(`updated_at = NOW()`);
|
||||
values.push(id);
|
||||
|
||||
const result = await postgresClient.query(
|
||||
`UPDATE classification_rules SET ${fields.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Failed to update classification rule:', error);
|
||||
return NextResponse.json({ error: 'Failed to update classification rule' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const result = await postgresClient.query(
|
||||
`DELETE FROM classification_rules WHERE id = $1 RETURNING id`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete classification rule:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete classification rule' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
62
app/api/workflow/classification-rules/route.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { ClassificationRuleInput } from '@/lib/types/workflow';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const ruleType = searchParams.get('rule_type');
|
||||
|
||||
let query = `SELECT * FROM classification_rules`;
|
||||
const params: any[] = [];
|
||||
|
||||
if (ruleType) {
|
||||
query += ` WHERE rule_type = $1`;
|
||||
params.push(ruleType);
|
||||
}
|
||||
|
||||
query += ` ORDER BY rule_type, sort_order`;
|
||||
|
||||
const result = await postgresClient.query(query, params);
|
||||
return NextResponse.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch classification rules:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch classification rules' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: ClassificationRuleInput = await request.json();
|
||||
|
||||
const result = await postgresClient.query(
|
||||
`INSERT INTO classification_rules
|
||||
(name, description, rule_type, sort_order, is_active, match_field, match_operator, match_value,
|
||||
match_case_sensitive, result_field, result_value, result_field_2, result_value_2, confidence, stop_on_match)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
RETURNING *`,
|
||||
[
|
||||
body.name,
|
||||
body.description || null,
|
||||
body.rule_type,
|
||||
body.sort_order ?? 0,
|
||||
body.is_active ?? true,
|
||||
body.match_field,
|
||||
body.match_operator,
|
||||
JSON.stringify(body.match_value),
|
||||
body.match_case_sensitive ?? false,
|
||||
body.result_field,
|
||||
JSON.stringify(body.result_value),
|
||||
body.result_field_2 || null,
|
||||
body.result_value_2 ? JSON.stringify(body.result_value_2) : null,
|
||||
body.confidence ?? 'high',
|
||||
body.stop_on_match ?? true,
|
||||
]
|
||||
);
|
||||
|
||||
return NextResponse.json(result.rows[0], { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Failed to create classification rule:', error);
|
||||
return NextResponse.json({ error: 'Failed to create classification rule' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
27
app/api/workflow/executions/[id]/route.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const [executionResult, stepsResult] = await Promise.all([
|
||||
postgresClient.query(`SELECT * FROM workflow_executions WHERE id = $1`, [id]),
|
||||
postgresClient.query(`SELECT * FROM workflow_execution_steps WHERE execution_id = $1 ORDER BY step_order`, [id]),
|
||||
]);
|
||||
|
||||
if (executionResult.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...executionResult.rows[0],
|
||||
steps: stepsResult.rows,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch execution:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch execution' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
53
app/api/workflow/executions/route.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
|
||||
const offset = parseInt(searchParams.get('offset') || '0');
|
||||
const status = searchParams.get('status');
|
||||
const method = searchParams.get('method');
|
||||
const branch = searchParams.get('branch');
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (status) {
|
||||
conditions.push(`status = $${paramIndex++}`);
|
||||
params.push(status);
|
||||
}
|
||||
if (method) {
|
||||
conditions.push(`classification_method = $${paramIndex++}`);
|
||||
params.push(method);
|
||||
}
|
||||
if (branch) {
|
||||
conditions.push(`branch = $${paramIndex++}`);
|
||||
params.push(branch);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const [executions, countResult] = await Promise.all([
|
||||
postgresClient.query(
|
||||
`SELECT * FROM workflow_executions ${whereClause} ORDER BY created_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||
[...params, limit, offset]
|
||||
),
|
||||
postgresClient.query(
|
||||
`SELECT COUNT(*) as total FROM workflow_executions ${whereClause}`,
|
||||
params
|
||||
),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
data: executions.rows,
|
||||
total: parseInt(countResult.rows[0].total),
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch executions:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch executions' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
124
app/api/workflow/rules/[id]/route.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { WorkflowRuleInput } from '@/lib/types/workflow';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const ruleResult = await postgresClient.query(
|
||||
`SELECT * FROM workflow_rules WHERE id = $1`, [id]
|
||||
);
|
||||
|
||||
if (ruleResult.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const rule = ruleResult.rows[0];
|
||||
const [conditions, actions] = await Promise.all([
|
||||
postgresClient.query(`SELECT * FROM workflow_conditions WHERE rule_id = $1 ORDER BY condition_group, id`, [id]),
|
||||
postgresClient.query(`SELECT * FROM workflow_actions WHERE rule_id = $1 ORDER BY sort_order`, [id]),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ ...rule, conditions: conditions.rows, actions: actions.rows });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch workflow rule:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch workflow rule' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body: Partial<WorkflowRuleInput> = await request.json();
|
||||
|
||||
// Update rule fields
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
for (const key of ['name', 'description', 'is_active', 'sort_order', 'trigger_event', 'trigger_entity', 'stop_processing']) {
|
||||
if (key in body) {
|
||||
fields.push(`${key} = $${paramIndex}`);
|
||||
values.push((body as any)[key]);
|
||||
paramIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length > 0) {
|
||||
fields.push(`updated_at = NOW()`);
|
||||
values.push(id);
|
||||
await postgresClient.query(
|
||||
`UPDATE workflow_rules SET ${fields.join(', ')} WHERE id = $${paramIndex}`,
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
// Replace conditions if provided
|
||||
if (body.conditions) {
|
||||
await postgresClient.query(`DELETE FROM workflow_conditions WHERE rule_id = $1`, [id]);
|
||||
for (const cond of body.conditions) {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO workflow_conditions (rule_id, condition_group, field, operator, value)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[id, cond.condition_group ?? 0, cond.field, cond.operator, JSON.stringify(cond.value)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Replace actions if provided
|
||||
if (body.actions) {
|
||||
await postgresClient.query(`DELETE FROM workflow_actions WHERE rule_id = $1`, [id]);
|
||||
for (const action of body.actions) {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO workflow_actions (rule_id, sort_order, action_type, config)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[id, action.sort_order ?? 0, action.action_type, JSON.stringify(action.config ?? {})]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Return updated rule
|
||||
const ruleResult = await postgresClient.query(`SELECT * FROM workflow_rules WHERE id = $1`, [id]);
|
||||
if (ruleResult.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const [conditions, actions] = await Promise.all([
|
||||
postgresClient.query(`SELECT * FROM workflow_conditions WHERE rule_id = $1`, [id]),
|
||||
postgresClient.query(`SELECT * FROM workflow_actions WHERE rule_id = $1 ORDER BY sort_order`, [id]),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ ...ruleResult.rows[0], conditions: conditions.rows, actions: actions.rows });
|
||||
} catch (error) {
|
||||
console.error('Failed to update workflow rule:', error);
|
||||
return NextResponse.json({ error: 'Failed to update workflow rule' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
// CASCADE will delete conditions and actions
|
||||
const result = await postgresClient.query(
|
||||
`DELETE FROM workflow_rules WHERE id = $1 RETURNING id`, [id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete workflow rule:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete workflow rule' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
86
app/api/workflow/rules/route.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { WorkflowRuleInput } from '@/lib/types/workflow';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const rules = await postgresClient.query(
|
||||
`SELECT * FROM workflow_rules ORDER BY sort_order`
|
||||
);
|
||||
|
||||
// Load conditions and actions for each rule
|
||||
const rulesWithDetails = await Promise.all(
|
||||
rules.rows.map(async (rule: any) => {
|
||||
const [conditions, actions] = await Promise.all([
|
||||
postgresClient.query(`SELECT * FROM workflow_conditions WHERE rule_id = $1 ORDER BY condition_group, id`, [rule.id]),
|
||||
postgresClient.query(`SELECT * FROM workflow_actions WHERE rule_id = $1 ORDER BY sort_order`, [rule.id]),
|
||||
]);
|
||||
return { ...rule, conditions: conditions.rows, actions: actions.rows };
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json(rulesWithDetails);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch workflow rules:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch workflow rules' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: WorkflowRuleInput = await request.json();
|
||||
|
||||
// Insert rule
|
||||
const ruleResult = await postgresClient.query(
|
||||
`INSERT INTO workflow_rules (name, description, is_active, sort_order, trigger_event, trigger_entity, stop_processing)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
[
|
||||
body.name,
|
||||
body.description || null,
|
||||
body.is_active ?? true,
|
||||
body.sort_order ?? 0,
|
||||
body.trigger_event,
|
||||
body.trigger_entity ?? 'ticket',
|
||||
body.stop_processing ?? false,
|
||||
]
|
||||
);
|
||||
|
||||
const rule = ruleResult.rows[0];
|
||||
|
||||
// Insert conditions
|
||||
if (body.conditions?.length) {
|
||||
for (const cond of body.conditions) {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO workflow_conditions (rule_id, condition_group, field, operator, value)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[rule.id, cond.condition_group ?? 0, cond.field, cond.operator, JSON.stringify(cond.value)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert actions
|
||||
if (body.actions?.length) {
|
||||
for (const action of body.actions) {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO workflow_actions (rule_id, sort_order, action_type, config)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[rule.id, action.sort_order ?? 0, action.action_type, JSON.stringify(action.config ?? {})]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Return full rule with conditions and actions
|
||||
const [conditions, actions] = await Promise.all([
|
||||
postgresClient.query(`SELECT * FROM workflow_conditions WHERE rule_id = $1`, [rule.id]),
|
||||
postgresClient.query(`SELECT * FROM workflow_actions WHERE rule_id = $1 ORDER BY sort_order`, [rule.id]),
|
||||
]);
|
||||
|
||||
return NextResponse.json(
|
||||
{ ...rule, conditions: conditions.rows, actions: actions.rows },
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to create workflow rule:', error);
|
||||
return NextResponse.json({ error: 'Failed to create workflow rule' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
56
app/api/workflow/settings/route.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const result = await postgresClient.query(
|
||||
`SELECT key, value, description FROM workflow_settings ORDER BY key`
|
||||
);
|
||||
|
||||
const settings: Record<string, any> = {};
|
||||
for (const row of result.rows) {
|
||||
try {
|
||||
settings[row.key] = { value: JSON.parse(row.value), description: row.description };
|
||||
} catch {
|
||||
settings[row.key] = { value: row.value, description: row.description };
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(settings);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch settings' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body: Record<string, any> = await request.json();
|
||||
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
await postgresClient.query(
|
||||
`UPDATE workflow_settings SET value = $1, updated_at = NOW() WHERE key = $2`,
|
||||
[JSON.stringify(value), key]
|
||||
);
|
||||
}
|
||||
|
||||
// Return updated settings
|
||||
const result = await postgresClient.query(
|
||||
`SELECT key, value, description FROM workflow_settings ORDER BY key`
|
||||
);
|
||||
|
||||
const settings: Record<string, any> = {};
|
||||
for (const row of result.rows) {
|
||||
try {
|
||||
settings[row.key] = { value: JSON.parse(row.value), description: row.description };
|
||||
} catch {
|
||||
settings[row.key] = { value: row.value, description: row.description };
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(settings);
|
||||
} catch (error) {
|
||||
console.error('Failed to update settings:', error);
|
||||
return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
87
app/api/workflow/templates/[id]/route.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const result = await postgresClient.query(
|
||||
`SELECT * FROM ai_prompt_templates WHERE id = $1`, [id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch template:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch template' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
for (const key of ['name', 'purpose', 'system_prompt', 'user_prompt_template', 'provider', 'model', 'temperature', 'max_tokens', 'is_active']) {
|
||||
if (key in body) {
|
||||
fields.push(`${key} = $${paramIndex}`);
|
||||
values.push(body[key]);
|
||||
paramIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
|
||||
}
|
||||
|
||||
fields.push(`updated_at = NOW()`);
|
||||
values.push(id);
|
||||
|
||||
const result = await postgresClient.query(
|
||||
`UPDATE ai_prompt_templates SET ${fields.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Failed to update template:', error);
|
||||
return NextResponse.json({ error: 'Failed to update template' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const result = await postgresClient.query(
|
||||
`DELETE FROM ai_prompt_templates WHERE id = $1 RETURNING id`, [id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete template:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete template' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
43
app/api/workflow/templates/route.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { AiPromptTemplateInput } from '@/lib/types/workflow';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const result = await postgresClient.query(
|
||||
`SELECT * FROM ai_prompt_templates ORDER BY purpose, version DESC`
|
||||
);
|
||||
return NextResponse.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch templates:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch templates' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: AiPromptTemplateInput = await request.json();
|
||||
|
||||
const result = await postgresClient.query(
|
||||
`INSERT INTO ai_prompt_templates
|
||||
(name, purpose, system_prompt, user_prompt_template, provider, model, temperature, max_tokens, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
|
||||
[
|
||||
body.name,
|
||||
body.purpose,
|
||||
body.system_prompt,
|
||||
body.user_prompt_template,
|
||||
body.provider ?? 'openai',
|
||||
body.model ?? 'gpt-4o',
|
||||
body.temperature ?? 0.3,
|
||||
body.max_tokens ?? 4000,
|
||||
body.is_active ?? true,
|
||||
]
|
||||
);
|
||||
|
||||
return NextResponse.json(result.rows[0], { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Failed to create template:', error);
|
||||
return NextResponse.json({ error: 'Failed to create template' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
19
app/api/workflow/test/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { workflowEngine } from '@/lib/services/workflow-engine';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const ticketId = body.ticket_id;
|
||||
|
||||
if (!ticketId) {
|
||||
return NextResponse.json({ error: 'ticket_id is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await workflowEngine.dryRun(ticketId);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to run workflow test:', error);
|
||||
return NextResponse.json({ error: 'Failed to run workflow test' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -4,81 +4,106 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
|
|||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Calendar, Check, X, Copy, CheckCircle2, Code2, LayoutTemplate, ExternalLink, Phone, Globe, MapPin, Mail } from 'lucide-react';
|
||||
import { Calendar, Check, X, Copy, CheckCircle2, Code2, LayoutTemplate, ExternalLink, Phone, Globe, Loader2, User, Building2, MessageSquare, Clock } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// ── Autotask label maps ────────────────────────────────────────────────────────
|
||||
// ── Static picklist maps (Autotask standard values from DB) ──────────────────
|
||||
|
||||
const TICKET_STATUS: Record<number, string> = {
|
||||
1: 'New', 5: 'Complete', 8: 'In Progress', 9: 'Waiting Customer',
|
||||
10: 'Waiting Materials', 11: 'Waiting Vendor', 12: 'Waiting Parts',
|
||||
13: 'Scheduled', 14: 'Escalated', 16: 'Waiting on 3rd Party',
|
||||
29: 'Customer Responded', 30: 'Dispatched', 31: 'Resolved',
|
||||
const PRIORITY_MAP: Record<number, { label: string; cls: string }> = {
|
||||
2: { label: 'Critical', cls: 'bg-red-500/15 text-red-600 border border-red-500/30' },
|
||||
3: { label: 'High', cls: 'bg-orange-500/15 text-orange-600 border border-orange-500/30' },
|
||||
4: { label: 'Medium', cls: 'bg-yellow-500/15 text-yellow-700 border border-yellow-500/30' },
|
||||
6: { label: 'Low', cls: 'bg-blue-500/15 text-blue-600 border border-blue-500/30' },
|
||||
7: { label: 'Very Low', cls: 'bg-slate-500/15 text-slate-500 border border-slate-500/30' },
|
||||
8: { label: 'Critical', cls: 'bg-red-500/15 text-red-600 border border-red-500/30' },
|
||||
9: { label: 'High', cls: 'bg-orange-500/15 text-orange-600 border border-orange-500/30' },
|
||||
10: { label: 'Medium', cls: 'bg-yellow-500/15 text-yellow-700 border border-yellow-500/30' },
|
||||
11: { label: 'Low', cls: 'bg-blue-500/15 text-blue-600 border border-blue-500/30' },
|
||||
};
|
||||
|
||||
const TICKET_PRIORITY: Record<number, { label: string; variant: 'destructive' | 'default' | 'secondary' | 'outline' }> = {
|
||||
1: { label: 'Critical', variant: 'destructive' },
|
||||
2: { label: 'High', variant: 'default' },
|
||||
3: { label: 'Medium', variant: 'secondary' },
|
||||
4: { label: 'Low', variant: 'outline' },
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
'New': 'bg-blue-500/15 text-blue-600 border border-blue-500/30',
|
||||
'In Progress': 'bg-indigo-500/15 text-indigo-600 border border-indigo-500/30',
|
||||
'Complete': 'bg-green-500/15 text-green-600 border border-green-500/30',
|
||||
'Waiting Customer': 'bg-amber-500/15 text-amber-700 border border-amber-500/30',
|
||||
'Waiting Materials': 'bg-orange-500/15 text-orange-600 border border-orange-500/30',
|
||||
'Waiting Vendor': 'bg-orange-500/15 text-orange-600 border border-orange-500/30',
|
||||
'Waiting Approval': 'bg-purple-500/15 text-purple-600 border border-purple-500/30',
|
||||
'On Hold': 'bg-slate-500/15 text-slate-500 border border-slate-500/30',
|
||||
'Escalate': 'bg-red-500/15 text-red-600 border border-red-500/30',
|
||||
'Escalate to Wulf': 'bg-red-500/15 text-red-600 border border-red-500/30',
|
||||
'Escalate to MC': 'bg-red-500/15 text-red-600 border border-red-500/30',
|
||||
'Resource Assigned': 'bg-cyan-500/15 text-cyan-600 border border-cyan-500/30',
|
||||
'Service Call Scheduled': 'bg-teal-500/15 text-teal-600 border border-teal-500/30',
|
||||
'Dispatched': 'bg-teal-500/15 text-teal-600 border border-teal-500/30',
|
||||
'Resolved <CSAT Survey>': 'bg-green-500/15 text-green-600 border border-green-500/30',
|
||||
};
|
||||
|
||||
const TICKET_SOURCE: Record<number, string> = {
|
||||
1: 'Phone', 2: 'Email', 5: 'Web Portal', 6: 'Monitoring Alert',
|
||||
8: 'RMM Alert', 9: 'Chat', 10: 'In Person', 14: 'API',
|
||||
const SOURCE_MAP: Record<number, string> = {
|
||||
[-2]: 'System', [-1]: 'Internal',
|
||||
1: 'Phone', 2: 'Email', 4: 'Web Portal', 6: 'Monitoring Alert',
|
||||
8: 'RMM Alert', 17: 'Chat', 21: 'API', 22: 'Automation',
|
||||
27: 'In Person', 29: 'Client Portal', 30: 'Microsoft Teams',
|
||||
31: 'Webhook', 33: 'Datto RMM', 34: 'Rewst', 35: 'TimeZest',
|
||||
36: 'DeskDirector', 38: 'Huntress', 39: 'Blumira', 40: 'SentinelOne',
|
||||
};
|
||||
|
||||
const QUEUE: Record<number, string> = {
|
||||
29482833: 'Client Services', 29482834: 'Network Operations',
|
||||
29482835: 'Help Desk', 29482836: 'Projects',
|
||||
const COMPANY_TYPE_MAP: Record<number, { label: string; cls: string }> = {
|
||||
1: { label: 'Customer', cls: 'bg-green-500/15 text-green-600 border border-green-500/30' },
|
||||
2: { label: 'Lead', cls: 'bg-blue-500/15 text-blue-600 border border-blue-500/30' },
|
||||
3: { label: 'Prospect', cls: 'bg-purple-500/15 text-purple-600 border border-purple-500/30' },
|
||||
4: { label: 'Dead', cls: 'bg-slate-500/15 text-slate-500 border border-slate-500/30' },
|
||||
6: { label: 'Cancelation', cls: 'bg-red-500/15 text-red-600 border border-red-500/30' },
|
||||
7: { label: 'Vendor', cls: 'bg-orange-500/15 text-orange-600 border border-orange-500/30' },
|
||||
8: { label: 'Partner', cls: 'bg-cyan-500/15 text-cyan-600 border border-cyan-500/30' },
|
||||
};
|
||||
|
||||
const COMPANY_TYPE: Record<number, string> = {
|
||||
1: 'Customer', 2: 'Lead', 3: 'Prospect', 4: 'Dead', 6: 'Cancelation',
|
||||
7: 'Vendor', 8: 'Partner',
|
||||
};
|
||||
// ── Live lookup types (fetched from DB) ───────────────────────────────────────
|
||||
|
||||
interface Lookups {
|
||||
statuses: Record<number, string>;
|
||||
resources: Record<number, string>;
|
||||
companies: Record<number, string>;
|
||||
issueTypes: Record<number, string>;
|
||||
subIssueTypes: Record<number, string>;
|
||||
queues: Record<number, string>;
|
||||
configItems: Record<number, string>;
|
||||
}
|
||||
|
||||
// ── Field metadata for formatted view ─────────────────────────────────────────
|
||||
|
||||
type FieldType = 'date' | 'bool' | 'status' | 'priority' | 'source' | 'queue' | 'company_type' | 'url' | 'phone' | 'hours' | 'id' | 'resource' | 'company' | 'issue_type' | 'sub_issue_type' | 'config_item';
|
||||
|
||||
type FieldGroup = {
|
||||
label: string;
|
||||
fields: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
type?: 'date' | 'bool' | 'status' | 'priority' | 'source' | 'queue' | 'company_type' | 'url' | 'phone' | 'email' | 'text' | 'hours' | 'id';
|
||||
}>;
|
||||
fields: Array<{ key: string; label: string; type?: FieldType }>;
|
||||
paired?: string;
|
||||
};
|
||||
|
||||
const TICKET_GROUPS: FieldGroup[] = [
|
||||
{
|
||||
label: 'Overview',
|
||||
label: 'Parties',
|
||||
fields: [
|
||||
{ key: 'company_id', label: 'Company', type: 'company' },
|
||||
{ key: 'contact_id', label: 'Contact ID', type: 'id' },
|
||||
{ key: 'assigned_resource_id', label: 'Assigned Resource', type: 'resource' },
|
||||
{ key: 'configuration_item_id', label: 'Configuration Item', type: 'config_item' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Details',
|
||||
fields: [
|
||||
{ key: 'ticket_number', label: 'Ticket #' },
|
||||
{ key: 'title', label: 'Title' },
|
||||
{ key: 'status', label: 'Status', type: 'status' },
|
||||
{ key: 'priority', label: 'Priority', type: 'priority' },
|
||||
{ key: 'source', label: 'Source', type: 'source' },
|
||||
{ key: 'queue_id', label: 'Queue', type: 'queue' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Parties',
|
||||
fields: [
|
||||
{ key: 'company_id', label: 'Company ID', type: 'id' },
|
||||
{ key: 'contact_id', label: 'Contact ID', type: 'id' },
|
||||
{ key: 'assigned_resource_id', label: 'Assigned Resource ID', type: 'id' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Classification',
|
||||
fields: [
|
||||
{ key: 'issue_type', label: 'Issue Type' },
|
||||
{ key: 'sub_issue_type', label: 'Sub-Issue Type' },
|
||||
{ key: 'issue_type', label: 'Issue Type', type: 'issue_type' },
|
||||
{ key: 'sub_issue_type', label: 'Sub-Issue Type', type: 'sub_issue_type' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Dates & Time',
|
||||
paired: 'System',
|
||||
fields: [
|
||||
{ key: 'create_date', label: 'Created', type: 'date' },
|
||||
{ key: 'due_date_time', label: 'Due', type: 'date' },
|
||||
|
|
@ -89,6 +114,7 @@ const TICKET_GROUPS: FieldGroup[] = [
|
|||
},
|
||||
{
|
||||
label: 'System',
|
||||
paired: 'Dates & Time',
|
||||
fields: [
|
||||
{ key: 'id', label: 'Record ID', type: 'id' },
|
||||
{ key: 'synced_at', label: 'Synced At', type: 'date' },
|
||||
|
|
@ -130,6 +156,7 @@ const COMPANY_GROUPS: FieldGroup[] = [
|
|||
},
|
||||
{
|
||||
label: 'System',
|
||||
paired: 'Address',
|
||||
fields: [
|
||||
{ key: 'id', label: 'Record ID', type: 'id' },
|
||||
{ key: 'synced_at', label: 'Synced At', type: 'date' },
|
||||
|
|
@ -140,23 +167,24 @@ const COMPANY_GROUPS: FieldGroup[] = [
|
|||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function resolveLabel(key: string, value: any, type?: string): { display: React.ReactNode; isEmpty: boolean } {
|
||||
function ColorBadge({ cls, children }: { cls: string; children: React.ReactNode }) {
|
||||
return <span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${cls}`}>{children}</span>;
|
||||
}
|
||||
|
||||
function resolveLabel(key: string, value: any, type: FieldType | undefined, lookups: Lookups): { display: React.ReactNode; isEmpty: boolean } {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return { display: <span className="text-muted-foreground/50 italic text-xs">—</span>, isEmpty: true };
|
||||
return { display: <span className="text-muted-foreground/40 italic text-xs">—</span>, isEmpty: true };
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'bool':
|
||||
return {
|
||||
display: (
|
||||
<Badge variant={value ? 'default' : 'secondary'} className="gap-1">
|
||||
{value ? <Check className="w-3 h-3" /> : <X className="w-3 h-3" />}
|
||||
{value ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
),
|
||||
display: value
|
||||
? <ColorBadge cls="bg-green-500/15 text-green-600 border border-green-500/30"><Check className="w-3 h-3 mr-1" />Yes</ColorBadge>
|
||||
: <ColorBadge cls="bg-slate-500/15 text-slate-500 border border-slate-500/30"><X className="w-3 h-3 mr-1" />No</ColorBadge>,
|
||||
isEmpty: false,
|
||||
};
|
||||
case 'date':
|
||||
case 'date': {
|
||||
try {
|
||||
const d = new Date(value);
|
||||
return {
|
||||
|
|
@ -164,39 +192,74 @@ function resolveLabel(key: string, value: any, type?: string): { display: React.
|
|||
<span className="inline-flex items-center gap-1.5 text-sm">
|
||||
<Calendar className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
<span className="text-muted-foreground text-xs">{d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</span>
|
||||
),
|
||||
isEmpty: false,
|
||||
};
|
||||
} catch { break; }
|
||||
}
|
||||
case 'status': {
|
||||
const label = TICKET_STATUS[Number(value)] ?? `Status ${value}`;
|
||||
return { display: <Badge variant="outline">{label}</Badge>, isEmpty: false };
|
||||
const label = lookups.statuses[Number(value)] ?? `Status ${value}`;
|
||||
const cls = STATUS_COLOR[label] ?? 'bg-muted text-muted-foreground border border-border';
|
||||
return { display: <ColorBadge cls={cls}>{label}</ColorBadge>, isEmpty: false };
|
||||
}
|
||||
case 'priority': {
|
||||
const p = TICKET_PRIORITY[Number(value)];
|
||||
return { display: <Badge variant={p?.variant ?? 'secondary'}>{p?.label ?? `Priority ${value}`}</Badge>, isEmpty: false };
|
||||
const p = PRIORITY_MAP[Number(value)];
|
||||
return { display: <ColorBadge cls={p?.cls ?? 'bg-muted text-muted-foreground border border-border'}>{p?.label ?? `Priority ${value}`}</ColorBadge>, isEmpty: false };
|
||||
}
|
||||
case 'source': {
|
||||
const label = TICKET_SOURCE[Number(value)] ?? `Source ${value}`;
|
||||
return { display: <Badge variant="secondary">{label}</Badge>, isEmpty: false };
|
||||
const label = SOURCE_MAP[Number(value)] ?? `Source ${value}`;
|
||||
return { display: <ColorBadge cls="bg-violet-500/15 text-violet-600 border border-violet-500/30">{label}</ColorBadge>, isEmpty: false };
|
||||
}
|
||||
case 'queue': {
|
||||
const label = QUEUE[Number(value)] ?? `Queue ${value}`;
|
||||
return { display: <span className="text-sm font-medium">{label}</span>, isEmpty: false };
|
||||
const qLabel = lookups.queues[Number(value)] ?? `Queue ${value}`;
|
||||
return { display: <ColorBadge cls="bg-indigo-500/15 text-indigo-600 border border-indigo-500/30">{qLabel}</ColorBadge>, isEmpty: false };
|
||||
}
|
||||
case 'company_type': {
|
||||
const label = COMPANY_TYPE[Number(value)] ?? `Type ${value}`;
|
||||
return { display: <Badge variant="outline">{label}</Badge>, isEmpty: false };
|
||||
const ct = COMPANY_TYPE_MAP[Number(value)];
|
||||
return { display: <ColorBadge cls={ct?.cls ?? 'bg-muted text-muted-foreground border border-border'}>{ct?.label ?? `Type ${value}`}</ColorBadge>, isEmpty: false };
|
||||
}
|
||||
case 'resource': {
|
||||
const name = lookups.resources[Number(value)];
|
||||
return {
|
||||
display: name
|
||||
? <span className="inline-flex items-center gap-1.5 text-sm"><User className="w-3.5 h-3.5 text-muted-foreground" />{name}</span>
|
||||
: <span className="font-mono text-xs bg-muted px-2 py-0.5 rounded">{value}</span>,
|
||||
isEmpty: false,
|
||||
};
|
||||
}
|
||||
case 'company': {
|
||||
const name = lookups.companies[Number(value)];
|
||||
return {
|
||||
display: name
|
||||
? <span className="inline-flex items-center gap-1.5 text-sm"><Building2 className="w-3.5 h-3.5 text-muted-foreground" />{name}</span>
|
||||
: <span className="font-mono text-xs bg-muted px-2 py-0.5 rounded">{value}</span>,
|
||||
isEmpty: false,
|
||||
};
|
||||
}
|
||||
case 'issue_type': {
|
||||
const label = lookups.issueTypes[Number(value)] ?? `Issue ${value}`;
|
||||
return { display: <ColorBadge cls="bg-sky-500/15 text-sky-600 border border-sky-500/30">{label}</ColorBadge>, isEmpty: false };
|
||||
}
|
||||
case 'sub_issue_type': {
|
||||
const label = lookups.subIssueTypes[Number(value)] ?? `Sub-Issue ${value}`;
|
||||
return { display: <ColorBadge cls="bg-sky-500/10 text-sky-500 border border-sky-500/20">{label}</ColorBadge>, isEmpty: false };
|
||||
}
|
||||
case 'config_item': {
|
||||
const name = lookups.configItems[Number(value)];
|
||||
return {
|
||||
display: name
|
||||
? <span className="text-sm">{name}</span>
|
||||
: <span className="font-mono text-xs bg-muted px-2 py-0.5 rounded">{value}</span>,
|
||||
isEmpty: false,
|
||||
};
|
||||
}
|
||||
case 'url':
|
||||
return {
|
||||
display: (
|
||||
<a href={String(value).startsWith('http') ? value : `https://${value}`} target="_blank" rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-blue-500 hover:underline">
|
||||
<Globe className="w-3.5 h-3.5" />{value}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
<Globe className="w-3.5 h-3.5" />{value}<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
),
|
||||
isEmpty: false,
|
||||
|
|
@ -217,7 +280,7 @@ function resolveLabel(key: string, value: any, type?: string): { display: React.
|
|||
}
|
||||
|
||||
if (typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}/)) {
|
||||
return resolveLabel(key, value, 'date');
|
||||
return resolveLabel(key, value, 'date', lookups);
|
||||
}
|
||||
|
||||
return { display: <span className="text-sm">{String(value)}</span>, isEmpty: false };
|
||||
|
|
@ -229,6 +292,8 @@ function detectGroups(data: Record<string, any>): FieldGroup[] {
|
|||
return [{ label: 'Fields', fields: Object.keys(data).map(k => ({ key: k, label: k })) }];
|
||||
}
|
||||
|
||||
const EMPTY_LOOKUPS: Lookups = { statuses: {}, resources: {}, companies: {}, issueTypes: {}, subIssueTypes: {}, queues: {}, configItems: {} };
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DetailModalProps {
|
||||
|
|
@ -241,6 +306,51 @@ interface DetailModalProps {
|
|||
|
||||
export default function DetailModal({ open, onOpenChange, title, data, fields }: DetailModalProps) {
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||
const [lookups, setLookups] = useState<Lookups>(EMPTY_LOOKUPS);
|
||||
const [lookupsLoading, setLookupsLoading] = useState(false);
|
||||
const [notes, setNotes] = useState<any[]>([]);
|
||||
const [notesLoading, setNotesLoading] = useState(false);
|
||||
const [timeEntries, setTimeEntries] = useState<any[]>([]);
|
||||
const [timeEntriesLoading, setTimeEntriesLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLookupsLoading(true);
|
||||
fetch('/api/data/lookups')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
setLookups({
|
||||
statuses: Object.fromEntries((d.statuses ?? []).map((r: any) => [r.value, r.label])),
|
||||
resources: Object.fromEntries((d.resources ?? []).map((r: any) => [r.id, r.name])),
|
||||
companies: Object.fromEntries((d.companies ?? []).map((r: any) => [r.id, r.name])),
|
||||
issueTypes: Object.fromEntries((d.issueTypes ?? []).map((r: any) => [r.value, r.label])),
|
||||
subIssueTypes:Object.fromEntries((d.subIssueTypes?? []).map((r: any) => [r.value, r.label])),
|
||||
queues: Object.fromEntries((d.queues ?? []).map((r: any) => [r.value, r.label])),
|
||||
configItems: Object.fromEntries((d.configItems ?? []).map((r: any) => [r.id, r.name])),
|
||||
});
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLookupsLoading(false));
|
||||
|
||||
if (data && 'ticket_number' in data && data.id) {
|
||||
setNotesLoading(true);
|
||||
fetch(`/api/data/ticket-notes?ticket_id=${data.id}&sort_by=create_date_time&sort_order=asc&limit=200`)
|
||||
.then(r => r.json())
|
||||
.then(d => setNotes(d.ticketNotes ?? []))
|
||||
.catch(() => setNotes([]))
|
||||
.finally(() => setNotesLoading(false));
|
||||
|
||||
setTimeEntriesLoading(true);
|
||||
fetch(`/api/data/time-entries?ticket_id=${data.id}&sort_by=entry_date&sort_order=asc&limit=200`)
|
||||
.then(r => r.json())
|
||||
.then(d => setTimeEntries(d.timeEntries ?? []))
|
||||
.catch(() => setTimeEntries([]))
|
||||
.finally(() => setTimeEntriesLoading(false));
|
||||
} else {
|
||||
setNotes([]);
|
||||
setTimeEntries([]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
|
|
@ -264,10 +374,27 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-7xl max-h-[90vh] overflow-hidden flex flex-col gap-0 p-0">
|
||||
<DialogContent className="max-w-7xl max-h-[90vh] overflow-hidden flex flex-col gap-0 p-0 border-2 border-blue-500/70 shadow-[0_0_0_1px_rgba(59,130,246,0.15),0_20px_60px_-10px_rgba(59,130,246,0.25)]">
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-6 pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="px-6 pt-5 pb-4 border-b">
|
||||
{'ticket_number' in data ? (
|
||||
<div className="flex items-start justify-between gap-6 pr-8">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
<span className="font-mono text-sm font-semibold text-muted-foreground shrink-0">{data.ticket_number}</span>
|
||||
<DialogTitle className="text-xl font-bold leading-tight" style={{overflowWrap:'anywhere', wordBreak:'break-word'}}>{data.title}</DialogTitle>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex flex-col items-end gap-1">
|
||||
{(() => {
|
||||
const label = lookups.statuses[Number(data.status)] ?? `Status ${data.status}`;
|
||||
const cls = STATUS_COLOR[label] ?? 'bg-muted text-muted-foreground border border-border';
|
||||
return <ColorBadge cls={cls}>{label}</ColorBadge>;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start justify-between gap-4 pr-8">
|
||||
<div>
|
||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
|
|
@ -275,16 +402,12 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
|
|||
</DialogDescription>
|
||||
</div>
|
||||
{'is_active' in data && (
|
||||
<Badge variant={data.is_active ? 'default' : 'secondary'} className="shrink-0 mt-1">
|
||||
<ColorBadge cls={data.is_active ? 'bg-green-500/15 text-green-600 border border-green-500/30' : 'bg-slate-500/15 text-slate-500 border border-slate-500/30'}>
|
||||
{data.is_active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
)}
|
||||
{'status' in data && (
|
||||
<Badge variant="outline" className="shrink-0 mt-1">
|
||||
{TICKET_STATUS[Number(data.status)] ?? `Status ${data.status}`}
|
||||
</Badge>
|
||||
</ColorBadge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
|
|
@ -295,6 +418,26 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
|
|||
<LayoutTemplate className="w-3.5 h-3.5" />
|
||||
Formatted
|
||||
</TabsTrigger>
|
||||
{'ticket_number' in data && (
|
||||
<TabsTrigger value="time" className="gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
Time
|
||||
{timeEntries.length > 0 && (
|
||||
<span className="ml-1 rounded-full bg-blue-500/20 text-blue-600 text-xs px-1.5 py-0.5 font-medium">
|
||||
{timeEntries.reduce((s, e) => s + (parseFloat(e.hours_worked) || 0), 0).toFixed(1)}h
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{'ticket_number' in data && (
|
||||
<TabsTrigger value="notes" className="gap-1.5">
|
||||
<MessageSquare className="w-3.5 h-3.5" />
|
||||
Notes
|
||||
{notes.length > 0 && (
|
||||
<span className="ml-1 rounded-full bg-blue-500/20 text-blue-600 text-xs px-1.5 py-0.5 font-medium">{notes.length}</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="raw" className="gap-1.5">
|
||||
<Code2 className="w-3.5 h-3.5" />
|
||||
Raw
|
||||
|
|
@ -305,16 +448,100 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
|
|||
{/* ── Formatted Tab ── */}
|
||||
<TabsContent value="formatted" className="flex-1 overflow-y-auto px-6 py-4 mt-0">
|
||||
<div className="space-y-6">
|
||||
{groups.map((group) => {
|
||||
{(() => {
|
||||
const rendered = new Set<string>();
|
||||
return groups.map((group) => {
|
||||
if (rendered.has(group.label)) return null;
|
||||
const visibleFields = group.fields.filter(f => f.key in data);
|
||||
if (visibleFields.length === 0) return null;
|
||||
|
||||
// Inline badge row for Details group
|
||||
if (group.label === 'Details') {
|
||||
return (
|
||||
<div key="Details">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">Details</h3>
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-3 px-4 py-3">
|
||||
{visibleFields.map((field) => {
|
||||
const value = data[field.key];
|
||||
const { display, isEmpty } = resolveLabel(field.key, value, field.type, lookups);
|
||||
if (isEmpty) return null;
|
||||
return (
|
||||
<div key={field.key} className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{field.label}:</span>
|
||||
{display}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pairedGroup = group.paired ? groups.find(g => g.label === group.paired) : null;
|
||||
const pairedVisible = pairedGroup ? pairedGroup.fields.filter(f => f.key in data) : [];
|
||||
const isPaired = !!pairedGroup && pairedVisible.length > 0;
|
||||
|
||||
if (isPaired) {
|
||||
rendered.add(group.label);
|
||||
rendered.add(pairedGroup!.label);
|
||||
}
|
||||
|
||||
const renderGroupTable = (g: FieldGroup, fields: typeof visibleFields) => (
|
||||
<div key={g.label} className="flex-1 min-w-0">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">{g.label}</h3>
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
{fields.map((field, idx) => {
|
||||
const value = data[field.key];
|
||||
const { display, isEmpty } = resolveLabel(field.key, value, field.type, lookups);
|
||||
const stringValue = value !== null && value !== undefined ? String(value) : '';
|
||||
return (
|
||||
<div key={field.key}>
|
||||
{idx > 0 && <Separator />}
|
||||
<div className="group grid grid-cols-[140px_1fr] items-start">
|
||||
<div className="px-3 py-2.5 bg-muted/40 text-xs font-medium text-muted-foreground border-r truncate">
|
||||
{field.label}
|
||||
</div>
|
||||
<div className="px-3 py-2.5 flex items-start justify-between gap-2 min-w-0 overflow-hidden">
|
||||
<div className={`flex-1 min-w-0 ${isEmpty ? 'opacity-40' : ''}`} style={{overflowWrap:'anywhere', wordBreak:'break-word'}}>
|
||||
{display}
|
||||
</div>
|
||||
{stringValue && !isEmpty && (
|
||||
<Button variant="ghost" size="icon"
|
||||
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={() => copyToClipboard(stringValue, `${g.label}-${field.key}`)}
|
||||
>
|
||||
{copiedField === `${g.label}-${field.key}`
|
||||
? <CheckCircle2 className="w-3 h-3 text-green-500" />
|
||||
: <Copy className="w-3 h-3" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isPaired) {
|
||||
return (
|
||||
<div key={group.label} className="grid grid-cols-2 gap-4">
|
||||
{renderGroupTable(group, visibleFields)}
|
||||
{renderGroupTable(pairedGroup!, pairedVisible)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={group.label}>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">{group.label}</h3>
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
{visibleFields.map((field, idx) => {
|
||||
const value = data[field.key];
|
||||
const { display, isEmpty } = resolveLabel(field.key, value, field.type);
|
||||
const { display, isEmpty } = resolveLabel(field.key, value, field.type, lookups);
|
||||
const stringValue = value !== null && value !== undefined ? String(value) : '';
|
||||
return (
|
||||
<div key={field.key}>
|
||||
|
|
@ -323,14 +550,12 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
|
|||
<div className="px-4 py-3 bg-muted/40 text-sm font-medium text-muted-foreground border-r">
|
||||
{field.label}
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-start justify-between gap-2 min-w-0">
|
||||
<div className={`flex-1 min-w-0 break-words ${isEmpty ? 'opacity-40' : ''}`}>
|
||||
<div className="px-4 py-3 flex items-start justify-between gap-2 min-w-0 overflow-hidden">
|
||||
<div className={`flex-1 min-w-0 ${isEmpty ? 'opacity-40' : ''}`} style={{overflowWrap:'anywhere', wordBreak:'break-word'}}>
|
||||
{display}
|
||||
</div>
|
||||
{stringValue && !isEmpty && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<Button variant="ghost" size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={() => copyToClipboard(stringValue, field.key)}
|
||||
>
|
||||
|
|
@ -347,13 +572,14 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
});
|
||||
})()}
|
||||
|
||||
{/* Description block for tickets */}
|
||||
{'description' in data && data.description && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">Description</h3>
|
||||
<div className="rounded-lg border p-4 text-sm whitespace-pre-wrap leading-relaxed text-muted-foreground">
|
||||
<div className="rounded-lg border p-4 text-sm whitespace-pre-wrap leading-relaxed text-muted-foreground break-words overflow-hidden" style={{overflowWrap:'anywhere', wordBreak:'break-word'}}>
|
||||
{data.description}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -361,6 +587,137 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
|
|||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Time Entries Tab ── */}
|
||||
<TabsContent value="time" className="flex-1 overflow-y-auto px-6 py-4 mt-0">
|
||||
{timeEntriesLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : timeEntries.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground gap-2">
|
||||
<Clock className="w-8 h-8 opacity-30" />
|
||||
<p className="text-sm">No time entries on this ticket</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* Summary bar */}
|
||||
<div className="rounded-lg border px-4 py-3 flex items-center gap-6 bg-muted/30 mb-4">
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="font-semibold">{timeEntries.reduce((s, e) => s + (parseFloat(e.hours_worked) || 0), 0).toFixed(2)}</span>
|
||||
<span className="text-muted-foreground">total hours</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{timeEntries.length} {timeEntries.length === 1 ? 'entry' : 'entries'}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{timeEntries.filter(e => e.billable).length} billable
|
||||
</div>
|
||||
</div>
|
||||
{/* Entry rows */}
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
{timeEntries.map((entry, idx) => (
|
||||
<div key={entry.id}>
|
||||
{idx > 0 && <Separator />}
|
||||
<div className="px-4 py-3 grid grid-cols-[1fr_auto] gap-4 items-start">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{entry.resource_name && (
|
||||
<span className="inline-flex items-center gap-1 text-sm font-medium">
|
||||
<User className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{entry.resource_name}
|
||||
</span>
|
||||
)}
|
||||
{entry.billable && (
|
||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-green-500/15 text-green-600 border border-green-500/30">Billable</span>
|
||||
)}
|
||||
{entry.approved && (
|
||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-blue-500/15 text-blue-600 border border-blue-500/30">Approved</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.notes && (
|
||||
<p className="text-sm text-muted-foreground" style={{overflowWrap:'anywhere', wordBreak:'break-word'}}>{entry.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 flex flex-col items-end gap-1">
|
||||
<span className="text-sm font-semibold tabular-nums">
|
||||
{parseFloat(entry.hours_worked).toFixed(2)}h
|
||||
</span>
|
||||
{entry.entry_date && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{new Date(entry.entry_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Notes Tab ── */}
|
||||
<TabsContent value="notes" className="flex-1 overflow-y-auto px-6 py-4 mt-0">
|
||||
{notesLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground gap-2">
|
||||
<MessageSquare className="w-8 h-8 opacity-30" />
|
||||
<p className="text-sm">No notes on this ticket</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{notes.map((note) => {
|
||||
const publishCls: Record<number, string> = {
|
||||
1: 'bg-green-500/15 text-green-600 border border-green-500/30',
|
||||
2: 'bg-amber-500/15 text-amber-700 border border-amber-500/30',
|
||||
4: 'bg-slate-500/15 text-slate-500 border border-slate-500/30',
|
||||
};
|
||||
const publishLabel: Record<number, string> = {
|
||||
1: 'All Users', 2: 'Internal', 4: 'Internal Only',
|
||||
};
|
||||
return (
|
||||
<div key={note.id} className="rounded-lg border p-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{note.creator_name && (
|
||||
<span className="inline-flex items-center gap-1 text-sm font-medium">
|
||||
<User className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{note.creator_name}
|
||||
</span>
|
||||
)}
|
||||
{note.publish != null && (
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${publishCls[note.publish] ?? 'bg-muted text-muted-foreground border border-border'}`}>
|
||||
{publishLabel[note.publish] ?? `Publish ${note.publish}`}
|
||||
</span>
|
||||
)}
|
||||
{note.title && (
|
||||
<span className="text-sm font-semibold text-foreground">{note.title}</span>
|
||||
)}
|
||||
</div>
|
||||
{note.create_date_time && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground shrink-0">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{new Date(note.create_date_time).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
{' '}
|
||||
{new Date(note.create_date_time).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{note.description && (
|
||||
<div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed border-t pt-2" style={{overflowWrap:'anywhere', wordBreak:'break-word'}}>
|
||||
{note.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Raw Tab ── */}
|
||||
<TabsContent value="raw" className="flex-1 overflow-y-auto px-6 py-4 mt-0">
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
|
|
|
|||
290
components/admin/IntegrationStatusTabs.tsx
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import {
|
||||
Shield, Monitor, Network, Apple,
|
||||
CheckCircle2, XCircle, AlertTriangle, RefreshCw, Loader2,
|
||||
Server, HardDrive, Cpu, Clock,
|
||||
} from 'lucide-react';
|
||||
|
||||
function StatusDot({ ok, warn }: { ok: boolean; warn?: boolean }) {
|
||||
if (!ok) return <span className="inline-block w-2 h-2 rounded-full bg-red-500" />;
|
||||
if (warn) return <span className="inline-block w-2 h-2 rounded-full bg-yellow-500" />;
|
||||
return <span className="inline-block w-2 h-2 rounded-full bg-green-500" />;
|
||||
}
|
||||
|
||||
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 fmtDate(d: string | null) {
|
||||
if (!d) return 'Never';
|
||||
return new Date(d).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function VeeamTab({ 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 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;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusDot ok={data.configured} warn={totalFailed > 0 || totalWarning > 0} />
|
||||
<div>
|
||||
<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>
|
||||
<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" />}
|
||||
Sync Now
|
||||
</Button>
|
||||
</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={(aj.success ?? 0) + (bj.success ?? 0)} 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' : ''} completed 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 (stalled jobs appear here)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DattoRmmTab({ 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 unlinked = Math.max(0, (data.totalConfigItems ?? 0) - (data.rmmLinkedDevices ?? 0));
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusDot ok={data.configured} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{data.configured ? 'Connected' : 'Not configured'}</p>
|
||||
<p className="text-xs text-muted-foreground">{data.apiUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<a href="https://concord.rmm.datto.com" target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" size="sm">Open 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" />}
|
||||
Sync CIs
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<StatCard label="Active Config Items" value={data.totalConfigItems ?? 0} icon={Cpu} />
|
||||
<StatCard label="RMM-Linked Devices" value={data.rmmLinkedDevices ?? 0} icon={Monitor}
|
||||
sub="with rmm_device_uid" />
|
||||
<StatCard label="Unlinked Devices" value={unlinked} icon={Monitor}
|
||||
cls={unlinked > 0 ? 'border-yellow-500/30 bg-yellow-500/5' : ''} />
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 bg-muted/30 text-sm text-muted-foreground">
|
||||
Datto RMM device data is queried live via the RMM API when investigating alerts.
|
||||
Device records link to Autotask Configuration Items via <code className="text-xs bg-muted px-1 rounded">rmm_device_uid</code>.
|
||||
Run an Autotask Configuration Items sync to refresh CI data.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuvikTab({ data }: { data: any }) {
|
||||
if (!data) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusDot ok={data.configured} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{data.configured ? 'Connected' : 'Not configured'}</p>
|
||||
<p className="text-xs text-muted-foreground">{data.apiUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="https://auvikapi.us5.my.auvik.com" target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" size="sm">Open Portal</Button>
|
||||
</a>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 bg-muted/30 text-sm text-muted-foreground">
|
||||
Auvik provides network topology and device data. The API is configured and accessible.
|
||||
Full sync and dashboard integration is planned — data is currently available via the Auvik API endpoints
|
||||
at <code className="text-xs bg-muted px-1 rounded">/api/auvik/devices</code> and <code className="text-xs bg-muted px-1 rounded">/api/auvik/tenant-mappings</code>.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddigyTab({ data }: { data: any }) {
|
||||
if (!data) return <div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusDot ok={data.configured} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{data.configured ? 'Connected' : 'Not configured'}</p>
|
||||
<p className="text-xs text-muted-foreground">{data.apiUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="https://app.addigy.com" target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" size="sm">Open Portal</Button>
|
||||
</a>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 bg-muted/30 text-sm text-muted-foreground">
|
||||
Addigy manages Apple (macOS/iOS) devices. The API is configured and accessible via token auth.
|
||||
Device and policy data is available via <code className="text-xs bg-muted px-1 rounded">/api/addigy-devices</code> and <code className="text-xs bg-muted px-1 rounded">/api/addigy-policies</code>.
|
||||
Full sync integration is planned.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IntegrationStatusTabs() {
|
||||
const [status, setStatus] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [veeamSyncing, setVeeamSyncing] = useState(false);
|
||||
const [rmmSyncing, setRmmSyncing] = useState(false);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/integrations/status');
|
||||
if (res.ok) setStatus(await res.json());
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch integration status:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchStatus(); }, []);
|
||||
|
||||
const handleVeeamSync = async () => {
|
||||
setVeeamSyncing(true);
|
||||
try {
|
||||
await fetch('/api/veeam/sync', { method: 'POST', body: JSON.stringify({ syncType: 'full' }), headers: { 'Content-Type': 'application/json' } });
|
||||
// Poll until done
|
||||
const poll = setInterval(async () => {
|
||||
const r = await fetch('/api/veeam/sync');
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
if (!d.isSyncing) {
|
||||
clearInterval(poll);
|
||||
setVeeamSyncing(false);
|
||||
fetchStatus();
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
} catch {
|
||||
setVeeamSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRmmSync = async () => {
|
||||
setRmmSyncing(true);
|
||||
try {
|
||||
await fetch('/api/sync/entity', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ entities: ['configuration_items'] }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
setTimeout(() => { setRmmSyncing(false); fetchStatus(); }, 5000);
|
||||
} catch {
|
||||
setRmmSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="veeam" className="w-full">
|
||||
<TabsList className="grid w-full max-w-lg grid-cols-4">
|
||||
<TabsTrigger value="veeam" className="gap-1.5">
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
Veeam
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="datto" className="gap-1.5">
|
||||
<Monitor className="w-3.5 h-3.5" />
|
||||
Datto RMM
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="auvik" className="gap-1.5">
|
||||
<Network className="w-3.5 h-3.5" />
|
||||
Auvik
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="addigy" className="gap-1.5">
|
||||
<Apple className="w-3.5 h-3.5" />
|
||||
Addigy
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="veeam" className="mt-6">
|
||||
<VeeamTab data={status?.veeam} onSync={handleVeeamSync} syncing={veeamSyncing} />
|
||||
</TabsContent>
|
||||
<TabsContent value="datto" className="mt-6">
|
||||
<DattoRmmTab data={status?.dattoRmm} onSync={handleRmmSync} syncing={rmmSyncing} />
|
||||
</TabsContent>
|
||||
<TabsContent value="auvik" className="mt-6">
|
||||
<AuvikTab data={status?.auvik} />
|
||||
</TabsContent>
|
||||
<TabsContent value="addigy" className="mt-6">
|
||||
<AddigyTab data={status?.addigy} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,7 +13,8 @@ import {
|
|||
RefreshCw,
|
||||
ChevronDown,
|
||||
Activity,
|
||||
HardDrive
|
||||
HardDrive,
|
||||
Workflow,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
NavigationMenu,
|
||||
|
|
@ -59,10 +60,10 @@ const navigationItems: NavItem[] = [
|
|||
icon: Activity,
|
||||
children: [
|
||||
{
|
||||
title: 'Sync Management',
|
||||
title: 'Integrations & Sync',
|
||||
href: '/admin/sync',
|
||||
icon: RefreshCw,
|
||||
description: 'Sync data from external systems'
|
||||
description: 'Manage sync across PSA, RMM, NMS, Backup, and Apple RMM'
|
||||
},
|
||||
{
|
||||
title: 'NMS Mapping (Auvik)',
|
||||
|
|
@ -82,6 +83,12 @@ const navigationItems: NavItem[] = [
|
|||
icon: Smartphone,
|
||||
description: 'Map Addigy devices to companies'
|
||||
},
|
||||
{
|
||||
title: 'Workflow Engine',
|
||||
href: '/admin/workflow',
|
||||
icon: Workflow,
|
||||
description: 'Automated ticket triage and classification'
|
||||
},
|
||||
{
|
||||
title: 'Data Browser',
|
||||
href: '/admin/data-browser',
|
||||
|
|
|
|||
BIN
dev/PSA_Autotask_Workflow_Rule.png
Normal file
|
After Width: | Height: | Size: 355 KiB |
28001
dev/Triage Auto - Main Branch.json
Normal file
|
|
@ -46,6 +46,17 @@ services:
|
|||
restart: unless-stopped
|
||||
ports:
|
||||
- "3100:3100"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.pulse.rule=Host(`pulse.wulfconsulting.cloud`)"
|
||||
- "traefik.http.routers.pulse.entrypoints=websecure"
|
||||
- "traefik.http.routers.pulse.tls=true"
|
||||
- "traefik.http.routers.pulse.tls.certresolver=cloudflare"
|
||||
- "traefik.http.services.pulse.loadbalancer.server.port=3100"
|
||||
- "traefik.docker.network=frontend"
|
||||
networks:
|
||||
- default
|
||||
- frontend
|
||||
env_file:
|
||||
- .env.local
|
||||
environment:
|
||||
|
|
@ -85,6 +96,10 @@ services:
|
|||
VEEAM_VSPC_URL: ${VEEAM_VSPC_URL}
|
||||
VEEAM_VSPC_API_KEY: ${VEEAM_VSPC_API_KEY}
|
||||
|
||||
# Webhook Configuration
|
||||
WEBHOOK_BASE_URL: ${WEBHOOK_BASE_URL:-https://pulse.wulfconsulting.cloud}
|
||||
AUTOTASK_WEBHOOK_SECRET: ${AUTOTASK_WEBHOOK_SECRET}
|
||||
|
||||
# PostgreSQL Configuration
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: 5432
|
||||
|
|
@ -110,3 +125,5 @@ volumes:
|
|||
networks:
|
||||
default:
|
||||
name: pulse-network
|
||||
frontend:
|
||||
external: true
|
||||
|
|
|
|||
359
lib/services/ai-triage-service.ts
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
/**
|
||||
* AI Triage Service
|
||||
* Handles AI-powered classification for ambiguous tickets and text enhancement.
|
||||
* Only called when robotic classifier can't confidently classify, or when
|
||||
* title/description cleanup is needed.
|
||||
*/
|
||||
|
||||
import { postgresClient } from './postgres-client';
|
||||
import {
|
||||
AiEnhancementResult,
|
||||
AiPromptTemplate,
|
||||
TicketData,
|
||||
WorkflowSettings,
|
||||
ClassificationResult,
|
||||
PromptPurpose,
|
||||
} from '../types/workflow';
|
||||
|
||||
export class AiTriageService {
|
||||
/**
|
||||
* Call the configured AI provider with a prompt.
|
||||
*/
|
||||
private async callProvider(
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
settings: WorkflowSettings,
|
||||
templateOverrides?: { provider?: string; model?: string; temperature?: number; max_tokens?: number }
|
||||
): Promise<string> {
|
||||
const provider = templateOverrides?.provider || settings.default_ai_provider;
|
||||
const temperature = templateOverrides?.temperature ?? 0.3;
|
||||
const maxTokens = templateOverrides?.max_tokens ?? 4000;
|
||||
|
||||
if (provider === 'anthropic') {
|
||||
return this.callAnthropic(
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
templateOverrides?.model || settings.anthropic_model,
|
||||
settings.anthropic_api_key,
|
||||
temperature,
|
||||
maxTokens
|
||||
);
|
||||
} else {
|
||||
return this.callOpenAI(
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
templateOverrides?.model || settings.openai_model,
|
||||
settings.openai_api_key,
|
||||
temperature,
|
||||
maxTokens
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call OpenAI API.
|
||||
*/
|
||||
private async callOpenAI(
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
model: string,
|
||||
apiKey: string,
|
||||
temperature: number,
|
||||
maxTokens: number
|
||||
): Promise<string> {
|
||||
if (!apiKey) throw new Error('OpenAI API key not configured');
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`OpenAI API error (${response.status}): ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.choices[0]?.message?.content || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Call Anthropic API.
|
||||
*/
|
||||
private async callAnthropic(
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
model: string,
|
||||
apiKey: string,
|
||||
temperature: number,
|
||||
maxTokens: number
|
||||
): Promise<string> {
|
||||
if (!apiKey) throw new Error('Anthropic API key not configured');
|
||||
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userPrompt }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Anthropic API error (${response.status}): ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const textBlock = data.content?.find((b: any) => b.type === 'text');
|
||||
return textBlock?.text || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an active AI prompt template by purpose.
|
||||
*/
|
||||
async getTemplate(purpose: PromptPurpose): Promise<AiPromptTemplate | null> {
|
||||
const result = await postgresClient.query<AiPromptTemplate>(
|
||||
`SELECT * FROM ai_prompt_templates WHERE purpose = $1 AND is_active = true ORDER BY version DESC LIMIT 1`,
|
||||
[purpose]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate template variables in a prompt string.
|
||||
* Supports {{field}} syntax.
|
||||
*/
|
||||
private interpolate(template: string, vars: Record<string, any>): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
||||
const val = vars[key];
|
||||
return val !== undefined && val !== null ? String(val) : '';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify ambiguous fields that the robotic classifier couldn't handle.
|
||||
* Returns only the fields that AI classified.
|
||||
*/
|
||||
async classifyAmbiguous(
|
||||
ticket: TicketData,
|
||||
failedFields: string[],
|
||||
settings: WorkflowSettings
|
||||
): Promise<AiEnhancementResult> {
|
||||
const template = await this.getTemplate('ambiguous_classification');
|
||||
|
||||
// Load picklist data for AI context
|
||||
const [issueTypes, subIssueTypes, priorities] = await Promise.all([
|
||||
postgresClient.query(`SELECT value, label FROM issue_types WHERE is_deleted = false ORDER BY label`),
|
||||
postgresClient.query(`SELECT value, label, parent_value FROM sub_issue_types WHERE is_deleted = false ORDER BY label`),
|
||||
postgresClient.query(`SELECT value, label FROM priorities WHERE is_active = true ORDER BY value`),
|
||||
]);
|
||||
|
||||
const picklistContext = {
|
||||
issueTypes: JSON.stringify(issueTypes.rows),
|
||||
subIssueTypes: JSON.stringify(subIssueTypes.rows),
|
||||
priorities: JSON.stringify(priorities.rows),
|
||||
};
|
||||
|
||||
const systemPrompt = template?.system_prompt || this.getDefaultClassificationSystemPrompt();
|
||||
const userTemplate = template?.user_prompt_template || this.getDefaultClassificationUserPrompt();
|
||||
|
||||
const userPrompt = this.interpolate(userTemplate, {
|
||||
title: ticket.title,
|
||||
description: ticket.description || 'No description provided',
|
||||
ticket_category: ticket.ticket_category,
|
||||
failed_fields: failedFields.join(', '),
|
||||
...picklistContext,
|
||||
});
|
||||
|
||||
const aiResponse = await this.callProvider(systemPrompt, userPrompt, settings, {
|
||||
provider: template?.provider,
|
||||
model: template?.model,
|
||||
temperature: template?.temperature,
|
||||
max_tokens: template?.max_tokens,
|
||||
});
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(aiResponse);
|
||||
return {
|
||||
classification: {
|
||||
issue_type: parsed.issueType || parsed.issue_type,
|
||||
sub_issue_type: parsed.subIssueType || parsed.sub_issue_type,
|
||||
ticket_type: parsed.ticketType || parsed.ticket_type,
|
||||
priority: parsed.priority,
|
||||
},
|
||||
method: 'ai',
|
||||
};
|
||||
} catch {
|
||||
console.error('[AI-TRIAGE] Failed to parse AI classification response:', aiResponse);
|
||||
return { method: 'ai' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up a messy ticket title (email subject, too long, garbled).
|
||||
*/
|
||||
async cleanupTitle(
|
||||
ticket: TicketData,
|
||||
settings: WorkflowSettings
|
||||
): Promise<string | undefined> {
|
||||
if (!settings.ai_for_title_cleanup) return undefined;
|
||||
|
||||
const template = await this.getTemplate('title_cleanup');
|
||||
const systemPrompt = template?.system_prompt ||
|
||||
'You are a helpdesk ticket title cleaner. Given a ticket title, produce a clean, concise title (max 80 chars). Remove email prefixes (Re:, Fw:, Fwd:), ticket numbers, excessive punctuation, and redundant info. Return JSON: {"title": "cleaned title"}';
|
||||
|
||||
const userTemplate = template?.user_prompt_template ||
|
||||
'Clean up this ticket title:\n\nOriginal title: {{title}}\nDescription (for context): {{description}}';
|
||||
|
||||
const userPrompt = this.interpolate(userTemplate, {
|
||||
title: ticket.title,
|
||||
description: (ticket.description || '').substring(0, 500),
|
||||
});
|
||||
|
||||
const aiResponse = await this.callProvider(systemPrompt, userPrompt, settings, {
|
||||
provider: template?.provider,
|
||||
model: template?.model,
|
||||
temperature: template?.temperature ?? 0.2,
|
||||
max_tokens: template?.max_tokens ?? 200,
|
||||
});
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(aiResponse);
|
||||
return parsed.title || undefined;
|
||||
} catch {
|
||||
console.error('[AI-TRIAGE] Failed to parse title cleanup response:', aiResponse);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite/restructure a ticket description.
|
||||
*/
|
||||
async rewriteDescription(
|
||||
ticket: TicketData,
|
||||
branch: string,
|
||||
settings: WorkflowSettings
|
||||
): Promise<string | undefined> {
|
||||
if (!settings.ai_for_description_rewrite) return undefined;
|
||||
if (!ticket.description || ticket.description.length < 50) return undefined;
|
||||
|
||||
const purpose: PromptPurpose = branch === 'noc' ? 'noc_format' : 'description_rewrite';
|
||||
const template = await this.getTemplate(purpose);
|
||||
if (!template) return undefined;
|
||||
|
||||
const userPrompt = this.interpolate(template.user_prompt_template, {
|
||||
title: ticket.title,
|
||||
description: ticket.description,
|
||||
branch,
|
||||
});
|
||||
|
||||
const aiResponse = await this.callProvider(template.system_prompt, userPrompt, settings, {
|
||||
provider: template.provider,
|
||||
model: template.model,
|
||||
temperature: template.temperature,
|
||||
max_tokens: template.max_tokens,
|
||||
});
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(aiResponse);
|
||||
return parsed.description || undefined;
|
||||
} catch {
|
||||
// If not JSON, return raw text as description
|
||||
return aiResponse.trim() || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate troubleshooting steps for an incident ticket.
|
||||
*/
|
||||
async generateTroubleshootingSteps(
|
||||
ticket: TicketData,
|
||||
settings: WorkflowSettings
|
||||
): Promise<string | undefined> {
|
||||
if (!settings.ai_for_troubleshooting) return undefined;
|
||||
|
||||
const template = await this.getTemplate('troubleshooting_steps');
|
||||
const systemPrompt = template?.system_prompt ||
|
||||
'You are an IT helpdesk assistant. Given a support ticket, generate 3-5 concise troubleshooting steps. Return JSON: {"steps": "numbered list of steps"}';
|
||||
|
||||
const userTemplate = template?.user_prompt_template ||
|
||||
'Generate troubleshooting steps for this ticket:\n\nTitle: {{title}}\nDescription: {{description}}';
|
||||
|
||||
const userPrompt = this.interpolate(userTemplate, {
|
||||
title: ticket.title,
|
||||
description: (ticket.description || '').substring(0, 2000),
|
||||
});
|
||||
|
||||
const aiResponse = await this.callProvider(systemPrompt, userPrompt, settings, {
|
||||
provider: template?.provider,
|
||||
model: template?.model,
|
||||
temperature: template?.temperature ?? 0.3,
|
||||
max_tokens: template?.max_tokens ?? 1000,
|
||||
});
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(aiResponse);
|
||||
return parsed.steps || undefined;
|
||||
} catch {
|
||||
return aiResponse.trim() || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default prompts (used when no template is configured in DB)
|
||||
// ============================================================================
|
||||
|
||||
private getDefaultClassificationSystemPrompt(): string {
|
||||
return `You are an IT helpdesk ticket classifier. Given a ticket title and description, classify the ticket into the correct issue type, sub-issue type, ticket type, and priority.
|
||||
|
||||
Rules:
|
||||
- ticket_type: 1 = Service Request, 2 = Incident
|
||||
- Use the provided picklist data to select valid issue types and sub-issue types
|
||||
- Ensure sub_issue_type parent_value matches the issue_type value
|
||||
- Return ONLY valid picklist values
|
||||
|
||||
Return JSON format:
|
||||
{"issueType": <number>, "subIssueType": <number>, "ticketType": <number>, "priority": <number>}`;
|
||||
}
|
||||
|
||||
private getDefaultClassificationUserPrompt(): string {
|
||||
return `Classify this ticket. Only provide values for these fields that need classification: {{failed_fields}}
|
||||
|
||||
Title: {{title}}
|
||||
Description: {{description}}
|
||||
Ticket Category: {{ticket_category}}
|
||||
|
||||
Available Issue Types:
|
||||
{{issueTypes}}
|
||||
|
||||
Available Sub-Issue Types (with parent_value):
|
||||
{{subIssueTypes}}
|
||||
|
||||
Available Priorities:
|
||||
{{priorities}}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const aiTriageService = new AiTriageService();
|
||||
358
lib/services/autotask-webhook-manager.ts
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
/**
|
||||
* Autotask Webhook Manager
|
||||
* Registers, manages, and deregisters webhooks with the Autotask REST API.
|
||||
* Autotask webhooks must be created via API — there is no GUI-based creation.
|
||||
*
|
||||
* Supported entities: Companies, Contacts, ConfigurationItems, Tickets, TicketNotes
|
||||
*/
|
||||
|
||||
import { AutotaskClient } from './autotask-client';
|
||||
import { postgresClient } from './postgres-client';
|
||||
import {
|
||||
WebhookEntityType,
|
||||
WEBHOOK_SUPPORTED_ENTITIES,
|
||||
AutotaskWebhookRegistration,
|
||||
AutotaskWebhookRegistrationResult,
|
||||
} from '../types/webhook';
|
||||
|
||||
/**
|
||||
* Maps our WebhookEntityType enum to the Autotask REST API webhook entity name.
|
||||
* e.g. "Tickets" → "TicketWebhooks"
|
||||
*/
|
||||
function getWebhookEntityName(entityType: WebhookEntityType): string {
|
||||
const map: Record<string, string> = {
|
||||
Companies: 'CompanyWebhooks',
|
||||
Contacts: 'ContactWebhooks',
|
||||
ConfigurationItems: 'ConfigurationItemWebhooks',
|
||||
Tickets: 'TicketWebhooks',
|
||||
TicketNotes: 'TicketNoteWebhooks',
|
||||
};
|
||||
return map[entityType] || `${entityType}Webhooks`;
|
||||
}
|
||||
|
||||
export class AutotaskWebhookManager {
|
||||
private client: AutotaskClient;
|
||||
private baseUrl: string;
|
||||
private webhookBaseUrl: string;
|
||||
private secretKey: string;
|
||||
private notificationEmail: string;
|
||||
|
||||
constructor() {
|
||||
this.client = new AutotaskClient({
|
||||
apiUrl: process.env.AUTOTASK_API_URL || '',
|
||||
username: process.env.AUTOTASK_USERNAME || '',
|
||||
password: process.env.AUTOTASK_SECRET || '',
|
||||
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
|
||||
});
|
||||
this.baseUrl = process.env.AUTOTASK_API_URL || '';
|
||||
this.webhookBaseUrl = process.env.WEBHOOK_BASE_URL || '';
|
||||
this.secretKey = process.env.AUTOTASK_WEBHOOK_SECRET || '';
|
||||
this.notificationEmail = process.env.AUTOTASK_USERNAME?.replace('@', '+webhooks@') || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Register webhooks for all supported entities
|
||||
*/
|
||||
async registerAll(): Promise<AutotaskWebhookRegistrationResult[]> {
|
||||
const results: AutotaskWebhookRegistrationResult[] = [];
|
||||
|
||||
for (const entityType of WEBHOOK_SUPPORTED_ENTITIES) {
|
||||
try {
|
||||
const result = await this.registerWebhook(entityType);
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[WEBHOOK-MGR] Failed to register ${entityType}:`, msg);
|
||||
results.push({
|
||||
entityType,
|
||||
webhookId: 0,
|
||||
fieldsRegistered: 0,
|
||||
excludedResources: 0,
|
||||
success: false,
|
||||
error: msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a webhook for a single entity type
|
||||
*/
|
||||
async registerWebhook(entityType: WebhookEntityType): Promise<AutotaskWebhookRegistrationResult> {
|
||||
const webhookEntityName = getWebhookEntityName(entityType);
|
||||
console.log(`[WEBHOOK-MGR] Registering webhook for ${entityType} via ${webhookEntityName}`);
|
||||
|
||||
// 1. Check if we already have a webhook registered
|
||||
const existing = await this.getExistingWebhookId(entityType);
|
||||
if (existing) {
|
||||
console.log(`[WEBHOOK-MGR] Webhook already registered for ${entityType} (ID: ${existing}), deleting first`);
|
||||
await this.deleteWebhook(entityType, existing);
|
||||
}
|
||||
|
||||
// 2. Create the webhook
|
||||
const webhookUrl = `${this.webhookBaseUrl}/api/webhooks/autotask`;
|
||||
const deactivationUrl = `${this.webhookBaseUrl}/api/webhooks/autotask?deactivated=true`;
|
||||
|
||||
const registration: AutotaskWebhookRegistration = {
|
||||
IsActive: true,
|
||||
DeactivationUrl: deactivationUrl,
|
||||
IsSubscribedToCreateEvents: true,
|
||||
IsSubscribedToUpdateEvents: true,
|
||||
IsSubscribedToDeleteEvents: true,
|
||||
Name: `Pulse - ${entityType}`,
|
||||
SecretKey: this.secretKey,
|
||||
SendThresholdExceededNotification: true,
|
||||
WebhookUrl: webhookUrl,
|
||||
NotificationEmailAddress: this.notificationEmail,
|
||||
};
|
||||
|
||||
const createUrl = `${this.baseUrl}/${webhookEntityName}`;
|
||||
const createResponse = await this.apiCall<{ itemId: number }>(createUrl, 'POST', registration);
|
||||
const webhookId = createResponse.itemId;
|
||||
|
||||
console.log(`[WEBHOOK-MGR] Created webhook for ${entityType}, ID: ${webhookId}`);
|
||||
|
||||
// 3. Discover and add trigger fields
|
||||
let fieldsRegistered = 0;
|
||||
try {
|
||||
fieldsRegistered = await this.addTriggerFields(webhookEntityName, webhookId);
|
||||
} catch (error) {
|
||||
console.warn(`[WEBHOOK-MGR] Could not add trigger fields for ${entityType}:`, error);
|
||||
}
|
||||
|
||||
// 4. Exclude the API user resource to prevent infinite loops
|
||||
let excludedResources = 0;
|
||||
try {
|
||||
excludedResources = await this.excludeApiResource(webhookEntityName, webhookId);
|
||||
} catch (error) {
|
||||
console.warn(`[WEBHOOK-MGR] Could not exclude API resource for ${entityType}:`, error);
|
||||
}
|
||||
|
||||
// 5. Save webhook ID to database
|
||||
await this.saveWebhookConfig(entityType, webhookId);
|
||||
|
||||
return {
|
||||
entityType,
|
||||
webhookId,
|
||||
fieldsRegistered,
|
||||
excludedResources,
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover available webhook fields and register them as triggers.
|
||||
* The fields endpoint is {Entity}WebhookFields/entityInformation/fields
|
||||
* (a separate entity, not a sub-path of the webhook).
|
||||
*/
|
||||
private async addTriggerFields(webhookEntityName: string, webhookId: number): Promise<number> {
|
||||
// Derive the fields entity name: "TicketWebhooks" → "TicketWebhookFields"
|
||||
const fieldsEntityName = webhookEntityName.replace('Webhooks', 'WebhookFields');
|
||||
const fieldsUrl = `${this.baseUrl}/${fieldsEntityName}/entityInformation/fields`;
|
||||
const fieldsResponse = await this.apiCall<{ fields: any[] }>(fieldsUrl, 'GET');
|
||||
|
||||
// Find the fieldID picklist to discover available trigger fields
|
||||
const fieldIdField = fieldsResponse.fields?.find((f: any) => f.name === 'fieldID');
|
||||
if (!fieldIdField?.picklistValues) {
|
||||
console.warn(`[WEBHOOK-MGR] No picklist values found for ${fieldsEntityName}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const activeFields = fieldIdField.picklistValues.filter((p: any) => p.isActive);
|
||||
let registered = 0;
|
||||
|
||||
// Register each available field as both a trigger and display-always field
|
||||
for (const field of activeFields) {
|
||||
try {
|
||||
const addFieldUrl = `${this.baseUrl}/${webhookEntityName}/${webhookId}/Fields`;
|
||||
await this.apiCall(addFieldUrl, 'POST', {
|
||||
FieldID: parseInt(field.value),
|
||||
IsDisplayAlwaysField: true,
|
||||
IsSubscribedField: true,
|
||||
WebhookID: webhookId,
|
||||
});
|
||||
registered++;
|
||||
} catch (error) {
|
||||
// Some fields may not support being triggers — that's OK
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
if (!msg.includes('already exists')) {
|
||||
console.debug(`[WEBHOOK-MGR] Could not add field ${field.label} (${field.value}): ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[WEBHOOK-MGR] Registered ${registered}/${activeFields.length} trigger fields for webhook ${webhookId}`);
|
||||
return registered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclude the API integration resource from triggering webhooks (prevents infinite loops)
|
||||
*/
|
||||
private async excludeApiResource(webhookEntityName: string, webhookId: number): Promise<number> {
|
||||
// Find the API resource by looking up the username
|
||||
const apiUsername = process.env.AUTOTASK_USERNAME || '';
|
||||
if (!apiUsername) return 0;
|
||||
|
||||
try {
|
||||
const resources = await this.client.getFieldInfo('Resources');
|
||||
// The API user is typically an API-only resource; we look it up by email
|
||||
const resource = await this.client.getResourceByEmail(apiUsername.split('@')[0] + '@' + apiUsername.split('@')[1]?.replace('@', ''));
|
||||
|
||||
if (!resource) {
|
||||
console.warn(`[WEBHOOK-MGR] Could not find API resource for ${apiUsername}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const excludeUrl = `${this.baseUrl}/${webhookEntityName}/${webhookId}/ExcludedResources`;
|
||||
await this.apiCall(excludeUrl, 'POST', {
|
||||
ResourceID: resource.id,
|
||||
WebhookID: webhookId,
|
||||
});
|
||||
|
||||
console.log(`[WEBHOOK-MGR] Excluded resource ${resource.id} (${apiUsername}) from webhook ${webhookId}`);
|
||||
return 1;
|
||||
} catch (error) {
|
||||
console.warn(`[WEBHOOK-MGR] Failed to exclude API resource:`, error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all webhooks for an entity type
|
||||
*/
|
||||
async listWebhooks(entityType: WebhookEntityType): Promise<any[]> {
|
||||
const webhookEntityName = getWebhookEntityName(entityType);
|
||||
const url = `${this.baseUrl}/${webhookEntityName}/query`;
|
||||
|
||||
try {
|
||||
const response = await this.apiCall<{ items: any[] }>(url, 'POST', {
|
||||
filter: [{ op: 'gte', field: 'id', value: 0 }],
|
||||
});
|
||||
return response.items || [];
|
||||
} catch (error) {
|
||||
console.error(`[WEBHOOK-MGR] Failed to list webhooks for ${entityType}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a webhook from Autotask
|
||||
*/
|
||||
async deleteWebhook(entityType: WebhookEntityType, webhookId: number): Promise<void> {
|
||||
const webhookEntityName = getWebhookEntityName(entityType);
|
||||
const url = `${this.baseUrl}/${webhookEntityName}/${webhookId}`;
|
||||
|
||||
try {
|
||||
await this.apiCall(url, 'DELETE');
|
||||
console.log(`[WEBHOOK-MGR] Deleted webhook ${webhookId} for ${entityType}`);
|
||||
} catch (error) {
|
||||
console.warn(`[WEBHOOK-MGR] Failed to delete webhook ${webhookId}:`, error);
|
||||
}
|
||||
|
||||
// Remove from database
|
||||
await postgresClient.query(
|
||||
`UPDATE webhook_configs SET autotask_webhook_id = NULL WHERE entity_type = $1`,
|
||||
[entityType]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deregister all webhooks
|
||||
*/
|
||||
async deregisterAll(): Promise<void> {
|
||||
for (const entityType of WEBHOOK_SUPPORTED_ENTITIES) {
|
||||
const webhookId = await this.getExistingWebhookId(entityType);
|
||||
if (webhookId) {
|
||||
await this.deleteWebhook(entityType, webhookId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of all registered webhooks
|
||||
*/
|
||||
async getStatus(): Promise<Array<{
|
||||
entityType: WebhookEntityType;
|
||||
webhookId: number | null;
|
||||
isActive: boolean;
|
||||
isConfigured: boolean;
|
||||
}>> {
|
||||
const results = [];
|
||||
|
||||
for (const entityType of WEBHOOK_SUPPORTED_ENTITIES) {
|
||||
const config = await postgresClient.query<{
|
||||
autotask_webhook_id: string | null;
|
||||
is_active: boolean;
|
||||
}>(
|
||||
`SELECT autotask_webhook_id, is_active FROM webhook_configs WHERE entity_type = $1`,
|
||||
[entityType]
|
||||
);
|
||||
|
||||
const row = config.rows[0];
|
||||
results.push({
|
||||
entityType,
|
||||
webhookId: row?.autotask_webhook_id ? parseInt(row.autotask_webhook_id) : null,
|
||||
isActive: row?.is_active ?? false,
|
||||
isConfigured: !!row?.autotask_webhook_id,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
private async getExistingWebhookId(entityType: WebhookEntityType): Promise<number | null> {
|
||||
const result = await postgresClient.query<{ autotask_webhook_id: string }>(
|
||||
`SELECT autotask_webhook_id FROM webhook_configs WHERE entity_type = $1 AND autotask_webhook_id IS NOT NULL`,
|
||||
[entityType]
|
||||
);
|
||||
if (result.rows.length > 0 && result.rows[0].autotask_webhook_id) {
|
||||
return parseInt(result.rows[0].autotask_webhook_id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async saveWebhookConfig(entityType: WebhookEntityType, webhookId: number): Promise<void> {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO webhook_configs (entity_type, event_types, is_active, autotask_webhook_id)
|
||||
VALUES ($1, $2, true, $3)
|
||||
ON CONFLICT (entity_type) DO UPDATE SET
|
||||
autotask_webhook_id = $3,
|
||||
is_active = true,
|
||||
updated_at = NOW()`,
|
||||
[entityType, JSON.stringify(['create', 'update', 'delete']), String(webhookId)]
|
||||
);
|
||||
}
|
||||
|
||||
private async apiCall<T = any>(url: string, method: string, body?: any): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Username': process.env.AUTOTASK_USERNAME || '',
|
||||
'Secret': process.env.AUTOTASK_SECRET || '',
|
||||
'APIIntegrationcode': process.env.AUTOTASK_API_INTEGRATION_CODE || '',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
const options: RequestInit = { method, headers };
|
||||
if (body && method !== 'GET' && method !== 'DELETE') {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Autotask API ${method} ${url} failed (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
// DELETE returns no body
|
||||
if (method === 'DELETE') {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
|
@ -128,6 +128,6 @@ export class DattoRMMClientSimple {
|
|||
}
|
||||
|
||||
// Then get devices for that site
|
||||
return this.getDevicesBySite(site.id);
|
||||
return this.getDevicesBySite(site.uid);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
DattoRMMConfig,
|
||||
DattoRMMDevice,
|
||||
DattoRMMSite,
|
||||
DattoRMMAlert,
|
||||
DattoRMMApiResponse,
|
||||
DattoRMMError,
|
||||
} from '@/lib/types/datto-rmm';
|
||||
|
|
@ -381,4 +382,88 @@ export class DattoRMMClient {
|
|||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic paginated fetch — follows nextPageUrl until exhausted
|
||||
*/
|
||||
private async fetchAllPages<T>(
|
||||
endpoint: string,
|
||||
dataKey: string,
|
||||
pageSize = 250
|
||||
): Promise<T[]> {
|
||||
const allItems: T[] = [];
|
||||
let url: string | null = `https://concord-api.centrastage.net/api/v2${endpoint}?pageSize=${pageSize}`;
|
||||
|
||||
while (url) {
|
||||
const token = await this.getAccessToken();
|
||||
const resp: Response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
throw new Error(`Datto RMM API ${resp.status}: ${errText.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
const body: any = await resp.json();
|
||||
const items = body[dataKey] || [];
|
||||
allItems.push(...items);
|
||||
|
||||
const nextUrl: string | null = body.pageDetails?.nextPageUrl ?? null;
|
||||
console.log(`[DATTO-RMM] ${dataKey}: fetched ${allItems.length} (page had ${items.length})`);
|
||||
|
||||
url = nextUrl;
|
||||
}
|
||||
|
||||
return allItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sites (paginated)
|
||||
*/
|
||||
async getAllSites(): Promise<DattoRMMSite[]> {
|
||||
return this.fetchAllPages<DattoRMMSite>('/account/sites', 'sites');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all open alerts (paginated)
|
||||
*/
|
||||
async getAllOpenAlerts(): Promise<DattoRMMAlert[]> {
|
||||
return this.fetchAllPages<DattoRMMAlert>('/account/alerts/open', 'alerts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all resolved alerts (paginated, recent only)
|
||||
*/
|
||||
async getRecentResolvedAlerts(maxPages = 4): Promise<DattoRMMAlert[]> {
|
||||
const allAlerts: DattoRMMAlert[] = [];
|
||||
let url: string | null = 'https://concord-api.centrastage.net/api/v2/account/alerts/resolved?pageSize=250';
|
||||
let pages = 0;
|
||||
|
||||
while (url && pages < maxPages) {
|
||||
const token = await this.getAccessToken();
|
||||
const resp: Response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!resp.ok) break;
|
||||
|
||||
const body: any = await resp.json();
|
||||
const alerts = body.alerts || [];
|
||||
allAlerts.push(...alerts);
|
||||
url = body.pageDetails?.nextPageUrl ?? null;
|
||||
pages++;
|
||||
console.log(`[DATTO-RMM] resolved alerts: fetched ${allAlerts.length} (page ${pages})`);
|
||||
}
|
||||
|
||||
return allAlerts;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
319
lib/services/datto-rmm-sync-service.ts
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
/**
|
||||
* Datto RMM Sync Service
|
||||
* Syncs sites, devices, and alerts from Datto RMM API to PostgreSQL
|
||||
*/
|
||||
|
||||
import postgresClient from './postgres-client';
|
||||
import { DattoRMMClient } from './datto-rmm-client';
|
||||
import { DattoRMMSite, DattoRMMDevice, DattoRMMAlert } from '@/lib/types/datto-rmm';
|
||||
|
||||
export interface DattoRMMSyncResult {
|
||||
syncId: string;
|
||||
syncType: 'full' | 'incremental';
|
||||
status: 'completed' | 'failed';
|
||||
startedAt: Date;
|
||||
completedAt: Date;
|
||||
duration: number;
|
||||
entities: DattoRMMEntitySyncResult[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface DattoRMMEntitySyncResult {
|
||||
entity: string;
|
||||
success: boolean;
|
||||
recordsUpserted: number;
|
||||
duration: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class DattoRMMSyncService {
|
||||
private client: DattoRMMClient;
|
||||
private isSyncing = false;
|
||||
|
||||
constructor(client?: DattoRMMClient) {
|
||||
if (client) {
|
||||
this.client = client;
|
||||
} else {
|
||||
this.client = new DattoRMMClient({
|
||||
apiUrl: process.env.DATTO_RMM_API_URL || 'https://concord-api.centrastage.net',
|
||||
apiKey: process.env.DATTO_RMM_API_KEY || '',
|
||||
apiSecret: process.env.DATTO_RMM_API_SECRET || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
isSyncInProgress(): boolean {
|
||||
return this.isSyncing;
|
||||
}
|
||||
|
||||
async fullSync(triggeredBy = 'system'): Promise<DattoRMMSyncResult> {
|
||||
return this.executeSync('full', triggeredBy);
|
||||
}
|
||||
|
||||
async incrementalSync(triggeredBy = 'system'): Promise<DattoRMMSyncResult> {
|
||||
return this.executeSync('incremental', triggeredBy);
|
||||
}
|
||||
|
||||
private async executeSync(syncType: 'full' | 'incremental', triggeredBy: string): Promise<DattoRMMSyncResult> {
|
||||
if (this.isSyncing) {
|
||||
throw new Error('A Datto RMM sync is already in progress');
|
||||
}
|
||||
|
||||
this.isSyncing = true;
|
||||
const syncId = `datto-rmm-${Date.now()}`;
|
||||
const startTime = new Date();
|
||||
const entityResults: DattoRMMEntitySyncResult[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
// Create sync history record
|
||||
let historyId: number | null = null;
|
||||
try {
|
||||
const histResult = await postgresClient.query<{ id: number }>(
|
||||
`INSERT INTO sync_history (entity_type, sync_type, status, started_at, records_added, records_updated, records_deleted, triggered_by)
|
||||
VALUES ($1, $2, $3, $4, 0, 0, 0, $5) RETURNING id`,
|
||||
['datto_rmm', syncType, 'started', startTime, triggeredBy]
|
||||
);
|
||||
historyId = histResult.rows[0].id;
|
||||
} catch (e) {
|
||||
console.warn('[DATTO-RMM-SYNC] Could not create sync history record:', e);
|
||||
}
|
||||
|
||||
console.log(`[DATTO-RMM-SYNC] Starting ${syncType} sync (${syncId})`);
|
||||
|
||||
try {
|
||||
const steps: Array<{ name: string; fn: () => Promise<number> }> = [
|
||||
{ name: 'sites', fn: () => this.syncSites() },
|
||||
{ name: 'devices', fn: () => this.syncDevices() },
|
||||
{ name: 'open_alerts', fn: () => this.syncOpenAlerts() },
|
||||
{ name: 'resolved_alerts', fn: () => this.syncResolvedAlerts() },
|
||||
];
|
||||
|
||||
for (const step of steps) {
|
||||
const stepStart = Date.now();
|
||||
try {
|
||||
const count = await step.fn();
|
||||
const duration = Date.now() - stepStart;
|
||||
entityResults.push({ entity: step.name, success: true, recordsUpserted: count, duration });
|
||||
console.log(`[DATTO-RMM-SYNC] ${step.name}: ${count} records in ${duration}ms`);
|
||||
} catch (error) {
|
||||
const duration = Date.now() - stepStart;
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`${step.name}: ${msg}`);
|
||||
entityResults.push({ entity: step.name, success: false, recordsUpserted: 0, duration, error: msg });
|
||||
console.error(`[DATTO-RMM-SYNC] ${step.name} failed:`, msg);
|
||||
}
|
||||
}
|
||||
|
||||
const completedAt = new Date();
|
||||
const duration = completedAt.getTime() - startTime.getTime();
|
||||
const status = errors.length === 0 ? 'completed' : 'failed';
|
||||
const totalRecords = entityResults.reduce((sum, r) => sum + r.recordsUpserted, 0);
|
||||
|
||||
console.log(`[DATTO-RMM-SYNC] Sync ${status} in ${duration}ms — ${totalRecords} total records`);
|
||||
|
||||
if (historyId) {
|
||||
try {
|
||||
await postgresClient.query(
|
||||
`UPDATE sync_history SET status = $1, completed_at = $2, records_added = $3, error_message = $4, entity_details = $5 WHERE id = $6`,
|
||||
[status, completedAt, totalRecords, errors.length > 0 ? errors.join('; ') : null, JSON.stringify(entityResults), historyId]
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('[DATTO-RMM-SYNC] Could not update sync history:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return { syncId, syncType, status, startedAt: startTime, completedAt, duration, entities: entityResults, errors };
|
||||
} catch (error) {
|
||||
const completedAt = new Date();
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error('[DATTO-RMM-SYNC] Sync failed catastrophically:', msg);
|
||||
|
||||
if (historyId) {
|
||||
try {
|
||||
await postgresClient.query(
|
||||
`UPDATE sync_history SET status = 'failed', completed_at = $1, error_message = $2 WHERE id = $3`,
|
||||
[completedAt, msg, historyId]
|
||||
);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
return {
|
||||
syncId, syncType, status: 'failed', startedAt: startTime, completedAt,
|
||||
duration: completedAt.getTime() - startTime.getTime(), entities: entityResults, errors: [msg],
|
||||
};
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sites ─────────────────────────────────────────────────────────────────────
|
||||
private async syncSites(): Promise<number> {
|
||||
const sites = await this.client.getAllSites();
|
||||
if (sites.length === 0) return 0;
|
||||
|
||||
// Build set of known company IDs for FK safety
|
||||
const knownCompanies = await postgresClient.query('SELECT id FROM companies');
|
||||
const companyIds = new Set(knownCompanies.rows.map((r: any) => r.id));
|
||||
|
||||
let count = 0;
|
||||
for (const s of sites) {
|
||||
const atCompanyId = s.autotaskCompanyId ? parseInt(s.autotaskCompanyId, 10) : null;
|
||||
const matchedCompanyId = atCompanyId && !isNaN(atCompanyId) && atCompanyId > 0 && companyIds.has(atCompanyId)
|
||||
? atCompanyId : null;
|
||||
|
||||
await postgresClient.query(
|
||||
`INSERT INTO datto_rmm_sites (id, uid, account_uid, name, description, notes, on_demand,
|
||||
autotask_company_id, autotask_company_name,
|
||||
number_of_devices, number_of_online_devices, number_of_offline_devices,
|
||||
portal_url, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
uid=EXCLUDED.uid, account_uid=EXCLUDED.account_uid, name=EXCLUDED.name,
|
||||
description=EXCLUDED.description, notes=EXCLUDED.notes, on_demand=EXCLUDED.on_demand,
|
||||
autotask_company_id=EXCLUDED.autotask_company_id, autotask_company_name=EXCLUDED.autotask_company_name,
|
||||
number_of_devices=EXCLUDED.number_of_devices, number_of_online_devices=EXCLUDED.number_of_online_devices,
|
||||
number_of_offline_devices=EXCLUDED.number_of_offline_devices,
|
||||
portal_url=EXCLUDED.portal_url, synced_at=NOW(), updated_at=NOW()`,
|
||||
[
|
||||
s.id, s.uid, s.accountUid || null, s.name, s.description || null, s.notes || null, s.onDemand,
|
||||
matchedCompanyId, s.autotaskCompanyName || null,
|
||||
s.devicesStatus?.numberOfDevices ?? 0,
|
||||
s.devicesStatus?.numberOfOnlineDevices ?? 0,
|
||||
s.devicesStatus?.numberOfOfflineDevices ?? 0,
|
||||
s.portalUrl || null,
|
||||
]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ── Devices ───────────────────────────────────────────────────────────────────
|
||||
private async syncDevices(): Promise<number> {
|
||||
const devices = await this.client.getAllDevices();
|
||||
if (devices.length === 0) return 0;
|
||||
|
||||
// Build set of known site IDs for FK safety
|
||||
const knownSites = await postgresClient.query('SELECT id FROM datto_rmm_sites');
|
||||
const siteIds = new Set(knownSites.rows.map((r: any) => r.id));
|
||||
|
||||
let count = 0;
|
||||
for (const d of devices) {
|
||||
const siteId = siteIds.has(d.siteId) ? d.siteId : null;
|
||||
|
||||
await postgresClient.query(
|
||||
`INSERT INTO datto_rmm_devices (id, uid, site_id, site_uid, site_name, hostname, description,
|
||||
device_type_category, device_type, operating_system, domain,
|
||||
int_ip_address, ext_ip_address, last_logged_in_user,
|
||||
last_seen, last_reboot, last_audit_date, creation_date,
|
||||
online, suspended, deleted, reboot_required, a64_bit,
|
||||
cag_version, display_version,
|
||||
antivirus_product, antivirus_status,
|
||||
patch_status, patches_approved_pending, patches_not_approved, patches_installed,
|
||||
software_status, portal_url, web_remote_url, warranty_date,
|
||||
snmp_enabled, device_class, network_probe, udf, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
uid=EXCLUDED.uid, site_id=EXCLUDED.site_id, site_uid=EXCLUDED.site_uid, site_name=EXCLUDED.site_name,
|
||||
hostname=EXCLUDED.hostname, description=EXCLUDED.description,
|
||||
device_type_category=EXCLUDED.device_type_category, device_type=EXCLUDED.device_type,
|
||||
operating_system=EXCLUDED.operating_system, domain=EXCLUDED.domain,
|
||||
int_ip_address=EXCLUDED.int_ip_address, ext_ip_address=EXCLUDED.ext_ip_address,
|
||||
last_logged_in_user=EXCLUDED.last_logged_in_user,
|
||||
last_seen=EXCLUDED.last_seen, last_reboot=EXCLUDED.last_reboot,
|
||||
last_audit_date=EXCLUDED.last_audit_date, creation_date=EXCLUDED.creation_date,
|
||||
online=EXCLUDED.online, suspended=EXCLUDED.suspended, deleted=EXCLUDED.deleted,
|
||||
reboot_required=EXCLUDED.reboot_required, a64_bit=EXCLUDED.a64_bit,
|
||||
cag_version=EXCLUDED.cag_version, display_version=EXCLUDED.display_version,
|
||||
antivirus_product=EXCLUDED.antivirus_product, antivirus_status=EXCLUDED.antivirus_status,
|
||||
patch_status=EXCLUDED.patch_status, patches_approved_pending=EXCLUDED.patches_approved_pending,
|
||||
patches_not_approved=EXCLUDED.patches_not_approved, patches_installed=EXCLUDED.patches_installed,
|
||||
software_status=EXCLUDED.software_status, portal_url=EXCLUDED.portal_url,
|
||||
web_remote_url=EXCLUDED.web_remote_url, warranty_date=EXCLUDED.warranty_date,
|
||||
snmp_enabled=EXCLUDED.snmp_enabled, device_class=EXCLUDED.device_class,
|
||||
network_probe=EXCLUDED.network_probe, udf=EXCLUDED.udf,
|
||||
synced_at=NOW(), updated_at=NOW()`,
|
||||
[
|
||||
d.id, d.uid, siteId, d.siteUid, d.siteName, d.hostname, d.description || null,
|
||||
d.deviceType?.category || null, d.deviceType?.type || null,
|
||||
d.operatingSystem || null, d.domain || null,
|
||||
d.intIpAddress || null, d.extIpAddress || null, d.lastLoggedInUser || null,
|
||||
d.lastSeen ? new Date(d.lastSeen) : null,
|
||||
d.lastReboot ? new Date(d.lastReboot) : null,
|
||||
d.lastAuditDate ? new Date(d.lastAuditDate) : null,
|
||||
d.creationDate ? new Date(d.creationDate) : null,
|
||||
d.online, d.suspended, d.deleted, d.rebootRequired ?? false, d.a64Bit ?? true,
|
||||
d.cagVersion || null, d.displayVersion || null,
|
||||
d.antivirus?.antivirusProduct || null, d.antivirus?.antivirusStatus || null,
|
||||
d.patchManagement?.patchStatus || null,
|
||||
d.patchManagement?.patchesApprovedPending ?? 0,
|
||||
d.patchManagement?.patchesNotApproved ?? 0,
|
||||
d.patchManagement?.patchesInstalled ?? 0,
|
||||
d.softwareStatus || null, d.portalUrl || null, d.webRemoteUrl || null,
|
||||
d.warrantyDate ? new Date(d.warrantyDate) : null,
|
||||
d.snmpEnabled ?? false, d.deviceClass || null, (d as any).networkProbe ?? false,
|
||||
d.udf ? JSON.stringify(d.udf) : null,
|
||||
]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ── Alerts ────────────────────────────────────────────────────────────────────
|
||||
private async upsertAlerts(alerts: DattoRMMAlert[]): Promise<number> {
|
||||
let count = 0;
|
||||
for (const a of alerts) {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO datto_rmm_alerts (alert_uid, device_uid, device_name, site_uid, site_name,
|
||||
priority, alert_context, alert_monitor_info, diagnostics,
|
||||
resolved, resolved_by, resolved_on, muted, ticket_number,
|
||||
autoresolve_mins, response_actions, timestamp, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW())
|
||||
ON CONFLICT (alert_uid) DO UPDATE SET
|
||||
device_uid=EXCLUDED.device_uid, device_name=EXCLUDED.device_name,
|
||||
site_uid=EXCLUDED.site_uid, site_name=EXCLUDED.site_name,
|
||||
priority=EXCLUDED.priority, alert_context=EXCLUDED.alert_context,
|
||||
alert_monitor_info=EXCLUDED.alert_monitor_info, diagnostics=EXCLUDED.diagnostics,
|
||||
resolved=EXCLUDED.resolved, resolved_by=EXCLUDED.resolved_by,
|
||||
resolved_on=EXCLUDED.resolved_on, muted=EXCLUDED.muted,
|
||||
ticket_number=EXCLUDED.ticket_number, autoresolve_mins=EXCLUDED.autoresolve_mins,
|
||||
response_actions=EXCLUDED.response_actions,
|
||||
synced_at=NOW(), updated_at=NOW()`,
|
||||
[
|
||||
a.alertUid,
|
||||
a.alertSourceInfo?.deviceUid || null,
|
||||
a.alertSourceInfo?.deviceName || null,
|
||||
a.alertSourceInfo?.siteUid || null,
|
||||
a.alertSourceInfo?.siteName || null,
|
||||
a.priority || null,
|
||||
a.alertContext ? JSON.stringify(a.alertContext) : null,
|
||||
a.alertMonitorInfo ? JSON.stringify(a.alertMonitorInfo) : null,
|
||||
a.diagnostics || null,
|
||||
a.resolved,
|
||||
a.resolvedBy || null,
|
||||
a.resolvedOn ? new Date(a.resolvedOn) : null,
|
||||
a.muted,
|
||||
a.ticketNumber || null,
|
||||
a.autoresolveMins ?? null,
|
||||
a.responseActions ? JSON.stringify(a.responseActions) : null,
|
||||
new Date(a.timestamp),
|
||||
]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncOpenAlerts(): Promise<number> {
|
||||
const alerts = await this.client.getAllOpenAlerts();
|
||||
if (alerts.length === 0) return 0;
|
||||
return this.upsertAlerts(alerts);
|
||||
}
|
||||
|
||||
private async syncResolvedAlerts(): Promise<number> {
|
||||
const alerts = await this.client.getRecentResolvedAlerts(4);
|
||||
if (alerts.length === 0) return 0;
|
||||
return this.upsertAlerts(alerts);
|
||||
}
|
||||
}
|
||||
|
|
@ -71,6 +71,15 @@ export class EntitySyncService {
|
|||
if (entity === EntityType.SUB_ISSUE_TYPES) {
|
||||
return await this.syncSubIssueTypes(isIncremental);
|
||||
}
|
||||
if (entity === EntityType.QUEUES) {
|
||||
return await this.syncQueues(isIncremental);
|
||||
}
|
||||
if (entity === EntityType.PRIORITIES) {
|
||||
return await this.syncPriorities(isIncremental);
|
||||
}
|
||||
if (entity === EntityType.TICKET_CATEGORIES) {
|
||||
return await this.syncTicketCategories(isIncremental);
|
||||
}
|
||||
|
||||
const trackingId = syncId || `${entity}_${Date.now()}`;
|
||||
const entityLogger = this.logger.child({ syncId: trackingId, entityType: entity });
|
||||
|
|
@ -889,6 +898,141 @@ export class EntitySyncService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Queues (Picklist from Ticket field)
|
||||
*/
|
||||
async syncQueues(isIncremental: boolean = false): Promise<EntitySyncStats> {
|
||||
const picklistLogger = this.logger.child({ entityType: EntityType.QUEUES, syncType: 'picklist' });
|
||||
const syncStartTime = picklistLogger.start('Picklist sync');
|
||||
|
||||
try {
|
||||
const picklistValues = await this.autotaskClient.getPicklistValues('Tickets', 'queueID');
|
||||
|
||||
const records = Object.entries(picklistValues).map(([value, label]) => ({
|
||||
value: parseInt(value),
|
||||
label: label,
|
||||
is_active: true,
|
||||
sort_order: parseInt(value),
|
||||
synced_at: new Date(),
|
||||
}));
|
||||
|
||||
picklistLogger.info('Found picklist values', { recordCount: records.length });
|
||||
|
||||
const tableName = getTableName(EntityType.QUEUES);
|
||||
const existingQuery = `SELECT value FROM ${tableName}`;
|
||||
const existingResult = await postgresClient.query<{ value: number }>(existingQuery);
|
||||
const existingValues = new Set(existingResult.rows.map((r: any) => r.value));
|
||||
|
||||
const recordsAdded = records.filter((r: any) => !existingValues.has(r.value)).length;
|
||||
const recordsUpdated = records.filter((r: any) => existingValues.has(r.value)).length;
|
||||
|
||||
await postgresClient.bulkUpsert(tableName, records, ['value']);
|
||||
|
||||
const stats: EntitySyncStats = { recordsAdded, recordsUpdated, recordsDeleted: 0 };
|
||||
|
||||
picklistLogger.complete('Picklist sync', syncStartTime, {
|
||||
recordsAdded: stats.recordsAdded,
|
||||
recordsUpdated: stats.recordsUpdated,
|
||||
});
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
picklistLogger.fail('Picklist sync', syncStartTime, err);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Priorities (Picklist from Ticket field)
|
||||
*/
|
||||
async syncPriorities(isIncremental: boolean = false): Promise<EntitySyncStats> {
|
||||
const picklistLogger = this.logger.child({ entityType: EntityType.PRIORITIES, syncType: 'picklist' });
|
||||
const syncStartTime = picklistLogger.start('Picklist sync');
|
||||
|
||||
try {
|
||||
const picklistValues = await this.autotaskClient.getPicklistValues('Tickets', 'priority');
|
||||
|
||||
const records = Object.entries(picklistValues).map(([value, label]) => ({
|
||||
value: parseInt(value),
|
||||
label: label,
|
||||
is_active: true,
|
||||
sort_order: parseInt(value),
|
||||
synced_at: new Date(),
|
||||
}));
|
||||
|
||||
picklistLogger.info('Found picklist values', { recordCount: records.length });
|
||||
|
||||
const tableName = getTableName(EntityType.PRIORITIES);
|
||||
const existingQuery = `SELECT value FROM ${tableName}`;
|
||||
const existingResult = await postgresClient.query<{ value: number }>(existingQuery);
|
||||
const existingValues = new Set(existingResult.rows.map((r: any) => r.value));
|
||||
|
||||
const recordsAdded = records.filter((r: any) => !existingValues.has(r.value)).length;
|
||||
const recordsUpdated = records.filter((r: any) => existingValues.has(r.value)).length;
|
||||
|
||||
await postgresClient.bulkUpsert(tableName, records, ['value']);
|
||||
|
||||
const stats: EntitySyncStats = { recordsAdded, recordsUpdated, recordsDeleted: 0 };
|
||||
|
||||
picklistLogger.complete('Picklist sync', syncStartTime, {
|
||||
recordsAdded: stats.recordsAdded,
|
||||
recordsUpdated: stats.recordsUpdated,
|
||||
});
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
picklistLogger.fail('Picklist sync', syncStartTime, err);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Ticket Categories (Picklist from Ticket field)
|
||||
*/
|
||||
async syncTicketCategories(isIncremental: boolean = false): Promise<EntitySyncStats> {
|
||||
const picklistLogger = this.logger.child({ entityType: EntityType.TICKET_CATEGORIES, syncType: 'picklist' });
|
||||
const syncStartTime = picklistLogger.start('Picklist sync');
|
||||
|
||||
try {
|
||||
const picklistValues = await this.autotaskClient.getPicklistValues('Tickets', 'ticketCategory');
|
||||
|
||||
const records = Object.entries(picklistValues).map(([value, label]) => ({
|
||||
value: parseInt(value),
|
||||
label: label,
|
||||
is_active: true,
|
||||
sort_order: parseInt(value),
|
||||
synced_at: new Date(),
|
||||
}));
|
||||
|
||||
picklistLogger.info('Found picklist values', { recordCount: records.length });
|
||||
|
||||
const tableName = getTableName(EntityType.TICKET_CATEGORIES);
|
||||
const existingQuery = `SELECT value FROM ${tableName}`;
|
||||
const existingResult = await postgresClient.query<{ value: number }>(existingQuery);
|
||||
const existingValues = new Set(existingResult.rows.map((r: any) => r.value));
|
||||
|
||||
const recordsAdded = records.filter((r: any) => !existingValues.has(r.value)).length;
|
||||
const recordsUpdated = records.filter((r: any) => existingValues.has(r.value)).length;
|
||||
|
||||
await postgresClient.bulkUpsert(tableName, records, ['value']);
|
||||
|
||||
const stats: EntitySyncStats = { recordsAdded, recordsUpdated, recordsDeleted: 0 };
|
||||
|
||||
picklistLogger.complete('Picklist sync', syncStartTime, {
|
||||
recordsAdded: stats.recordsAdded,
|
||||
recordsUpdated: stats.recordsUpdated,
|
||||
});
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
picklistLogger.fail('Picklist sync', syncStartTime, err);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Work Types (Picklist from TimeEntry field)
|
||||
*/
|
||||
|
|
|
|||
373
lib/services/robotic-classifier.ts
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
/**
|
||||
* Robotic Classifier
|
||||
* DB-driven keyword classification engine for ticket triage.
|
||||
* Handles ~90% of classification deterministically without AI.
|
||||
*/
|
||||
|
||||
import { postgresClient } from './postgres-client';
|
||||
import {
|
||||
ClassificationRule,
|
||||
ClassificationResult,
|
||||
ClassificationStepResult,
|
||||
ConfidenceLevel,
|
||||
RuleType,
|
||||
TicketData,
|
||||
} from '../types/workflow';
|
||||
|
||||
// Cache TTL for classification rules (5 minutes)
|
||||
const RULES_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export class RoboticClassifier {
|
||||
private rulesCache: Map<RuleType, ClassificationRule[]> = new Map();
|
||||
private cacheLoadedAt: number = 0;
|
||||
|
||||
/**
|
||||
* Load classification rules from DB, grouped by rule_type.
|
||||
* Cached for RULES_CACHE_TTL_MS to avoid repeated DB queries.
|
||||
*/
|
||||
async loadRules(forceRefresh = false): Promise<Map<RuleType, ClassificationRule[]>> {
|
||||
const now = Date.now();
|
||||
if (!forceRefresh && this.rulesCache.size > 0 && now - this.cacheLoadedAt < RULES_CACHE_TTL_MS) {
|
||||
return this.rulesCache;
|
||||
}
|
||||
|
||||
const result = await postgresClient.query<ClassificationRule>(
|
||||
`SELECT * FROM classification_rules WHERE is_active = true ORDER BY rule_type, sort_order`
|
||||
);
|
||||
|
||||
this.rulesCache.clear();
|
||||
for (const rule of result.rows) {
|
||||
const existing = this.rulesCache.get(rule.rule_type) || [];
|
||||
existing.push(rule);
|
||||
this.rulesCache.set(rule.rule_type, existing);
|
||||
}
|
||||
|
||||
this.cacheLoadedAt = now;
|
||||
console.log(`[CLASSIFIER] Loaded ${result.rows.length} classification rules`);
|
||||
return this.rulesCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run full classification pipeline on a ticket.
|
||||
*/
|
||||
async classify(ticket: TicketData): Promise<ClassificationResult> {
|
||||
await this.loadRules();
|
||||
|
||||
const branch = await this.classifyByType('branch_routing', ticket);
|
||||
const ticketType = await this.classifyByType('ticket_type', ticket);
|
||||
const issueClassification = await this.classifyByType('issue_classification', ticket);
|
||||
|
||||
// Priority classification can use ticket_type result as context
|
||||
const priorityTicket = { ...ticket };
|
||||
if (ticketType?.value != null) {
|
||||
priorityTicket.ticket_type = ticketType.value;
|
||||
}
|
||||
const priority = await this.classifyByType('priority', priorityTicket);
|
||||
|
||||
// Queue routing can use priority result as context
|
||||
const queueTicket = { ...priorityTicket };
|
||||
if (priority?.value != null) {
|
||||
queueTicket.priority = priority.value;
|
||||
}
|
||||
const queue = await this.classifyByType('queue_routing', queueTicket);
|
||||
|
||||
// Determine overall confidence and whether AI is needed
|
||||
const stepResults = [branch, ticketType, issueClassification, priority, queue];
|
||||
const { overallConfidence, needsAi, aiReasons } = this.assessConfidence(
|
||||
stepResults,
|
||||
ticket
|
||||
);
|
||||
|
||||
return {
|
||||
branch: branch || this.defaultBranch(),
|
||||
ticket_type: ticketType,
|
||||
issue_classification: issueClassification,
|
||||
priority: priority,
|
||||
queue: queue,
|
||||
overall_confidence: overallConfidence,
|
||||
needs_ai: needsAi,
|
||||
ai_reasons: aiReasons,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run classification for a specific rule type.
|
||||
*/
|
||||
private async classifyByType(
|
||||
ruleType: RuleType,
|
||||
ticket: TicketData
|
||||
): Promise<ClassificationStepResult | null> {
|
||||
const rules = this.rulesCache.get(ruleType) || [];
|
||||
|
||||
for (const rule of rules) {
|
||||
if (this.evaluateRule(rule, ticket)) {
|
||||
return {
|
||||
field: rule.result_field,
|
||||
value: rule.result_value,
|
||||
field_2: rule.result_field_2 || undefined,
|
||||
value_2: rule.result_value_2 || undefined,
|
||||
confidence: rule.confidence,
|
||||
matched_rule_id: rule.id,
|
||||
matched_rule_name: rule.name,
|
||||
method: 'robotic',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single classification rule against ticket data.
|
||||
*/
|
||||
private evaluateRule(rule: ClassificationRule, ticket: TicketData): boolean {
|
||||
const fieldValue = this.getFieldValue(rule.match_field, ticket);
|
||||
|
||||
if (fieldValue === null || fieldValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.evaluateMatch(
|
||||
rule.match_operator,
|
||||
fieldValue,
|
||||
rule.match_value,
|
||||
rule.match_case_sensitive
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of a field from the ticket data.
|
||||
* For 'title_or_description', returns a combined check target.
|
||||
*/
|
||||
private getFieldValue(
|
||||
matchField: string,
|
||||
ticket: TicketData
|
||||
): string | number | null {
|
||||
switch (matchField) {
|
||||
case 'title':
|
||||
return ticket.title || null;
|
||||
case 'description':
|
||||
return ticket.description || null;
|
||||
case 'title_or_description':
|
||||
// Return combined text for pattern matching
|
||||
return [ticket.title, ticket.description].filter(Boolean).join(' ') || null;
|
||||
case 'ticket_category':
|
||||
return ticket.ticket_category;
|
||||
case 'ticket_type':
|
||||
return ticket.ticket_type;
|
||||
case 'priority':
|
||||
return ticket.priority;
|
||||
case 'policy_name':
|
||||
return ticket.policy_name || null;
|
||||
case 'device_name':
|
||||
return ticket.device_name || null;
|
||||
case 'creator_resource_id':
|
||||
return ticket.creator_resource_id;
|
||||
case 'person_id':
|
||||
return ticket.person_id;
|
||||
case 'company_id':
|
||||
return ticket.company_id;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a match operation.
|
||||
*/
|
||||
private evaluateMatch(
|
||||
operator: string,
|
||||
fieldValue: string | number,
|
||||
matchValue: any,
|
||||
caseSensitive: boolean
|
||||
): boolean {
|
||||
switch (operator) {
|
||||
case 'contains':
|
||||
return this.evaluateContains(fieldValue, matchValue, caseSensitive);
|
||||
case 'starts_with':
|
||||
return this.evaluateStartsWith(fieldValue, matchValue, caseSensitive);
|
||||
case 'regex':
|
||||
return this.evaluateRegex(fieldValue, matchValue, caseSensitive);
|
||||
case 'equals':
|
||||
return this.evaluateEquals(fieldValue, matchValue);
|
||||
case 'in':
|
||||
return this.evaluateIn(fieldValue, matchValue);
|
||||
case 'not_in':
|
||||
return !this.evaluateIn(fieldValue, matchValue);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains: check if field contains any of the match values.
|
||||
* matchValue can be a string or array of strings.
|
||||
*/
|
||||
private evaluateContains(
|
||||
fieldValue: string | number,
|
||||
matchValue: any,
|
||||
caseSensitive: boolean
|
||||
): boolean {
|
||||
const text = String(fieldValue);
|
||||
const searchText = caseSensitive ? text : text.toLowerCase();
|
||||
const patterns = Array.isArray(matchValue) ? matchValue : [matchValue];
|
||||
|
||||
return patterns.some((pattern: string) => {
|
||||
const searchPattern = caseSensitive ? String(pattern) : String(pattern).toLowerCase();
|
||||
return searchText.includes(searchPattern);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts with: check if field starts with the match value.
|
||||
*/
|
||||
private evaluateStartsWith(
|
||||
fieldValue: string | number,
|
||||
matchValue: any,
|
||||
caseSensitive: boolean
|
||||
): boolean {
|
||||
const text = String(fieldValue);
|
||||
const searchText = caseSensitive ? text : text.toLowerCase();
|
||||
const patterns = Array.isArray(matchValue) ? matchValue : [matchValue];
|
||||
|
||||
return patterns.some((pattern: string) => {
|
||||
const searchPattern = caseSensitive ? String(pattern) : String(pattern).toLowerCase();
|
||||
return searchText.startsWith(searchPattern);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex: evaluate a regex pattern against the field value.
|
||||
*/
|
||||
private evaluateRegex(
|
||||
fieldValue: string | number,
|
||||
matchValue: any,
|
||||
caseSensitive: boolean
|
||||
): boolean {
|
||||
const text = String(fieldValue);
|
||||
const pattern = String(matchValue);
|
||||
try {
|
||||
const flags = caseSensitive ? '' : 'i';
|
||||
const regex = new RegExp(pattern, flags);
|
||||
return regex.test(text);
|
||||
} catch {
|
||||
console.error(`[CLASSIFIER] Invalid regex pattern: ${pattern}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Equals: exact match (handles numbers and strings).
|
||||
*/
|
||||
private evaluateEquals(
|
||||
fieldValue: string | number,
|
||||
matchValue: any
|
||||
): boolean {
|
||||
// Compare as numbers if both are numeric
|
||||
const numField = Number(fieldValue);
|
||||
const numMatch = Number(matchValue);
|
||||
if (!isNaN(numField) && !isNaN(numMatch)) {
|
||||
return numField === numMatch;
|
||||
}
|
||||
return String(fieldValue).toLowerCase() === String(matchValue).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* In: check if field value is in the match value array.
|
||||
*/
|
||||
private evaluateIn(
|
||||
fieldValue: string | number,
|
||||
matchValue: any
|
||||
): boolean {
|
||||
const values = Array.isArray(matchValue) ? matchValue : [matchValue];
|
||||
const numField = Number(fieldValue);
|
||||
|
||||
return values.some((v: any) => {
|
||||
const numV = Number(v);
|
||||
if (!isNaN(numField) && !isNaN(numV)) {
|
||||
return numField === numV;
|
||||
}
|
||||
return String(fieldValue).toLowerCase() === String(v).toLowerCase();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Default branch when no branch routing rule matches.
|
||||
*/
|
||||
private defaultBranch(): ClassificationStepResult {
|
||||
return {
|
||||
field: 'branch',
|
||||
value: 'service_desk',
|
||||
confidence: 'high',
|
||||
matched_rule_id: null,
|
||||
matched_rule_name: 'Default (service_desk)',
|
||||
method: 'robotic',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assess overall confidence and determine if AI enhancement is needed.
|
||||
*/
|
||||
private assessConfidence(
|
||||
results: (ClassificationStepResult | null)[],
|
||||
ticket: TicketData
|
||||
): { overallConfidence: ConfidenceLevel; needsAi: boolean; aiReasons: string[] } {
|
||||
const aiReasons: string[] = [];
|
||||
|
||||
// Check for missing classifications
|
||||
const [branch, ticketType, issueClass, priority, queue] = results;
|
||||
|
||||
if (!ticketType) {
|
||||
aiReasons.push('No ticket type classification matched');
|
||||
}
|
||||
if (!issueClass) {
|
||||
aiReasons.push('No issue type classification matched');
|
||||
}
|
||||
if (!priority) {
|
||||
aiReasons.push('No priority classification matched');
|
||||
}
|
||||
|
||||
// Check for low confidence results
|
||||
for (const result of results) {
|
||||
if (result && result.confidence === 'low') {
|
||||
aiReasons.push(`Low confidence on ${result.field}: ${result.matched_rule_name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if title looks like it needs cleanup (email subject, too long, garbled)
|
||||
if (ticket.title) {
|
||||
if (ticket.title.length > 150) {
|
||||
aiReasons.push('Title is very long (possible email subject)');
|
||||
}
|
||||
if (/^(re:|fw:|fwd:)/i.test(ticket.title)) {
|
||||
aiReasons.push('Title is a forwarded/replied email subject');
|
||||
}
|
||||
}
|
||||
|
||||
// Determine overall confidence
|
||||
let overallConfidence: ConfidenceLevel;
|
||||
if (aiReasons.length === 0) {
|
||||
overallConfidence = 'high';
|
||||
} else if (aiReasons.length <= 2) {
|
||||
overallConfidence = 'medium';
|
||||
} else {
|
||||
overallConfidence = 'low';
|
||||
}
|
||||
|
||||
return {
|
||||
overallConfidence,
|
||||
needsAi: aiReasons.length > 0,
|
||||
aiReasons,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh the rules cache.
|
||||
*/
|
||||
async refreshCache(): Promise<void> {
|
||||
await this.loadRules(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const roboticClassifier = new RoboticClassifier();
|
||||
151
lib/services/triage-validator.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* Triage Validator
|
||||
* Validates classification output against DB picklists.
|
||||
* Ensures issueType/subIssueType parent-child relationships are correct,
|
||||
* priority exists, queue exists, etc.
|
||||
*/
|
||||
|
||||
import { postgresClient } from './postgres-client';
|
||||
import {
|
||||
ClassificationResult,
|
||||
ValidationResult,
|
||||
ValidationError,
|
||||
} from '../types/workflow';
|
||||
|
||||
// Cache TTL for picklist data (10 minutes)
|
||||
const PICKLIST_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
interface PicklistCache {
|
||||
issueTypes: Set<number>;
|
||||
subIssueTypes: Map<number, number>; // value → parent_value
|
||||
priorities: Set<number>;
|
||||
queues: Set<number>;
|
||||
ticketCategories: Set<number>;
|
||||
loadedAt: number;
|
||||
}
|
||||
|
||||
export class TriageValidator {
|
||||
private cache: PicklistCache | null = null;
|
||||
|
||||
/**
|
||||
* Load picklist data from DB into cache.
|
||||
*/
|
||||
private async loadPicklists(forceRefresh = false): Promise<PicklistCache> {
|
||||
const now = Date.now();
|
||||
if (!forceRefresh && this.cache && now - this.cache.loadedAt < PICKLIST_CACHE_TTL_MS) {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
const [issueTypes, subIssueTypes, priorities, queues, ticketCategories] = await Promise.all([
|
||||
postgresClient.query(`SELECT value FROM issue_types WHERE is_deleted = false`),
|
||||
postgresClient.query(`SELECT value, parent_value FROM sub_issue_types WHERE is_deleted = false`),
|
||||
postgresClient.query(`SELECT value FROM priorities WHERE is_active = true`),
|
||||
postgresClient.query(`SELECT value FROM queues WHERE is_active = true`),
|
||||
postgresClient.query(`SELECT value FROM ticket_categories WHERE is_active = true`),
|
||||
]);
|
||||
|
||||
this.cache = {
|
||||
issueTypes: new Set(issueTypes.rows.map((r: any) => Number(r.value))),
|
||||
subIssueTypes: new Map(
|
||||
subIssueTypes.rows.map((r: any) => [Number(r.value), Number(r.parent_value)])
|
||||
),
|
||||
priorities: new Set(priorities.rows.map((r: any) => Number(r.value))),
|
||||
queues: new Set(queues.rows.map((r: any) => Number(r.value))),
|
||||
ticketCategories: new Set(ticketCategories.rows.map((r: any) => Number(r.value))),
|
||||
loadedAt: now,
|
||||
};
|
||||
|
||||
console.log(
|
||||
`[VALIDATOR] Loaded picklists: ${this.cache.issueTypes.size} issueTypes, ` +
|
||||
`${this.cache.subIssueTypes.size} subIssueTypes, ${this.cache.priorities.size} priorities, ` +
|
||||
`${this.cache.queues.size} queues, ${this.cache.ticketCategories.size} ticketCategories`
|
||||
);
|
||||
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a classification result against DB picklists.
|
||||
*/
|
||||
async validate(classification: ClassificationResult): Promise<ValidationResult> {
|
||||
const picklists = await this.loadPicklists();
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
// Validate issue type
|
||||
if (classification.issue_classification) {
|
||||
const issueTypeValue = Number(classification.issue_classification.value);
|
||||
if (!picklists.issueTypes.has(issueTypeValue)) {
|
||||
errors.push({
|
||||
field: 'issue_type',
|
||||
message: `Issue type ${issueTypeValue} not found in picklist`,
|
||||
value: issueTypeValue,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate sub-issue type
|
||||
if (classification.issue_classification.value_2 != null) {
|
||||
const subIssueTypeValue = Number(classification.issue_classification.value_2);
|
||||
if (!picklists.subIssueTypes.has(subIssueTypeValue)) {
|
||||
errors.push({
|
||||
field: 'sub_issue_type',
|
||||
message: `Sub-issue type ${subIssueTypeValue} not found in picklist`,
|
||||
value: subIssueTypeValue,
|
||||
});
|
||||
} else {
|
||||
// Validate parent-child relationship
|
||||
const parentValue = picklists.subIssueTypes.get(subIssueTypeValue);
|
||||
if (parentValue !== issueTypeValue) {
|
||||
errors.push({
|
||||
field: 'sub_issue_type',
|
||||
message: `Sub-issue type ${subIssueTypeValue} has parent ${parentValue}, but issue type is ${issueTypeValue}`,
|
||||
value: subIssueTypeValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate priority
|
||||
if (classification.priority) {
|
||||
const priorityValue = Number(classification.priority.value);
|
||||
if (!picklists.priorities.has(priorityValue)) {
|
||||
errors.push({
|
||||
field: 'priority',
|
||||
message: `Priority ${priorityValue} not found in picklist`,
|
||||
value: priorityValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate queue
|
||||
if (classification.queue) {
|
||||
const queueValue = Number(classification.queue.value);
|
||||
if (!picklists.queues.has(queueValue)) {
|
||||
errors.push({
|
||||
field: 'queue_id',
|
||||
message: `Queue ${queueValue} not found in picklist`,
|
||||
value: queueValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.warn(`[VALIDATOR] Validation failed with ${errors.length} error(s):`, errors);
|
||||
}
|
||||
|
||||
return {
|
||||
is_valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh the picklist cache.
|
||||
*/
|
||||
async refreshCache(): Promise<void> {
|
||||
await this.loadPicklists(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const triageValidator = new TriageValidator();
|
||||
|
|
@ -6,6 +6,8 @@ import {
|
|||
VspcBackupAgentJob,
|
||||
VspcProtectedWorkload,
|
||||
VspcRepository,
|
||||
VspcBackupAgent,
|
||||
VspcAlarm,
|
||||
} from '@/lib/types/veeam';
|
||||
|
||||
export interface VeeamClientConfig {
|
||||
|
|
@ -129,16 +131,20 @@ export class VeeamClient {
|
|||
const url = this.buildUrl(path, params);
|
||||
const response = await this.makeApiCall<VspcListResponse<T>>(url);
|
||||
|
||||
if (response.data && response.data.length > 0) {
|
||||
allItems.push(...response.data);
|
||||
const pageData = response.data ?? [];
|
||||
if (pageData.length > 0) {
|
||||
allItems.push(...pageData);
|
||||
}
|
||||
|
||||
total = response.meta?.pagingInfo?.total ?? 0;
|
||||
offset += this.DEFAULT_PAGE_SIZE;
|
||||
|
||||
if (response.data.length > 0) {
|
||||
if (pageData.length > 0) {
|
||||
console.log(`Veeam VSPC: fetched ${allItems.length}/${total} from ${path}`);
|
||||
}
|
||||
|
||||
// Safety: if page returned nothing and we haven't hit total, stop
|
||||
if (pageData.length === 0) break;
|
||||
} while (offset < total);
|
||||
|
||||
return allItems;
|
||||
|
|
@ -208,6 +214,26 @@ export class VeeamClient {
|
|||
return repos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all backup agents (Veeam agent installs on managed machines)
|
||||
*/
|
||||
async getBackupAgents(): Promise<VspcBackupAgent[]> {
|
||||
console.log('Fetching Veeam VSPC backup agents...');
|
||||
const agents = await this.fetchAllPages<VspcBackupAgent>('/infrastructure/backupAgents');
|
||||
console.log(`Fetched ${agents.length} Veeam backup agents`);
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all active VSPC alarms
|
||||
*/
|
||||
async getActiveAlarms(): Promise<VspcAlarm[]> {
|
||||
console.log('Fetching Veeam VSPC active alarms...');
|
||||
const alarms = await this.fetchAllPages<VspcAlarm>('/alarms/active');
|
||||
console.log(`Fetched ${alarms.length} Veeam active alarms`);
|
||||
return alarms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test API connectivity by fetching a single organization
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import {
|
|||
VspcBackupAgentJob,
|
||||
VspcProtectedWorkload,
|
||||
VspcRepository,
|
||||
VspcBackupAgent,
|
||||
VspcAlarm,
|
||||
} from '@/lib/types/veeam';
|
||||
import { VeeamComplianceService } from './veeam-compliance-service';
|
||||
|
||||
|
|
@ -97,6 +99,8 @@ export class VeeamSyncService {
|
|||
{ name: 'backup_jobs', fn: () => this.syncBackupJobs() },
|
||||
{ name: 'backup_agent_jobs', fn: () => this.syncBackupAgentJobs() },
|
||||
{ name: 'protected_workloads', fn: () => this.syncProtectedWorkloads() },
|
||||
{ name: 'backup_agents', fn: () => this.syncBackupAgents() },
|
||||
{ name: 'alarms', fn: () => this.syncAlarms() },
|
||||
];
|
||||
|
||||
for (const step of steps) {
|
||||
|
|
@ -136,8 +140,8 @@ export class VeeamSyncService {
|
|||
if (historyId) {
|
||||
try {
|
||||
await postgresClient.query(
|
||||
`UPDATE sync_history SET status = $1, completed_at = $2, records_added = $3, error_message = $4 WHERE id = $5`,
|
||||
[status, completedAt, totalRecords, errors.length > 0 ? errors.join('; ') : null, historyId]
|
||||
`UPDATE sync_history SET status = $1, completed_at = $2, records_added = $3, error_message = $4, entity_details = $5 WHERE id = $6`,
|
||||
[status, completedAt, totalRecords, errors.length > 0 ? errors.join('; ') : null, JSON.stringify(entityResults), historyId]
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('[VEEAM-SYNC] Could not update sync history:', e);
|
||||
|
|
@ -399,4 +403,101 @@ export class VeeamSyncService {
|
|||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncBackupAgents(): Promise<number> {
|
||||
const agents = await this.client.getBackupAgents();
|
||||
if (agents.length === 0) return 0;
|
||||
|
||||
const knownOrgs = await postgresClient.query('SELECT instance_uid FROM veeam_organizations');
|
||||
const orgUids = new Set(knownOrgs.rows.map((r: any) => r.instance_uid));
|
||||
|
||||
let count = 0;
|
||||
for (const a of agents) {
|
||||
const orgUid = orgUids.has(a.organizationUid) ? a.organizationUid : null;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO veeam_backup_agents (instance_uid, organization_uid, site_uid, management_agent_uid,
|
||||
name, agent_platform, status, management_agent_status, operation_mode, gui_mode,
|
||||
platform, version, version_status, management_mode, installation_type, activation_time,
|
||||
total_jobs_count, running_jobs_count, success_jobs_count, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,NOW())
|
||||
ON CONFLICT (instance_uid) DO UPDATE SET
|
||||
organization_uid=EXCLUDED.organization_uid, site_uid=EXCLUDED.site_uid,
|
||||
management_agent_uid=EXCLUDED.management_agent_uid, name=EXCLUDED.name,
|
||||
agent_platform=EXCLUDED.agent_platform, status=EXCLUDED.status,
|
||||
management_agent_status=EXCLUDED.management_agent_status, operation_mode=EXCLUDED.operation_mode,
|
||||
gui_mode=EXCLUDED.gui_mode, platform=EXCLUDED.platform, version=EXCLUDED.version,
|
||||
version_status=EXCLUDED.version_status, management_mode=EXCLUDED.management_mode,
|
||||
installation_type=EXCLUDED.installation_type, activation_time=EXCLUDED.activation_time,
|
||||
total_jobs_count=EXCLUDED.total_jobs_count, running_jobs_count=EXCLUDED.running_jobs_count,
|
||||
success_jobs_count=EXCLUDED.success_jobs_count, synced_at=NOW(), updated_at=NOW()`,
|
||||
[
|
||||
a.instanceUid, orgUid, a.siteUid || null, a.managementAgentUid || null,
|
||||
a.name || a.instanceUid, a.agentPlatform || null, a.status || null, a.managementAgentStatus || null,
|
||||
a.operationMode || null, a.guiMode || null, a.platform || null,
|
||||
a.version || null, a.versionStatus || null, a.managementMode || null,
|
||||
a.installationType || null, a.activationTime ? new Date(a.activationTime) : null,
|
||||
a.totalJobsCount ?? 0, a.runningJobsCount ?? 0, a.successJobsCount ?? 0,
|
||||
]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async syncAlarms(): Promise<number> {
|
||||
const alarms = await this.client.getActiveAlarms();
|
||||
if (alarms.length === 0) return 0;
|
||||
|
||||
const knownOrgs = await postgresClient.query('SELECT instance_uid FROM veeam_organizations');
|
||||
const orgUids = new Set(knownOrgs.rows.map((r: any) => r.instance_uid));
|
||||
|
||||
let count = 0;
|
||||
for (const alarm of alarms) {
|
||||
const orgUid = alarm.object?.organizationUid && orgUids.has(alarm.object.organizationUid)
|
||||
? alarm.object.organizationUid : null;
|
||||
const lastStatus = alarm.lastActivation?.status || null;
|
||||
const resolved = lastStatus === 'Resolved';
|
||||
|
||||
await postgresClient.query(
|
||||
`INSERT INTO veeam_alarms (instance_uid, alarm_template_uid, repeat_count,
|
||||
object_uid, object_type, object_name, object_computer_name,
|
||||
organization_uid, location_uid, management_agent_uid,
|
||||
last_activation_uid, last_activation_time, last_activation_status,
|
||||
last_activation_message, last_activation_remark, area, resolved, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW())
|
||||
ON CONFLICT (instance_uid) DO UPDATE SET
|
||||
alarm_template_uid=EXCLUDED.alarm_template_uid, repeat_count=EXCLUDED.repeat_count,
|
||||
object_uid=EXCLUDED.object_uid, object_type=EXCLUDED.object_type,
|
||||
object_name=EXCLUDED.object_name, object_computer_name=EXCLUDED.object_computer_name,
|
||||
organization_uid=EXCLUDED.organization_uid, location_uid=EXCLUDED.location_uid,
|
||||
management_agent_uid=EXCLUDED.management_agent_uid,
|
||||
last_activation_uid=EXCLUDED.last_activation_uid,
|
||||
last_activation_time=EXCLUDED.last_activation_time,
|
||||
last_activation_status=EXCLUDED.last_activation_status,
|
||||
last_activation_message=EXCLUDED.last_activation_message,
|
||||
last_activation_remark=EXCLUDED.last_activation_remark,
|
||||
area=EXCLUDED.area, resolved=EXCLUDED.resolved,
|
||||
synced_at=NOW(), updated_at=NOW()`,
|
||||
[
|
||||
alarm.instanceUid, alarm.alarmTemplateUid || null, alarm.repeatCount ?? 0,
|
||||
alarm.object?.objectUid || alarm.object?.instanceUid || null,
|
||||
alarm.object?.type || null,
|
||||
alarm.object?.objectName || null,
|
||||
alarm.object?.computerName || null,
|
||||
orgUid,
|
||||
alarm.object?.locationUid || null,
|
||||
alarm.object?.managementAgentUid || null,
|
||||
alarm.lastActivation?.instanceUid || null,
|
||||
alarm.lastActivation?.time ? new Date(alarm.lastActivation.time) : null,
|
||||
lastStatus,
|
||||
alarm.lastActivation?.message || null,
|
||||
alarm.lastActivation?.remark || null,
|
||||
alarm.area || null,
|
||||
resolved,
|
||||
]
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,59 @@
|
|||
* Handles incoming webhooks from Autotask for real-time updates
|
||||
*/
|
||||
|
||||
import { createHmac } from 'crypto';
|
||||
import { postgresClient } from './postgres-client';
|
||||
import { AutotaskWebhookPayload, WebhookProcessingResult, WebhookLog, WebhookEventType, WebhookEntityType } from '../types/webhook';
|
||||
import { EntityType } from '../types/sync';
|
||||
import { mapAutotaskToDatabase } from '../utils/entity-mapper';
|
||||
import { getTableName } from '../utils/sync-helpers';
|
||||
import { getTableName, getAutotaskEntityName } from '../utils/sync-helpers';
|
||||
import { AutotaskClient } from './autotask-client';
|
||||
import { workflowEngine } from './workflow-engine';
|
||||
import { WorkflowEvent, TicketData } from '../types/workflow';
|
||||
|
||||
export class WebhookService {
|
||||
private _autotaskClient: AutotaskClient | null = null;
|
||||
|
||||
private getAutotaskClient(): AutotaskClient {
|
||||
if (!this._autotaskClient) {
|
||||
this._autotaskClient = new AutotaskClient({
|
||||
apiUrl: process.env.AUTOTASK_API_URL || '',
|
||||
username: process.env.AUTOTASK_USERNAME || '',
|
||||
password: process.env.AUTOTASK_SECRET || '',
|
||||
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
|
||||
});
|
||||
}
|
||||
return this._autotaskClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify webhook signature.
|
||||
* Autotask sends: x-hook-signature: sha1=<base64>
|
||||
* Computed as HMAC-SHA1 of the raw body using the secret key.
|
||||
*/
|
||||
verifySignature(rawBody: string, signatureHeader: string | null): boolean {
|
||||
const secret = process.env.AUTOTASK_WEBHOOK_SECRET;
|
||||
if (!secret) {
|
||||
console.warn('[WEBHOOK] No AUTOTASK_WEBHOOK_SECRET configured, skipping signature verification');
|
||||
return true;
|
||||
}
|
||||
if (!signatureHeader) {
|
||||
console.warn('[WEBHOOK] No x-hook-signature header in webhook request');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Header format: "sha1=<base64>"
|
||||
const signature = signatureHeader.startsWith('sha1=')
|
||||
? signatureHeader.slice(5)
|
||||
: signatureHeader;
|
||||
|
||||
const computed = createHmac('sha1', secret).update(rawBody).digest('base64');
|
||||
const match = computed === signature;
|
||||
if (!match) {
|
||||
console.warn(`[WEBHOOK] Signature mismatch: expected=${computed}, received=${signature}`);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
/**
|
||||
* Process an incoming webhook from Autotask
|
||||
*/
|
||||
|
|
@ -61,6 +107,13 @@ export class WebhookService {
|
|||
|
||||
console.log(`[WEBHOOK] Successfully processed ${payload.eventType} for ${payload.entityType} #${payload.entityId} in ${processingTime}ms`);
|
||||
|
||||
// Trigger workflow engine for new tickets (fire-and-forget)
|
||||
if (payload.entityType === WebhookEntityType.TICKETS && payload.eventType === WebhookEventType.CREATE) {
|
||||
this.triggerWorkflowEngine(payload).catch(err =>
|
||||
console.error('[WEBHOOK] Workflow engine error:', err)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
eventId: payload.eventId,
|
||||
|
|
@ -95,17 +148,28 @@ export class WebhookService {
|
|||
* Handle create or update events
|
||||
*/
|
||||
private async handleCreateOrUpdate(payload: AutotaskWebhookPayload): Promise<'created' | 'updated'> {
|
||||
// If webhook includes full entity data, use it
|
||||
if (payload.entity) {
|
||||
// If webhook includes full entity data, use it directly
|
||||
if (payload.entity && Object.keys(payload.entity).length > 2) {
|
||||
await this.upsertEntity(payload.entityType, payload.entity);
|
||||
return payload.eventType === WebhookEventType.CREATE ? 'created' : 'updated';
|
||||
}
|
||||
|
||||
// Otherwise, fetch the entity from Autotask API
|
||||
// Note: This requires the AutotaskClient to fetch individual entities
|
||||
// For now, we'll log and skip - can be enhanced later
|
||||
console.warn(`[WEBHOOK] Entity data not included in webhook, skipping upsert for ${payload.entityType} #${payload.entityId}`);
|
||||
return 'updated';
|
||||
// Fetch the full entity from Autotask API
|
||||
const internalEntityType = this.mapWebhookEntityType(payload.entityType);
|
||||
const autotaskEntityName = getAutotaskEntityName(internalEntityType);
|
||||
|
||||
console.log(`[WEBHOOK] Fetching ${autotaskEntityName} #${payload.entityId} from Autotask API`);
|
||||
|
||||
const client = this.getAutotaskClient();
|
||||
const entity = await client.getEntityById(autotaskEntityName, payload.entityId);
|
||||
|
||||
if (!entity) {
|
||||
console.warn(`[WEBHOOK] Entity ${autotaskEntityName} #${payload.entityId} not found in Autotask API`);
|
||||
return payload.eventType === WebhookEventType.CREATE ? 'created' : 'updated';
|
||||
}
|
||||
|
||||
await this.upsertEntity(payload.entityType, entity as Record<string, any>);
|
||||
return payload.eventType === WebhookEventType.CREATE ? 'created' : 'updated';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -263,21 +327,16 @@ export class WebhookService {
|
|||
}> {
|
||||
const query = `
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE status = 'processed') as processed,
|
||||
COUNT(*) FILTER (WHERE status = 'failed') as failed,
|
||||
COUNT(*) FILTER (WHERE status = 'pending') as pending,
|
||||
jsonb_object_agg(entity_type, entity_count) as by_entity_type
|
||||
FROM (
|
||||
SELECT
|
||||
entity_type,
|
||||
COUNT(*) as entity_count
|
||||
(SELECT COUNT(*) FROM webhook_logs WHERE received_at >= NOW() - INTERVAL '${hours} hours') as total,
|
||||
(SELECT COUNT(*) FROM webhook_logs WHERE received_at >= NOW() - INTERVAL '${hours} hours' AND status = 'processed') as processed,
|
||||
(SELECT COUNT(*) FROM webhook_logs WHERE received_at >= NOW() - INTERVAL '${hours} hours' AND status = 'failed') as failed,
|
||||
(SELECT COUNT(*) FROM webhook_logs WHERE received_at >= NOW() - INTERVAL '${hours} hours' AND status = 'pending') as pending,
|
||||
(SELECT COALESCE(jsonb_object_agg(entity_type, entity_count), '{}'::jsonb) FROM (
|
||||
SELECT entity_type, COUNT(*) as entity_count
|
||||
FROM webhook_logs
|
||||
WHERE received_at >= NOW() - INTERVAL '${hours} hours'
|
||||
GROUP BY entity_type
|
||||
) entity_counts,
|
||||
webhook_logs
|
||||
WHERE received_at >= NOW() - INTERVAL '${hours} hours'
|
||||
) ec) as by_entity_type
|
||||
`;
|
||||
|
||||
const result = await postgresClient.query(query);
|
||||
|
|
@ -309,6 +368,7 @@ export class WebhookService {
|
|||
const mapping: Record<WebhookEntityType, EntityType> = {
|
||||
[WebhookEntityType.COMPANIES]: EntityType.COMPANIES,
|
||||
[WebhookEntityType.TICKETS]: EntityType.TICKETS,
|
||||
[WebhookEntityType.TICKET_NOTES]: EntityType.TICKET_NOTES,
|
||||
[WebhookEntityType.TASKS]: EntityType.TASKS,
|
||||
[WebhookEntityType.PROJECTS]: EntityType.PROJECTS,
|
||||
[WebhookEntityType.TIME_ENTRIES]: EntityType.TIME_ENTRIES,
|
||||
|
|
@ -327,6 +387,45 @@ export class WebhookService {
|
|||
const entityType = this.mapWebhookEntityType(webhookType);
|
||||
return getTableName(entityType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger the workflow engine for a new ticket.
|
||||
* Runs asynchronously — does not block webhook response.
|
||||
*/
|
||||
private async triggerWorkflowEngine(payload: AutotaskWebhookPayload): Promise<void> {
|
||||
const event: WorkflowEvent = {
|
||||
trigger_event: 'ticket.created',
|
||||
entity_type: 'ticket',
|
||||
entity_id: payload.entityId,
|
||||
ticket_number: payload.fields?.ticketNumber || undefined,
|
||||
};
|
||||
|
||||
// If the webhook payload includes the full entity, build TicketData from it
|
||||
if (payload.entity) {
|
||||
event.ticket_data = {
|
||||
id: payload.entityId,
|
||||
ticket_number: payload.entity.ticketNumber || null,
|
||||
title: payload.entity.title || '',
|
||||
description: payload.entity.description || null,
|
||||
ticket_category: payload.entity.ticketCategory || null,
|
||||
ticket_type: payload.entity.ticketType || null,
|
||||
priority: payload.entity.priority || null,
|
||||
queue_id: payload.entity.queueID || null,
|
||||
issue_type: payload.entity.issueType || null,
|
||||
sub_issue_type: payload.entity.subIssueType || null,
|
||||
company_id: payload.entity.companyID || 0,
|
||||
contact_id: payload.entity.contactID || null,
|
||||
assigned_resource_id: payload.entity.assignedResourceID || null,
|
||||
creator_resource_id: payload.entity.creatorResourceID || null,
|
||||
person_id: payload.personId || null,
|
||||
status: payload.entity.status || null,
|
||||
source: payload.entity.source || null,
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[WEBHOOK] Triggering workflow engine for ticket ${payload.entityId}`);
|
||||
await workflowEngine.process(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
|
|
|
|||
954
lib/services/workflow-engine.ts
Normal file
|
|
@ -0,0 +1,954 @@
|
|||
/**
|
||||
* Workflow Engine
|
||||
* Orchestrates the full ticket triage pipeline:
|
||||
* 1. Check if enabled
|
||||
* 2. Run exclusion filter rules
|
||||
* 3. Run robotic classification
|
||||
* 4. Validate classification
|
||||
* 5. If needed → AI enhancement
|
||||
* 6. Re-validate after AI
|
||||
* 7. Delay (configurable)
|
||||
* 8. Write back to Autotask
|
||||
* 9. Create notes if applicable
|
||||
* 10. Log full execution
|
||||
*/
|
||||
|
||||
import { postgresClient } from './postgres-client';
|
||||
import { AutotaskClient } from './autotask-client';
|
||||
import { roboticClassifier } from './robotic-classifier';
|
||||
import { triageValidator } from './triage-validator';
|
||||
import { aiTriageService } from './ai-triage-service';
|
||||
import {
|
||||
WorkflowEvent,
|
||||
ExecutionResult,
|
||||
WorkflowSettings,
|
||||
TicketData,
|
||||
ClassificationResult,
|
||||
FieldChanges,
|
||||
ExecutionStepSummary,
|
||||
WorkflowRuleWithDetails,
|
||||
WorkflowCondition,
|
||||
StepName,
|
||||
ExecutionStatus,
|
||||
StepMethod,
|
||||
ConfidenceLevel,
|
||||
Branch,
|
||||
ClassificationMethod,
|
||||
} from '../types/workflow';
|
||||
|
||||
export class WorkflowEngine {
|
||||
private _autotaskClient: AutotaskClient | null = null;
|
||||
|
||||
private getAutotaskClient(): AutotaskClient {
|
||||
if (!this._autotaskClient) {
|
||||
this._autotaskClient = new AutotaskClient({
|
||||
apiUrl: process.env.AUTOTASK_API_URL || '',
|
||||
username: process.env.AUTOTASK_USERNAME || '',
|
||||
password: process.env.AUTOTASK_SECRET || '',
|
||||
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
|
||||
});
|
||||
}
|
||||
return this._autotaskClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load workflow settings from DB.
|
||||
*/
|
||||
async getSettings(): Promise<WorkflowSettings> {
|
||||
const result = await postgresClient.query(
|
||||
`SELECT key, value FROM workflow_settings`
|
||||
);
|
||||
|
||||
const raw: Record<string, any> = {};
|
||||
for (const row of result.rows) {
|
||||
try {
|
||||
raw[row.key] = JSON.parse(row.value);
|
||||
} catch {
|
||||
raw[row.key] = row.value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
workflow_engine_enabled: raw.workflow_engine_enabled ?? false,
|
||||
default_ai_provider: raw.default_ai_provider ?? 'openai',
|
||||
openai_api_key: raw.openai_api_key ?? '',
|
||||
openai_model: raw.openai_model ?? 'gpt-4o',
|
||||
anthropic_api_key: raw.anthropic_api_key ?? '',
|
||||
anthropic_model: raw.anthropic_model ?? 'claude-sonnet-4-20250514',
|
||||
ai_for_title_cleanup: raw.ai_for_title_cleanup ?? true,
|
||||
ai_for_description_rewrite: raw.ai_for_description_rewrite ?? true,
|
||||
ai_for_ambiguous_classification: raw.ai_for_ambiguous_classification ?? true,
|
||||
ai_for_troubleshooting: raw.ai_for_troubleshooting ?? true,
|
||||
autotask_update_delay_ms: Number(raw.autotask_update_delay_ms) || 30000,
|
||||
max_ai_retries: Number(raw.max_ai_retries) || 2,
|
||||
classification_confidence_threshold: raw.classification_confidence_threshold ?? 'medium',
|
||||
log_retention_days: Number(raw.log_retention_days) || 90,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point: process a workflow event.
|
||||
*/
|
||||
async process(event: WorkflowEvent): Promise<ExecutionResult> {
|
||||
const startTime = Date.now();
|
||||
const steps: ExecutionStepSummary[] = [];
|
||||
const fieldChanges: FieldChanges = {};
|
||||
let executionId: number = 0;
|
||||
let classificationMethod: ClassificationMethod = 'robotic';
|
||||
let branch: Branch = 'service_desk';
|
||||
|
||||
try {
|
||||
// 1. Check if engine is enabled
|
||||
const settings = await this.getSettings();
|
||||
if (!settings.workflow_engine_enabled) {
|
||||
console.log('[WORKFLOW] Engine is disabled, skipping');
|
||||
return this.buildSkippedResult(0, 'Engine disabled');
|
||||
}
|
||||
|
||||
// Create execution record
|
||||
executionId = await this.createExecution(event);
|
||||
|
||||
// 2. Load ticket data
|
||||
const ticket = event.ticket_data || await this.loadTicketData(event.entity_id);
|
||||
if (!ticket) {
|
||||
await this.updateExecution(executionId, 'failed', null, null, 'Ticket not found');
|
||||
return this.buildSkippedResult(executionId, 'Ticket not found');
|
||||
}
|
||||
|
||||
// 3. Run exclusion filter rules
|
||||
const filterStep = await this.runFilterRules(ticket, event.trigger_event, executionId);
|
||||
steps.push(filterStep);
|
||||
if (filterStep.status === 'completed' && filterStep.method === 'skipped') {
|
||||
await this.updateExecution(executionId, 'skipped', null, null);
|
||||
return {
|
||||
execution_id: executionId,
|
||||
status: 'skipped',
|
||||
classification_method: 'robotic',
|
||||
branch: 'service_desk',
|
||||
field_changes: {},
|
||||
steps,
|
||||
duration_ms: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Run robotic classification
|
||||
const classifyStart = Date.now();
|
||||
const classification = await roboticClassifier.classify(ticket);
|
||||
branch = (classification.branch?.value as Branch) || 'service_desk';
|
||||
|
||||
// Log classification steps
|
||||
const classificationSteps = this.buildClassificationSteps(classification, executionId, classifyStart);
|
||||
steps.push(...classificationSteps);
|
||||
|
||||
// 5. Validate classification
|
||||
const validationStep = await this.runValidation(classification, executionId);
|
||||
steps.push(validationStep);
|
||||
|
||||
// 6. AI Enhancement (if needed)
|
||||
let finalClassification = classification;
|
||||
const confidenceThreshold = settings.classification_confidence_threshold;
|
||||
|
||||
if (
|
||||
classification.needs_ai &&
|
||||
settings.ai_for_ambiguous_classification &&
|
||||
this.shouldUseAi(classification.overall_confidence, confidenceThreshold)
|
||||
) {
|
||||
classificationMethod = 'hybrid';
|
||||
|
||||
// AI for ambiguous classification
|
||||
const failedFields = classification.ai_reasons
|
||||
.filter(r => r.includes('No ') && r.includes('matched'))
|
||||
.map(r => {
|
||||
if (r.includes('ticket type')) return 'ticket_type';
|
||||
if (r.includes('issue type')) return 'issue_type';
|
||||
if (r.includes('priority')) return 'priority';
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (failedFields.length > 0) {
|
||||
const aiClassifyStep = await this.runAiClassification(
|
||||
ticket, failedFields, finalClassification, settings, executionId
|
||||
);
|
||||
steps.push(aiClassifyStep);
|
||||
}
|
||||
}
|
||||
|
||||
// AI for title cleanup
|
||||
if (
|
||||
settings.ai_for_title_cleanup &&
|
||||
classification.ai_reasons.some(r => r.includes('Title'))
|
||||
) {
|
||||
classificationMethod = classificationMethod === 'robotic' ? 'hybrid' : classificationMethod;
|
||||
const titleStep = await this.runAiTitleCleanup(ticket, settings, executionId);
|
||||
steps.push(titleStep);
|
||||
if (titleStep.status === 'completed' && (titleStep as any)._newTitle) {
|
||||
fieldChanges['title'] = { before: ticket.title, after: (titleStep as any)._newTitle };
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Build field changes for write-back
|
||||
this.buildFieldChanges(ticket, finalClassification, fieldChanges);
|
||||
|
||||
// 8. Delay before Autotask write-back
|
||||
if (Object.keys(fieldChanges).length > 0 && settings.autotask_update_delay_ms > 0) {
|
||||
await this.delay(settings.autotask_update_delay_ms);
|
||||
}
|
||||
|
||||
// 9. Write back to Autotask
|
||||
if (Object.keys(fieldChanges).length > 0) {
|
||||
const updateStep = await this.writeBackToAutotask(
|
||||
ticket.id, fieldChanges, executionId
|
||||
);
|
||||
steps.push(updateStep);
|
||||
}
|
||||
|
||||
// 10. Create troubleshooting note for incidents
|
||||
const ticketTypeValue = finalClassification.ticket_type?.value ?? ticket.ticket_type;
|
||||
if (
|
||||
Number(ticketTypeValue) === 2 &&
|
||||
settings.ai_for_troubleshooting
|
||||
) {
|
||||
classificationMethod = classificationMethod === 'robotic' ? 'hybrid' : classificationMethod;
|
||||
const noteStep = await this.runAiTroubleshooting(ticket, settings, executionId);
|
||||
steps.push(noteStep);
|
||||
}
|
||||
|
||||
// 11. Update execution record
|
||||
const duration = Date.now() - startTime;
|
||||
await this.updateExecution(executionId, 'completed', classificationMethod, branch);
|
||||
|
||||
console.log(
|
||||
`[WORKFLOW] Completed processing ticket #${ticket.ticket_number} ` +
|
||||
`(${classificationMethod}, ${branch}) in ${duration}ms`
|
||||
);
|
||||
|
||||
return {
|
||||
execution_id: executionId,
|
||||
status: 'completed',
|
||||
classification_method: classificationMethod,
|
||||
branch,
|
||||
field_changes: fieldChanges,
|
||||
steps,
|
||||
duration_ms: duration,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('[WORKFLOW] Pipeline error:', errorMessage);
|
||||
|
||||
if (executionId) {
|
||||
await this.updateExecution(executionId, 'failed', classificationMethod, branch, errorMessage);
|
||||
}
|
||||
|
||||
return {
|
||||
execution_id: executionId,
|
||||
status: 'failed',
|
||||
classification_method: classificationMethod,
|
||||
branch,
|
||||
field_changes: fieldChanges,
|
||||
steps,
|
||||
duration_ms: Date.now() - startTime,
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dry-run: process a ticket without writing back to Autotask.
|
||||
*/
|
||||
async dryRun(ticketId: number): Promise<ExecutionResult> {
|
||||
const settings = await this.getSettings();
|
||||
const ticket = await this.loadTicketData(ticketId);
|
||||
if (!ticket) {
|
||||
return this.buildSkippedResult(0, 'Ticket not found');
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const steps: ExecutionStepSummary[] = [];
|
||||
|
||||
// Run filter rules
|
||||
const filterStep = await this.runFilterRules(ticket, 'ticket.created', 0);
|
||||
steps.push(filterStep);
|
||||
|
||||
// Run classification
|
||||
const classification = await roboticClassifier.classify(ticket);
|
||||
const branch = (classification.branch?.value as Branch) || 'service_desk';
|
||||
const classificationSteps = this.buildClassificationSteps(classification, 0, Date.now());
|
||||
steps.push(...classificationSteps);
|
||||
|
||||
// Run validation
|
||||
const validation = await triageValidator.validate(classification);
|
||||
steps.push({
|
||||
step_name: 'validation',
|
||||
status: validation.is_valid ? 'completed' : 'failed',
|
||||
method: 'robotic',
|
||||
duration_ms: 0,
|
||||
});
|
||||
|
||||
// Build proposed field changes
|
||||
const fieldChanges: FieldChanges = {};
|
||||
this.buildFieldChanges(ticket, classification, fieldChanges);
|
||||
|
||||
return {
|
||||
execution_id: 0,
|
||||
status: filterStep.method === 'skipped' ? 'skipped' : 'completed',
|
||||
classification_method: 'robotic',
|
||||
branch,
|
||||
field_changes: fieldChanges,
|
||||
steps,
|
||||
duration_ms: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pipeline Steps
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Run workflow filter rules (exclusion/inclusion).
|
||||
*/
|
||||
private async runFilterRules(
|
||||
ticket: TicketData,
|
||||
triggerEvent: string,
|
||||
executionId: number
|
||||
): Promise<ExecutionStepSummary> {
|
||||
const stepStart = Date.now();
|
||||
|
||||
try {
|
||||
const rules = await this.loadWorkflowRules(triggerEvent);
|
||||
|
||||
for (const rule of rules) {
|
||||
if (this.evaluateConditions(rule.conditions, ticket)) {
|
||||
// Check if any action is 'skip'
|
||||
const skipAction = rule.actions.find(a => a.action_type === 'skip');
|
||||
if (skipAction) {
|
||||
const reason = skipAction.config?.reason || rule.name;
|
||||
console.log(`[WORKFLOW] Ticket #${ticket.ticket_number} skipped: ${reason}`);
|
||||
|
||||
if (executionId > 0) {
|
||||
await this.logStep(executionId, 'filter', 1, 'completed', 'skipped', {
|
||||
rule_name: rule.name,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
step_name: 'filter',
|
||||
status: 'completed',
|
||||
method: 'skipped',
|
||||
matched_rule: rule.name,
|
||||
duration_ms: Date.now() - stepStart,
|
||||
};
|
||||
}
|
||||
|
||||
if (rule.stop_processing) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (executionId > 0) {
|
||||
await this.logStep(executionId, 'filter', 1, 'completed', 'robotic', {
|
||||
result: 'passed_all_filters',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
step_name: 'filter',
|
||||
status: 'completed',
|
||||
method: 'robotic',
|
||||
duration_ms: Date.now() - stepStart,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[WORKFLOW] Filter rules error:', error);
|
||||
return {
|
||||
step_name: 'filter',
|
||||
status: 'failed',
|
||||
method: 'robotic',
|
||||
duration_ms: Date.now() - stepStart,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build step summaries from classification result.
|
||||
*/
|
||||
private buildClassificationSteps(
|
||||
classification: ClassificationResult,
|
||||
executionId: number,
|
||||
startTime: number
|
||||
): ExecutionStepSummary[] {
|
||||
const steps: ExecutionStepSummary[] = [];
|
||||
const entries: [StepName, any][] = [
|
||||
['branch_routing', classification.branch],
|
||||
['ticket_type', classification.ticket_type],
|
||||
['issue_classification', classification.issue_classification],
|
||||
['priority', classification.priority],
|
||||
['queue_routing', classification.queue],
|
||||
];
|
||||
|
||||
let order = 2;
|
||||
for (const [stepName, result] of entries) {
|
||||
steps.push({
|
||||
step_name: stepName,
|
||||
status: result ? 'completed' : 'completed',
|
||||
method: result?.method || 'robotic',
|
||||
confidence: result?.confidence,
|
||||
matched_rule: result?.matched_rule_name || (result ? undefined : 'No match'),
|
||||
duration_ms: 0,
|
||||
});
|
||||
|
||||
if (executionId > 0 && result) {
|
||||
this.logStep(executionId, stepName, order, 'completed', result.method, {
|
||||
field: result.field,
|
||||
value: result.value,
|
||||
confidence: result.confidence,
|
||||
matched_rule: result.matched_rule_name,
|
||||
}, result.matched_rule_id).catch(err =>
|
||||
console.error(`[WORKFLOW] Failed to log step ${stepName}:`, err)
|
||||
);
|
||||
}
|
||||
order++;
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run validation step.
|
||||
*/
|
||||
private async runValidation(
|
||||
classification: ClassificationResult,
|
||||
executionId: number
|
||||
): Promise<ExecutionStepSummary> {
|
||||
const stepStart = Date.now();
|
||||
const validation = await triageValidator.validate(classification);
|
||||
|
||||
if (executionId > 0) {
|
||||
await this.logStep(executionId, 'validation', 7, validation.is_valid ? 'completed' : 'failed', 'robotic', {
|
||||
is_valid: validation.is_valid,
|
||||
errors: validation.errors,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
step_name: 'validation',
|
||||
status: validation.is_valid ? 'completed' : 'failed',
|
||||
method: 'robotic',
|
||||
duration_ms: Date.now() - stepStart,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run AI classification for ambiguous fields.
|
||||
*/
|
||||
private async runAiClassification(
|
||||
ticket: TicketData,
|
||||
failedFields: string[],
|
||||
classification: ClassificationResult,
|
||||
settings: WorkflowSettings,
|
||||
executionId: number
|
||||
): Promise<ExecutionStepSummary> {
|
||||
const stepStart = Date.now();
|
||||
|
||||
try {
|
||||
const aiResult = await aiTriageService.classifyAmbiguous(ticket, failedFields, settings);
|
||||
|
||||
// Merge AI results into classification
|
||||
if (aiResult.classification) {
|
||||
if (aiResult.classification.issue_type != null && !classification.issue_classification) {
|
||||
classification.issue_classification = {
|
||||
field: 'issue_type',
|
||||
value: aiResult.classification.issue_type,
|
||||
field_2: 'sub_issue_type',
|
||||
value_2: aiResult.classification.sub_issue_type,
|
||||
confidence: 'medium',
|
||||
matched_rule_id: null,
|
||||
matched_rule_name: 'AI classification',
|
||||
method: 'ai',
|
||||
};
|
||||
}
|
||||
if (aiResult.classification.ticket_type != null && !classification.ticket_type) {
|
||||
classification.ticket_type = {
|
||||
field: 'ticket_type',
|
||||
value: aiResult.classification.ticket_type,
|
||||
confidence: 'medium',
|
||||
matched_rule_id: null,
|
||||
matched_rule_name: 'AI classification',
|
||||
method: 'ai',
|
||||
};
|
||||
}
|
||||
if (aiResult.classification.priority != null && !classification.priority) {
|
||||
classification.priority = {
|
||||
field: 'priority',
|
||||
value: aiResult.classification.priority,
|
||||
confidence: 'medium',
|
||||
matched_rule_id: null,
|
||||
matched_rule_name: 'AI classification',
|
||||
method: 'ai',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (executionId > 0) {
|
||||
await this.logStep(executionId, 'ai_classification', 8, 'completed', 'ai', {
|
||||
failed_fields: failedFields,
|
||||
ai_result: aiResult.classification,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
step_name: 'ai_classification',
|
||||
status: 'completed',
|
||||
method: 'ai',
|
||||
duration_ms: Date.now() - stepStart,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[WORKFLOW] AI classification error:', error);
|
||||
|
||||
if (executionId > 0) {
|
||||
await this.logStep(executionId, 'ai_classification', 8, 'failed', 'ai', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
step_name: 'ai_classification',
|
||||
status: 'failed',
|
||||
method: 'ai',
|
||||
duration_ms: Date.now() - stepStart,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run AI title cleanup.
|
||||
*/
|
||||
private async runAiTitleCleanup(
|
||||
ticket: TicketData,
|
||||
settings: WorkflowSettings,
|
||||
executionId: number
|
||||
): Promise<ExecutionStepSummary & { _newTitle?: string }> {
|
||||
const stepStart = Date.now();
|
||||
|
||||
try {
|
||||
const newTitle = await aiTriageService.cleanupTitle(ticket, settings);
|
||||
|
||||
if (executionId > 0) {
|
||||
await this.logStep(executionId, 'ai_title', 9, 'completed', 'ai', {
|
||||
original: ticket.title,
|
||||
cleaned: newTitle,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
step_name: 'ai_title',
|
||||
status: 'completed',
|
||||
method: 'ai',
|
||||
duration_ms: Date.now() - stepStart,
|
||||
_newTitle: newTitle,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[WORKFLOW] AI title cleanup error:', error);
|
||||
return {
|
||||
step_name: 'ai_title',
|
||||
status: 'failed',
|
||||
method: 'ai',
|
||||
duration_ms: Date.now() - stepStart,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run AI troubleshooting steps generation and create a ticket note.
|
||||
*/
|
||||
private async runAiTroubleshooting(
|
||||
ticket: TicketData,
|
||||
settings: WorkflowSettings,
|
||||
executionId: number
|
||||
): Promise<ExecutionStepSummary> {
|
||||
const stepStart = Date.now();
|
||||
|
||||
try {
|
||||
const steps = await aiTriageService.generateTroubleshootingSteps(ticket, settings);
|
||||
if (!steps) {
|
||||
return {
|
||||
step_name: 'ai_troubleshooting',
|
||||
status: 'completed',
|
||||
method: 'skipped',
|
||||
duration_ms: Date.now() - stepStart,
|
||||
};
|
||||
}
|
||||
|
||||
// Create ticket note in Autotask
|
||||
const client = this.getAutotaskClient();
|
||||
await client.createEntity('TicketNotes', {
|
||||
ticketID: ticket.id,
|
||||
title: 'Troubleshooting Steps (Auto-Generated)',
|
||||
description: steps,
|
||||
noteType: 1, // Internal
|
||||
publish: 1,
|
||||
});
|
||||
|
||||
if (executionId > 0) {
|
||||
await this.logStep(executionId, 'create_note', 12, 'completed', 'ai', {
|
||||
note_type: 'troubleshooting_steps',
|
||||
content_length: steps.length,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
step_name: 'create_note',
|
||||
status: 'completed',
|
||||
method: 'ai',
|
||||
duration_ms: Date.now() - stepStart,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[WORKFLOW] Troubleshooting note error:', error);
|
||||
return {
|
||||
step_name: 'create_note',
|
||||
status: 'failed',
|
||||
method: 'ai',
|
||||
duration_ms: Date.now() - stepStart,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write classified fields back to Autotask.
|
||||
*/
|
||||
private async writeBackToAutotask(
|
||||
ticketId: number,
|
||||
fieldChanges: FieldChanges,
|
||||
executionId: number
|
||||
): Promise<ExecutionStepSummary> {
|
||||
const stepStart = Date.now();
|
||||
|
||||
try {
|
||||
// Build Autotask update payload (camelCase field names)
|
||||
const updatePayload: Record<string, any> = { id: ticketId };
|
||||
const fieldMap: Record<string, string> = {
|
||||
ticket_type: 'ticketType',
|
||||
issue_type: 'issueType',
|
||||
sub_issue_type: 'subIssueType',
|
||||
priority: 'priority',
|
||||
queue_id: 'queueID',
|
||||
title: 'title',
|
||||
};
|
||||
|
||||
for (const [field, change] of Object.entries(fieldChanges)) {
|
||||
const autotaskField = fieldMap[field] || field;
|
||||
updatePayload[autotaskField] = change.after;
|
||||
}
|
||||
|
||||
const client = this.getAutotaskClient();
|
||||
await client.updateTicket(ticketId, updatePayload);
|
||||
|
||||
// Also update local DB
|
||||
const dbUpdates: Record<string, any> = {};
|
||||
for (const [field, change] of Object.entries(fieldChanges)) {
|
||||
dbUpdates[field] = change.after;
|
||||
}
|
||||
if (Object.keys(dbUpdates).length > 0) {
|
||||
const setClauses = Object.keys(dbUpdates).map((k, i) => `${k} = $${i + 2}`);
|
||||
await postgresClient.query(
|
||||
`UPDATE tickets SET ${setClauses.join(', ')}, updated_at = NOW() WHERE id = $1`,
|
||||
[ticketId, ...Object.values(dbUpdates)]
|
||||
);
|
||||
}
|
||||
|
||||
if (executionId > 0) {
|
||||
await this.logStep(executionId, 'autotask_update', 11, 'completed', 'robotic', {
|
||||
fields_updated: Object.keys(fieldChanges),
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[WORKFLOW] Updated ticket ${ticketId} in Autotask: ${Object.keys(fieldChanges).join(', ')}`);
|
||||
|
||||
return {
|
||||
step_name: 'autotask_update',
|
||||
status: 'completed',
|
||||
method: 'robotic',
|
||||
duration_ms: Date.now() - stepStart,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[WORKFLOW] Autotask update error:', error);
|
||||
|
||||
if (executionId > 0) {
|
||||
await this.logStep(executionId, 'autotask_update', 11, 'failed', 'robotic', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
step_name: 'autotask_update',
|
||||
status: 'failed',
|
||||
method: 'robotic',
|
||||
duration_ms: Date.now() - stepStart,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Load ticket data from DB.
|
||||
*/
|
||||
private async loadTicketData(ticketId: number): Promise<TicketData | null> {
|
||||
const result = await postgresClient.query(
|
||||
`SELECT id, ticket_number, title, description, ticket_category, ticket_type,
|
||||
priority, queue_id, issue_type, sub_issue_type, company_id, contact_id,
|
||||
assigned_resource_id, status, source
|
||||
FROM tickets WHERE id = $1`,
|
||||
[ticketId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) return null;
|
||||
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
...row,
|
||||
// Try to extract device/policy info from title/description for queue routing
|
||||
device_name: this.extractDeviceName(row.title, row.description),
|
||||
policy_name: this.extractPolicyName(row.description),
|
||||
creator_resource_id: null, // Not stored on tickets table — comes from webhook
|
||||
person_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract device name patterns from text (e.g., DT-1234, LT-5678).
|
||||
*/
|
||||
private extractDeviceName(title: string | null, description: string | null): string | null {
|
||||
const text = [title, description].filter(Boolean).join(' ');
|
||||
const match = text.match(/\b(DT|LT|SP|SRV|VM)-?\d{2,}/i);
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract policy name from description (e.g., "Policy: Windows Workstation").
|
||||
*/
|
||||
private extractPolicyName(description: string | null): string | null {
|
||||
if (!description) return null;
|
||||
const match = description.match(/(?:policy|monitoring policy)[:\s]+([^\n]+)/i);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load workflow filter rules with conditions and actions.
|
||||
*/
|
||||
private async loadWorkflowRules(triggerEvent: string): Promise<WorkflowRuleWithDetails[]> {
|
||||
const rulesResult = await postgresClient.query(
|
||||
`SELECT * FROM workflow_rules WHERE is_active = true AND trigger_event = $1 ORDER BY sort_order`,
|
||||
[triggerEvent]
|
||||
);
|
||||
|
||||
const rules: WorkflowRuleWithDetails[] = [];
|
||||
for (const rule of rulesResult.rows) {
|
||||
const [conditionsResult, actionsResult] = await Promise.all([
|
||||
postgresClient.query(`SELECT * FROM workflow_conditions WHERE rule_id = $1`, [rule.id]),
|
||||
postgresClient.query(`SELECT * FROM workflow_actions WHERE rule_id = $1 ORDER BY sort_order`, [rule.id]),
|
||||
]);
|
||||
|
||||
rules.push({
|
||||
...rule,
|
||||
conditions: conditionsResult.rows,
|
||||
actions: actionsResult.rows,
|
||||
});
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate workflow conditions against ticket data.
|
||||
* AND within condition_group, OR between groups.
|
||||
*/
|
||||
private evaluateConditions(conditions: WorkflowCondition[], ticket: TicketData): boolean {
|
||||
if (conditions.length === 0) return true;
|
||||
|
||||
// Group conditions by condition_group
|
||||
const groups = new Map<number, WorkflowCondition[]>();
|
||||
for (const cond of conditions) {
|
||||
const group = groups.get(cond.condition_group) || [];
|
||||
group.push(cond);
|
||||
groups.set(cond.condition_group, group);
|
||||
}
|
||||
|
||||
// OR between groups: at least one group must pass
|
||||
for (const [, groupConditions] of groups) {
|
||||
const groupPasses = groupConditions.every(cond => this.evaluateCondition(cond, ticket));
|
||||
if (groupPasses) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single workflow condition.
|
||||
*/
|
||||
private evaluateCondition(condition: WorkflowCondition, ticket: TicketData): boolean {
|
||||
const fieldValue = this.getTicketFieldValue(condition.field, ticket);
|
||||
|
||||
switch (condition.operator) {
|
||||
case 'equals':
|
||||
return this.isEqual(fieldValue, condition.value);
|
||||
case 'not_equals':
|
||||
return !this.isEqual(fieldValue, condition.value);
|
||||
case 'in':
|
||||
return this.isIn(fieldValue, condition.value);
|
||||
case 'not_in':
|
||||
return !this.isIn(fieldValue, condition.value);
|
||||
case 'contains':
|
||||
return fieldValue != null && String(fieldValue).toLowerCase().includes(String(condition.value).toLowerCase());
|
||||
case 'not_contains':
|
||||
return fieldValue == null || !String(fieldValue).toLowerCase().includes(String(condition.value).toLowerCase());
|
||||
case 'regex':
|
||||
try {
|
||||
return fieldValue != null && new RegExp(String(condition.value), 'i').test(String(fieldValue));
|
||||
} catch { return false; }
|
||||
case 'gt':
|
||||
return fieldValue != null && Number(fieldValue) > Number(condition.value);
|
||||
case 'lt':
|
||||
return fieldValue != null && Number(fieldValue) < Number(condition.value);
|
||||
case 'is_null':
|
||||
return fieldValue == null;
|
||||
case 'is_not_null':
|
||||
return fieldValue != null;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private getTicketFieldValue(field: string, ticket: TicketData): any {
|
||||
return (ticket as any)[field] ?? null;
|
||||
}
|
||||
|
||||
private isEqual(a: any, b: any): boolean {
|
||||
if (a == null && b == null) return true;
|
||||
if (a == null || b == null) return false;
|
||||
return String(a) === String(b);
|
||||
}
|
||||
|
||||
private isIn(value: any, list: any): boolean {
|
||||
if (value == null) return false;
|
||||
const arr = Array.isArray(list) ? list : [list];
|
||||
return arr.some(v => String(v) === String(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build field changes from classification result.
|
||||
*/
|
||||
private buildFieldChanges(
|
||||
ticket: TicketData,
|
||||
classification: ClassificationResult,
|
||||
fieldChanges: FieldChanges
|
||||
): void {
|
||||
if (classification.ticket_type?.value != null && classification.ticket_type.value !== ticket.ticket_type) {
|
||||
fieldChanges['ticket_type'] = { before: ticket.ticket_type, after: Number(classification.ticket_type.value) };
|
||||
}
|
||||
|
||||
if (classification.issue_classification?.value != null && Number(classification.issue_classification.value) !== ticket.issue_type) {
|
||||
fieldChanges['issue_type'] = { before: ticket.issue_type, after: Number(classification.issue_classification.value) };
|
||||
}
|
||||
|
||||
if (classification.issue_classification?.value_2 != null && Number(classification.issue_classification.value_2) !== ticket.sub_issue_type) {
|
||||
fieldChanges['sub_issue_type'] = { before: ticket.sub_issue_type, after: Number(classification.issue_classification.value_2) };
|
||||
}
|
||||
|
||||
if (classification.priority?.value != null && Number(classification.priority.value) !== ticket.priority) {
|
||||
fieldChanges['priority'] = { before: ticket.priority, after: Number(classification.priority.value) };
|
||||
}
|
||||
|
||||
if (classification.queue?.value != null && Number(classification.queue.value) !== ticket.queue_id) {
|
||||
fieldChanges['queue_id'] = { before: ticket.queue_id, after: Number(classification.queue.value) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if AI should be used based on confidence.
|
||||
*/
|
||||
private shouldUseAi(confidence: ConfidenceLevel, threshold: ConfidenceLevel): boolean {
|
||||
const levels: Record<ConfidenceLevel, number> = { high: 3, medium: 2, low: 1 };
|
||||
return levels[confidence] < levels[threshold];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workflow execution record.
|
||||
*/
|
||||
private async createExecution(event: WorkflowEvent): Promise<number> {
|
||||
const result = await postgresClient.query(
|
||||
`INSERT INTO workflow_executions (trigger_event, entity_type, entity_id, ticket_number, status)
|
||||
VALUES ($1, $2, $3, $4, 'running')
|
||||
RETURNING id`,
|
||||
[event.trigger_event, event.entity_type, event.entity_id, event.ticket_number || null]
|
||||
);
|
||||
return result.rows[0].id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update execution status.
|
||||
*/
|
||||
private async updateExecution(
|
||||
executionId: number,
|
||||
status: ExecutionStatus,
|
||||
method: ClassificationMethod | null,
|
||||
branch: Branch | null,
|
||||
errorMessage?: string
|
||||
): Promise<void> {
|
||||
await postgresClient.query(
|
||||
`UPDATE workflow_executions
|
||||
SET status = $1, classification_method = $2, branch = $3,
|
||||
completed_at = NOW(), duration_ms = EXTRACT(EPOCH FROM (NOW() - started_at)) * 1000,
|
||||
error_message = $4
|
||||
WHERE id = $5`,
|
||||
[status, method, branch, errorMessage || null, executionId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an execution step.
|
||||
*/
|
||||
private async logStep(
|
||||
executionId: number,
|
||||
stepName: string,
|
||||
stepOrder: number,
|
||||
status: ExecutionStatus,
|
||||
method: StepMethod,
|
||||
outputData?: Record<string, any>,
|
||||
classificationRuleId?: number | null
|
||||
): Promise<void> {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO workflow_execution_steps
|
||||
(execution_id, step_name, step_order, status, method, output_data, classification_rule_id, completed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`,
|
||||
[executionId, stepName, stepOrder, status, method, outputData ? JSON.stringify(outputData) : null, classificationRuleId || null]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a skipped execution result.
|
||||
*/
|
||||
private buildSkippedResult(executionId: number, reason: string): ExecutionResult {
|
||||
return {
|
||||
execution_id: executionId,
|
||||
status: 'skipped',
|
||||
classification_method: 'robotic',
|
||||
branch: 'service_desk',
|
||||
field_changes: {},
|
||||
steps: [],
|
||||
duration_ms: 0,
|
||||
error: reason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay for a specified number of milliseconds.
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const workflowEngine = new WorkflowEngine();
|
||||
|
|
@ -140,6 +140,19 @@ export interface Company {
|
|||
competitorID?: number;
|
||||
}
|
||||
|
||||
export interface TicketNote {
|
||||
id: number;
|
||||
ticketID: number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
noteType?: number;
|
||||
publish?: number;
|
||||
creatorResourceID?: number;
|
||||
creatorType?: number;
|
||||
lastActivityDate?: string;
|
||||
createDateTime?: string;
|
||||
}
|
||||
|
||||
export interface ConfigurationItem {
|
||||
id: number;
|
||||
companyID: number;
|
||||
|
|
|
|||
|
|
@ -435,6 +435,13 @@ export interface SyncHistoryRecord {
|
|||
triggered_by?: string | null;
|
||||
}
|
||||
|
||||
// Device Lifecycle Policy entity
|
||||
export interface DeviceLifecyclePolicy extends AuditFields {
|
||||
id: number;
|
||||
device_type: string;
|
||||
expected_months: number;
|
||||
}
|
||||
|
||||
// Time Entry entity
|
||||
export interface TimeEntry extends AuditFields {
|
||||
id: number;
|
||||
|
|
@ -492,9 +499,9 @@ export type Entity =
|
|||
| IssueType
|
||||
| SubIssueType
|
||||
| WorkType
|
||||
| TimeEntry;
|
||||
| TimeEntry
|
||||
| DeviceLifecyclePolicy;
|
||||
|
||||
// Table name type
|
||||
export type TableName =
|
||||
| 'companies'
|
||||
| 'resources'
|
||||
|
|
@ -510,4 +517,5 @@ export type TableName =
|
|||
| 'sub_issue_types'
|
||||
| 'work_types'
|
||||
| 'time_entries'
|
||||
| 'sync_history';
|
||||
| 'sync_history'
|
||||
| 'device_lifecycle_policies';
|
||||
|
|
|
|||
|
|
@ -62,28 +62,72 @@ export interface DattoRMMDevice {
|
|||
}
|
||||
|
||||
export interface DattoRMMSite {
|
||||
id: string;
|
||||
id: number;
|
||||
uid: string;
|
||||
accountUid?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
notes: string;
|
||||
notes: string | null;
|
||||
onDemand: boolean;
|
||||
splashtopAutoInstall?: boolean;
|
||||
proxySettings?: {
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
} | null;
|
||||
devicesStatus?: {
|
||||
numberOfDevices: number;
|
||||
numberOfOnlineDevices: number;
|
||||
numberOfOfflineDevices: number;
|
||||
};
|
||||
autotaskCompanyName?: string;
|
||||
autotaskCompanyId?: string;
|
||||
portalUrl?: string;
|
||||
devices?: DattoRMMDevice[];
|
||||
}
|
||||
|
||||
export interface DattoRMMAlert {
|
||||
alertUid: string;
|
||||
priority: string;
|
||||
diagnostics: string | null;
|
||||
resolved: boolean;
|
||||
resolvedBy: string | null;
|
||||
resolvedOn: number | null;
|
||||
muted: boolean;
|
||||
ticketNumber: string | null;
|
||||
timestamp: number;
|
||||
alertMonitorInfo?: {
|
||||
sendsEmails: boolean;
|
||||
createsTicket: boolean;
|
||||
};
|
||||
alertContext?: {
|
||||
'@class': string;
|
||||
[key: string]: any;
|
||||
};
|
||||
alertSourceInfo?: {
|
||||
deviceUid: string;
|
||||
deviceName: string;
|
||||
siteUid: string;
|
||||
siteName: string;
|
||||
};
|
||||
responseActions?: Array<{
|
||||
actionTime: number;
|
||||
actionType: string;
|
||||
description: string;
|
||||
actionReference: string | null;
|
||||
actionReferenceInt: string | null;
|
||||
}> | null;
|
||||
autoresolveMins?: number;
|
||||
}
|
||||
|
||||
export interface DattoRMMApiResponse<T> {
|
||||
items?: T[];
|
||||
item?: T;
|
||||
pageDetails?: {
|
||||
page: number;
|
||||
perPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
count: number;
|
||||
totalCount?: number;
|
||||
prevPageUrl: string | null;
|
||||
nextPageUrl: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,11 +14,15 @@ export enum EntityType {
|
|||
ISSUE_TYPES = 'issue_types',
|
||||
SUB_ISSUE_TYPES = 'sub_issue_types',
|
||||
WORK_TYPES = 'work_types',
|
||||
QUEUES = 'queues',
|
||||
PRIORITIES = 'priorities',
|
||||
TICKET_CATEGORIES = 'ticket_categories',
|
||||
BILLING_ITEMS = 'billing_items',
|
||||
CONFIGURATION_ITEMS = 'configuration_items',
|
||||
CONTACTS = 'contacts',
|
||||
CONTRACTS = 'contracts',
|
||||
TIME_ENTRIES = 'time_entries',
|
||||
TICKET_NOTES = 'ticket_notes',
|
||||
}
|
||||
|
||||
// Sync operation types
|
||||
|
|
@ -151,6 +155,9 @@ export const ENTITY_DEPENDENCIES: Record<EntityType, EntityType[]> = {
|
|||
[EntityType.ISSUE_TYPES]: [], // No dependencies
|
||||
[EntityType.SUB_ISSUE_TYPES]: [], // No dependencies
|
||||
[EntityType.WORK_TYPES]: [], // No dependencies
|
||||
[EntityType.QUEUES]: [], // No dependencies
|
||||
[EntityType.PRIORITIES]: [], // No dependencies
|
||||
[EntityType.TICKET_CATEGORIES]: [], // No dependencies
|
||||
[EntityType.CONTACTS]: [EntityType.COMPANIES], // Depends on companies
|
||||
[EntityType.PROJECTS]: [EntityType.COMPANIES, EntityType.RESOURCES], // Depends on companies and resources
|
||||
[EntityType.TICKETS]: [EntityType.COMPANIES, EntityType.RESOURCES, EntityType.CONTACTS], // Depends on companies, resources, contacts
|
||||
|
|
@ -159,6 +166,7 @@ export const ENTITY_DEPENDENCIES: Record<EntityType, EntityType[]> = {
|
|||
[EntityType.CONTRACTS]: [EntityType.COMPANIES, EntityType.CONTACTS], // Depends on companies and contacts
|
||||
[EntityType.BILLING_ITEMS]: [EntityType.COMPANIES, EntityType.TASKS, EntityType.TICKETS, EntityType.PROJECTS], // Depends on multiple entities
|
||||
[EntityType.TIME_ENTRIES]: [EntityType.COMPANIES, EntityType.RESOURCES, EntityType.CONTACTS, EntityType.PROJECTS, EntityType.TASKS, EntityType.TICKETS], // Depends on many entities
|
||||
[EntityType.TICKET_NOTES]: [EntityType.TICKETS], // Depends on tickets
|
||||
};
|
||||
|
||||
// Autotask API field names (for incremental sync)
|
||||
|
|
|
|||
|
|
@ -367,3 +367,57 @@ export interface VeeamComplianceSummary {
|
|||
backedUpNotContracted: number;
|
||||
computedAt: Date | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// New entity types: Backup Agents + Alarms
|
||||
// ============================================================================
|
||||
|
||||
export interface VspcBackupAgent {
|
||||
instanceUid: string;
|
||||
organizationUid: string;
|
||||
siteUid?: string;
|
||||
managementAgentUid?: string;
|
||||
name: string;
|
||||
agentPlatform: string;
|
||||
status: string;
|
||||
managementAgentStatus: string;
|
||||
operationMode: string;
|
||||
guiMode?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
versionStatus?: string;
|
||||
managementMode?: string;
|
||||
installationType?: string;
|
||||
activationTime?: string;
|
||||
totalJobsCount: number;
|
||||
runningJobsCount: number;
|
||||
successJobsCount: number;
|
||||
}
|
||||
|
||||
export interface VspcAlarmActivation {
|
||||
instanceUid: string;
|
||||
time: string;
|
||||
status: string;
|
||||
message: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface VspcAlarmObject {
|
||||
instanceUid: string;
|
||||
type: string;
|
||||
organizationUid?: string;
|
||||
locationUid?: string;
|
||||
managementAgentUid?: string;
|
||||
computerName?: string;
|
||||
objectUid?: string;
|
||||
objectName?: string;
|
||||
}
|
||||
|
||||
export interface VspcAlarm {
|
||||
instanceUid: string;
|
||||
alarmTemplateUid: string;
|
||||
repeatCount: number;
|
||||
object: VspcAlarmObject;
|
||||
lastActivation: VspcAlarmActivation;
|
||||
area?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export enum WebhookEventType {
|
|||
export enum WebhookEntityType {
|
||||
COMPANIES = 'Companies',
|
||||
TICKETS = 'Tickets',
|
||||
TICKET_NOTES = 'TicketNotes',
|
||||
TASKS = 'Tasks',
|
||||
PROJECTS = 'Projects',
|
||||
TIME_ENTRIES = 'TimeEntries',
|
||||
|
|
@ -27,43 +28,125 @@ export enum WebhookEntityType {
|
|||
}
|
||||
|
||||
/**
|
||||
* Autotask webhook payload structure
|
||||
* Autotask entities that support webhooks via their REST API
|
||||
*/
|
||||
export const WEBHOOK_SUPPORTED_ENTITIES: WebhookEntityType[] = [
|
||||
WebhookEntityType.COMPANIES,
|
||||
WebhookEntityType.CONTACTS,
|
||||
WebhookEntityType.CONFIGURATION_ITEMS,
|
||||
WebhookEntityType.TICKETS,
|
||||
WebhookEntityType.TICKET_NOTES,
|
||||
];
|
||||
|
||||
/**
|
||||
* Autotask webhook registration request
|
||||
*/
|
||||
export interface AutotaskWebhookRegistration {
|
||||
IsActive: boolean;
|
||||
DeactivationUrl: string;
|
||||
IsSubscribedToCreateEvents?: boolean;
|
||||
IsSubscribedToUpdateEvents?: boolean;
|
||||
IsSubscribedToDeleteEvents?: boolean;
|
||||
Name: string;
|
||||
SecretKey: string;
|
||||
SendThresholdExceededNotification: boolean;
|
||||
WebhookUrl: string;
|
||||
NotificationEmailAddress: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Autotask webhook field trigger
|
||||
*/
|
||||
export interface AutotaskWebhookFieldTrigger {
|
||||
FieldID: number;
|
||||
IsDisplayAlwaysField: boolean;
|
||||
IsSubscribedField: boolean;
|
||||
WebhookID: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Autotask webhook registration result
|
||||
*/
|
||||
export interface AutotaskWebhookRegistrationResult {
|
||||
entityType: WebhookEntityType;
|
||||
webhookId: number;
|
||||
fieldsRegistered: number;
|
||||
excludedResources: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw Autotask webhook payload as actually sent by Autotask
|
||||
*/
|
||||
export interface AutotaskRawWebhookPayload {
|
||||
Action: string; // "Create", "Update", "Delete"
|
||||
Guid: string; // Unique event GUID
|
||||
EntityType: string; // Singular: "Ticket", "Company", "Contact", "ConfigurationItem", "TicketNote"
|
||||
Id: number; // Entity ID
|
||||
Fields?: Record<string, string>; // Changed/created fields
|
||||
EventTime: string; // ISO timestamp
|
||||
SequenceNumber?: number;
|
||||
PersonId?: number; // Resource who triggered the event
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized webhook payload used internally
|
||||
*/
|
||||
export interface AutotaskWebhookPayload {
|
||||
/**
|
||||
* Unique identifier for the webhook event
|
||||
*/
|
||||
eventId: string;
|
||||
|
||||
/**
|
||||
* Type of event (create, update, delete)
|
||||
*/
|
||||
eventType: WebhookEventType;
|
||||
|
||||
/**
|
||||
* Entity type that triggered the webhook
|
||||
*/
|
||||
entityType: WebhookEntityType;
|
||||
|
||||
/**
|
||||
* ID of the entity that changed
|
||||
*/
|
||||
entityId: number;
|
||||
|
||||
/**
|
||||
* Timestamp when the event occurred
|
||||
*/
|
||||
eventTimestamp: string;
|
||||
|
||||
/**
|
||||
* Optional: Full entity data (if configured in webhook)
|
||||
*/
|
||||
fields?: Record<string, string>;
|
||||
entity?: Record<string, any>;
|
||||
personId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional: Previous values for update events
|
||||
* Map Autotask singular EntityType to our WebhookEntityType enum
|
||||
*/
|
||||
previousValues?: Record<string, any>;
|
||||
const ENTITY_TYPE_MAP: Record<string, WebhookEntityType> = {
|
||||
'Company': WebhookEntityType.COMPANIES,
|
||||
'Contact': WebhookEntityType.CONTACTS,
|
||||
'ConfigurationItem': WebhookEntityType.CONFIGURATION_ITEMS,
|
||||
'Ticket': WebhookEntityType.TICKETS,
|
||||
'TicketNote': WebhookEntityType.TICKET_NOTES,
|
||||
};
|
||||
|
||||
/**
|
||||
* Map Autotask Action string to our WebhookEventType enum
|
||||
*/
|
||||
const ACTION_MAP: Record<string, WebhookEventType> = {
|
||||
'Create': WebhookEventType.CREATE,
|
||||
'Update': WebhookEventType.UPDATE,
|
||||
'Delete': WebhookEventType.DELETE,
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a raw Autotask webhook payload into our internal format
|
||||
*/
|
||||
export function normalizeWebhookPayload(raw: AutotaskRawWebhookPayload): AutotaskWebhookPayload {
|
||||
const entityType = ENTITY_TYPE_MAP[raw.EntityType];
|
||||
if (!entityType) {
|
||||
throw new Error(`Unknown Autotask EntityType: ${raw.EntityType}`);
|
||||
}
|
||||
|
||||
const eventType = ACTION_MAP[raw.Action];
|
||||
if (!eventType) {
|
||||
throw new Error(`Unknown Autotask Action: ${raw.Action}`);
|
||||
}
|
||||
|
||||
return {
|
||||
eventId: raw.Guid,
|
||||
eventType,
|
||||
entityType,
|
||||
entityId: raw.Id,
|
||||
eventTimestamp: raw.EventTime,
|
||||
fields: raw.Fields,
|
||||
personId: raw.PersonId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
427
lib/types/workflow.ts
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
/**
|
||||
* Workflow Engine Types
|
||||
* TypeScript definitions for the ticket triage workflow engine
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Enums & Constants
|
||||
// ============================================================================
|
||||
|
||||
export type RuleType =
|
||||
| 'branch_routing'
|
||||
| 'ticket_type'
|
||||
| 'issue_classification'
|
||||
| 'priority'
|
||||
| 'queue_routing';
|
||||
|
||||
export type MatchField =
|
||||
| 'title'
|
||||
| 'description'
|
||||
| 'title_or_description'
|
||||
| 'ticket_category'
|
||||
| 'policy_name'
|
||||
| 'device_name'
|
||||
| 'creator_resource_id'
|
||||
| 'priority'
|
||||
| 'ticket_type'
|
||||
| 'person_id'
|
||||
| 'company_id';
|
||||
|
||||
export type MatchOperator =
|
||||
| 'contains'
|
||||
| 'starts_with'
|
||||
| 'regex'
|
||||
| 'equals'
|
||||
| 'in'
|
||||
| 'not_in';
|
||||
|
||||
export type ConditionOperator =
|
||||
| 'equals'
|
||||
| 'not_equals'
|
||||
| 'in'
|
||||
| 'not_in'
|
||||
| 'contains'
|
||||
| 'not_contains'
|
||||
| 'regex'
|
||||
| 'gt'
|
||||
| 'lt'
|
||||
| 'is_null'
|
||||
| 'is_not_null';
|
||||
|
||||
export type ConfidenceLevel = 'high' | 'medium' | 'low';
|
||||
|
||||
export type Branch = 'service_desk' | 'noc' | 'soc';
|
||||
|
||||
export type ClassificationMethod = 'robotic' | 'ai' | 'hybrid';
|
||||
|
||||
export type ExecutionStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
|
||||
export type StepMethod = 'robotic' | 'ai' | 'skipped';
|
||||
|
||||
export type ActionType =
|
||||
| 'set_field'
|
||||
| 'classify'
|
||||
| 'ai_enhance'
|
||||
| 'create_note'
|
||||
| 'update_autotask'
|
||||
| 'delay'
|
||||
| 'skip';
|
||||
|
||||
export type PromptPurpose =
|
||||
| 'title_cleanup'
|
||||
| 'description_rewrite'
|
||||
| 'ambiguous_classification'
|
||||
| 'troubleshooting_steps'
|
||||
| 'noc_format'
|
||||
| 'soc_analysis';
|
||||
|
||||
export type TriggerEvent = 'ticket.created' | 'ticket.updated';
|
||||
|
||||
export type StepName =
|
||||
| 'filter'
|
||||
| 'branch_routing'
|
||||
| 'ticket_type'
|
||||
| 'issue_classification'
|
||||
| 'priority'
|
||||
| 'queue_routing'
|
||||
| 'validation'
|
||||
| 'ai_title'
|
||||
| 'ai_description'
|
||||
| 'ai_classification'
|
||||
| 'ai_troubleshooting'
|
||||
| 'autotask_update'
|
||||
| 'create_note';
|
||||
|
||||
// ============================================================================
|
||||
// Database Row Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ClassificationRule {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
rule_type: RuleType;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
match_field: MatchField;
|
||||
match_operator: MatchOperator;
|
||||
match_value: any; // JSONB: string | string[] | regex pattern
|
||||
match_case_sensitive: boolean;
|
||||
result_field: string;
|
||||
result_value: any; // JSONB
|
||||
result_field_2: string | null;
|
||||
result_value_2: any | null;
|
||||
confidence: ConfidenceLevel;
|
||||
stop_on_match: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface WorkflowRule {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
sort_order: number;
|
||||
trigger_event: TriggerEvent;
|
||||
trigger_entity: string;
|
||||
stop_processing: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface WorkflowCondition {
|
||||
id: number;
|
||||
rule_id: number;
|
||||
condition_group: number;
|
||||
field: string;
|
||||
operator: ConditionOperator;
|
||||
value: any; // JSONB
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface WorkflowAction {
|
||||
id: number;
|
||||
rule_id: number;
|
||||
sort_order: number;
|
||||
action_type: ActionType;
|
||||
config: Record<string, any>;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface AiPromptTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
purpose: PromptPurpose;
|
||||
system_prompt: string;
|
||||
user_prompt_template: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
temperature: number;
|
||||
max_tokens: number;
|
||||
is_active: boolean;
|
||||
version: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface WorkflowExecution {
|
||||
id: number;
|
||||
trigger_event: string;
|
||||
entity_type: string;
|
||||
entity_id: number;
|
||||
ticket_number: string | null;
|
||||
status: ExecutionStatus;
|
||||
classification_method: ClassificationMethod | null;
|
||||
branch: Branch | null;
|
||||
started_at: Date;
|
||||
completed_at: Date | null;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface WorkflowExecutionStep {
|
||||
id: number;
|
||||
execution_id: number;
|
||||
step_name: StepName;
|
||||
step_order: number;
|
||||
status: ExecutionStatus;
|
||||
method: StepMethod | null;
|
||||
input_data: Record<string, any> | null;
|
||||
output_data: Record<string, any> | null;
|
||||
field_changes: Record<string, { before: any; after: any }> | null;
|
||||
classification_rule_id: number | null;
|
||||
confidence: ConfidenceLevel | null;
|
||||
ai_request: Record<string, any> | null;
|
||||
ai_response: string | null;
|
||||
attempt_number: number;
|
||||
error_message: string | null;
|
||||
duration_ms: number | null;
|
||||
started_at: Date | null;
|
||||
completed_at: Date | null;
|
||||
}
|
||||
|
||||
export interface WorkflowSetting {
|
||||
key: string;
|
||||
value: any; // JSONB
|
||||
description: string | null;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Runtime Types (used during processing)
|
||||
// ============================================================================
|
||||
|
||||
/** Ticket data as consumed by the workflow engine */
|
||||
export interface TicketData {
|
||||
id: number;
|
||||
ticket_number: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
ticket_category: number | null;
|
||||
ticket_type: number | null;
|
||||
priority: number | null;
|
||||
queue_id: number | null;
|
||||
issue_type: number | null;
|
||||
sub_issue_type: number | null;
|
||||
company_id: number;
|
||||
contact_id: number | null;
|
||||
assigned_resource_id: number | null;
|
||||
creator_resource_id: number | null;
|
||||
person_id: number | null;
|
||||
status: number | null;
|
||||
source: number | null;
|
||||
// Computed/extracted fields for classification
|
||||
policy_name?: string | null;
|
||||
device_name?: string | null;
|
||||
}
|
||||
|
||||
/** Result from a single classification step */
|
||||
export interface ClassificationStepResult {
|
||||
field: string;
|
||||
value: any;
|
||||
field_2?: string;
|
||||
value_2?: any;
|
||||
confidence: ConfidenceLevel;
|
||||
matched_rule_id: number | null;
|
||||
matched_rule_name: string | null;
|
||||
method: StepMethod;
|
||||
}
|
||||
|
||||
/** Full classification result from robotic classifier */
|
||||
export interface ClassificationResult {
|
||||
branch: ClassificationStepResult | null;
|
||||
ticket_type: ClassificationStepResult | null;
|
||||
issue_classification: ClassificationStepResult | null;
|
||||
priority: ClassificationStepResult | null;
|
||||
queue: ClassificationStepResult | null;
|
||||
overall_confidence: ConfidenceLevel;
|
||||
needs_ai: boolean;
|
||||
ai_reasons: string[];
|
||||
}
|
||||
|
||||
/** Validation result */
|
||||
export interface ValidationResult {
|
||||
is_valid: boolean;
|
||||
errors: ValidationError[];
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
field: string;
|
||||
message: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
/** AI enhancement result */
|
||||
export interface AiEnhancementResult {
|
||||
title?: string;
|
||||
description?: string;
|
||||
classification?: {
|
||||
issue_type?: number;
|
||||
sub_issue_type?: number;
|
||||
ticket_type?: number;
|
||||
priority?: number;
|
||||
};
|
||||
troubleshooting_steps?: string;
|
||||
method: 'ai';
|
||||
}
|
||||
|
||||
/** Field changes to write back to Autotask */
|
||||
export interface FieldChanges {
|
||||
[field: string]: {
|
||||
before: any;
|
||||
after: any;
|
||||
};
|
||||
}
|
||||
|
||||
/** Full execution result */
|
||||
export interface ExecutionResult {
|
||||
execution_id: number;
|
||||
status: ExecutionStatus;
|
||||
classification_method: ClassificationMethod;
|
||||
branch: Branch;
|
||||
field_changes: FieldChanges;
|
||||
steps: ExecutionStepSummary[];
|
||||
duration_ms: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ExecutionStepSummary {
|
||||
step_name: StepName;
|
||||
status: ExecutionStatus;
|
||||
method: StepMethod;
|
||||
confidence?: ConfidenceLevel;
|
||||
matched_rule?: string;
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
/** Workflow event that triggers processing */
|
||||
export interface WorkflowEvent {
|
||||
trigger_event: TriggerEvent;
|
||||
entity_type: string;
|
||||
entity_id: number;
|
||||
ticket_number?: string;
|
||||
ticket_data?: TicketData;
|
||||
}
|
||||
|
||||
/** Workflow settings loaded into memory */
|
||||
export interface WorkflowSettings {
|
||||
workflow_engine_enabled: boolean;
|
||||
default_ai_provider: 'openai' | 'anthropic';
|
||||
openai_api_key: string;
|
||||
openai_model: string;
|
||||
anthropic_api_key: string;
|
||||
anthropic_model: string;
|
||||
ai_for_title_cleanup: boolean;
|
||||
ai_for_description_rewrite: boolean;
|
||||
ai_for_ambiguous_classification: boolean;
|
||||
ai_for_troubleshooting: boolean;
|
||||
autotask_update_delay_ms: number;
|
||||
max_ai_retries: number;
|
||||
classification_confidence_threshold: ConfidenceLevel;
|
||||
log_retention_days: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Types (request/response shapes for API routes)
|
||||
// ============================================================================
|
||||
|
||||
export interface ClassificationRuleInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
rule_type: RuleType;
|
||||
sort_order?: number;
|
||||
is_active?: boolean;
|
||||
match_field: MatchField;
|
||||
match_operator: MatchOperator;
|
||||
match_value: any;
|
||||
match_case_sensitive?: boolean;
|
||||
result_field: string;
|
||||
result_value: any;
|
||||
result_field_2?: string;
|
||||
result_value_2?: any;
|
||||
confidence?: ConfidenceLevel;
|
||||
stop_on_match?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkflowRuleInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
is_active?: boolean;
|
||||
sort_order?: number;
|
||||
trigger_event: TriggerEvent;
|
||||
trigger_entity?: string;
|
||||
stop_processing?: boolean;
|
||||
conditions: WorkflowConditionInput[];
|
||||
actions: WorkflowActionInput[];
|
||||
}
|
||||
|
||||
export interface WorkflowConditionInput {
|
||||
condition_group?: number;
|
||||
field: string;
|
||||
operator: ConditionOperator;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface WorkflowActionInput {
|
||||
sort_order?: number;
|
||||
action_type: ActionType;
|
||||
config?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AiPromptTemplateInput {
|
||||
name: string;
|
||||
purpose: PromptPurpose;
|
||||
system_prompt: string;
|
||||
user_prompt_template: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkflowTestRequest {
|
||||
ticket_id: number;
|
||||
dry_run?: boolean; // default true
|
||||
}
|
||||
|
||||
export interface WorkflowTestResult {
|
||||
ticket: TicketData;
|
||||
classification: ClassificationResult;
|
||||
validation: ValidationResult;
|
||||
proposed_changes: FieldChanges;
|
||||
execution_steps: ExecutionStepSummary[];
|
||||
}
|
||||
|
||||
/** Workflow rule with its conditions and actions loaded */
|
||||
export interface WorkflowRuleWithDetails extends WorkflowRule {
|
||||
conditions: WorkflowCondition[];
|
||||
actions: WorkflowAction[];
|
||||
}
|
||||
|
||||
/** Execution with its steps loaded */
|
||||
export interface WorkflowExecutionWithSteps extends WorkflowExecution {
|
||||
steps: WorkflowExecutionStep[];
|
||||
}
|
||||
|
|
@ -57,6 +57,9 @@ export function mapAutotaskToDatabase(
|
|||
case EntityType.TIME_ENTRIES:
|
||||
mapped = mapTimeEntry(data);
|
||||
break;
|
||||
case EntityType.TICKET_NOTES:
|
||||
mapped = mapTicketNote(data);
|
||||
break;
|
||||
case EntityType.STATUSES:
|
||||
case EntityType.ISSUE_TYPES:
|
||||
case EntityType.SUB_ISSUE_TYPES:
|
||||
|
|
@ -201,6 +204,26 @@ function mapTicket(data: any): Record<string, any> {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map TicketNote entity
|
||||
*/
|
||||
function mapTicketNote(data: any): Record<string, any> {
|
||||
return {
|
||||
id: data.id,
|
||||
ticket_id: data.ticketID,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
note_type: data.noteType,
|
||||
publish: data.publish,
|
||||
creator_resource_id: data.creatorResourceID,
|
||||
creator_type: data.creatorType,
|
||||
last_activity_date: data.lastActivityDate,
|
||||
create_date_time: data.createDateTime,
|
||||
synced_at: data.synced_at,
|
||||
is_deleted: data.is_deleted || false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Task entity
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ export function getAllEntitiesInOrder(): EntityType[] {
|
|||
EntityType.ISSUE_TYPES,
|
||||
EntityType.SUB_ISSUE_TYPES,
|
||||
EntityType.WORK_TYPES,
|
||||
EntityType.QUEUES,
|
||||
EntityType.PRIORITIES,
|
||||
EntityType.TICKET_CATEGORIES,
|
||||
EntityType.CONTACTS,
|
||||
EntityType.PROJECTS,
|
||||
EntityType.TICKETS,
|
||||
|
|
@ -92,11 +95,15 @@ export function getAutotaskEntityName(entity: EntityType): string {
|
|||
[EntityType.ISSUE_TYPES]: 'IssueTypes',
|
||||
[EntityType.SUB_ISSUE_TYPES]: 'SubIssueTypes',
|
||||
[EntityType.WORK_TYPES]: 'WorkTypes',
|
||||
[EntityType.QUEUES]: 'Queues',
|
||||
[EntityType.PRIORITIES]: 'Priorities',
|
||||
[EntityType.TICKET_CATEGORIES]: 'TicketCategories',
|
||||
[EntityType.BILLING_ITEMS]: 'BillingItems',
|
||||
[EntityType.CONFIGURATION_ITEMS]: 'ConfigurationItems',
|
||||
[EntityType.CONTACTS]: 'Contacts',
|
||||
[EntityType.CONTRACTS]: 'Contracts',
|
||||
[EntityType.TIME_ENTRIES]: 'TimeEntries',
|
||||
[EntityType.TICKET_NOTES]: 'TicketNotes',
|
||||
};
|
||||
|
||||
return mapping[entity] || entity;
|
||||
|
|
@ -113,6 +120,9 @@ export function isPicklistEntity(entity: EntityType): boolean {
|
|||
EntityType.ISSUE_TYPES,
|
||||
EntityType.SUB_ISSUE_TYPES,
|
||||
EntityType.WORK_TYPES,
|
||||
EntityType.QUEUES,
|
||||
EntityType.PRIORITIES,
|
||||
EntityType.TICKET_CATEGORIES,
|
||||
].includes(entity);
|
||||
}
|
||||
|
||||
|
|
@ -133,10 +143,14 @@ export function getLastModifiedField(entity: EntityType): string {
|
|||
[EntityType.CONTRACTS]: 'lastModifiedDateTime',
|
||||
[EntityType.BILLING_ITEMS]: 'itemDate',
|
||||
[EntityType.TIME_ENTRIES]: 'dateWorked',
|
||||
[EntityType.TICKET_NOTES]: 'lastActivityDate',
|
||||
[EntityType.STATUSES]: 'lastModifiedDate',
|
||||
[EntityType.ISSUE_TYPES]: 'lastModifiedDate',
|
||||
[EntityType.SUB_ISSUE_TYPES]: 'lastModifiedDate',
|
||||
[EntityType.WORK_TYPES]: 'lastModifiedDate',
|
||||
[EntityType.QUEUES]: 'lastModifiedDate',
|
||||
[EntityType.PRIORITIES]: 'lastModifiedDate',
|
||||
[EntityType.TICKET_CATEGORIES]: 'lastModifiedDate',
|
||||
};
|
||||
|
||||
return mapping[entity] || 'lastModifiedDate';
|
||||
|
|
@ -159,10 +173,14 @@ export function getActiveField(entity: EntityType): string | null {
|
|||
[EntityType.CONTRACTS]: null, // Use status field instead
|
||||
[EntityType.BILLING_ITEMS]: null,
|
||||
[EntityType.TIME_ENTRIES]: null, // Time entries don't have active status
|
||||
[EntityType.TICKET_NOTES]: null, // Ticket notes don't have active status
|
||||
[EntityType.STATUSES]: 'isActive',
|
||||
[EntityType.ISSUE_TYPES]: 'isActive',
|
||||
[EntityType.SUB_ISSUE_TYPES]: 'isActive',
|
||||
[EntityType.WORK_TYPES]: 'isActive',
|
||||
[EntityType.QUEUES]: 'isActive',
|
||||
[EntityType.PRIORITIES]: 'isActive',
|
||||
[EntityType.TICKET_CATEGORIES]: 'isActive',
|
||||
};
|
||||
|
||||
return mapping[entity] || null;
|
||||
|
|
@ -245,10 +263,14 @@ export function buildDateRangeFilter(
|
|||
[EntityType.RESOURCES]: null,
|
||||
[EntityType.CONTACTS]: null,
|
||||
[EntityType.CONFIGURATION_ITEMS]: null,
|
||||
[EntityType.TICKET_NOTES]: null,
|
||||
[EntityType.STATUSES]: null,
|
||||
[EntityType.ISSUE_TYPES]: null,
|
||||
[EntityType.SUB_ISSUE_TYPES]: null,
|
||||
[EntityType.WORK_TYPES]: null,
|
||||
[EntityType.QUEUES]: null,
|
||||
[EntityType.PRIORITIES]: null,
|
||||
[EntityType.TICKET_CATEGORIES]: null,
|
||||
};
|
||||
|
||||
const dateField = dateFieldMapping[entity];
|
||||
|
|
@ -427,11 +449,15 @@ export function getEntityDisplayName(entity: EntityType): string {
|
|||
[EntityType.ISSUE_TYPES]: 'Issue Types',
|
||||
[EntityType.SUB_ISSUE_TYPES]: 'Sub-Issue Types',
|
||||
[EntityType.WORK_TYPES]: 'Work Types',
|
||||
[EntityType.QUEUES]: 'Queues',
|
||||
[EntityType.PRIORITIES]: 'Priorities',
|
||||
[EntityType.TICKET_CATEGORIES]: 'Ticket Categories',
|
||||
[EntityType.BILLING_ITEMS]: 'Billing Items',
|
||||
[EntityType.CONFIGURATION_ITEMS]: 'Configuration Items',
|
||||
[EntityType.CONTACTS]: 'Contacts',
|
||||
[EntityType.CONTRACTS]: 'Contracts',
|
||||
[EntityType.TIME_ENTRIES]: 'Time Entries',
|
||||
[EntityType.TICKET_NOTES]: 'Ticket Notes',
|
||||
};
|
||||
|
||||
return mapping[entity] || entity;
|
||||
|
|
|
|||
48
migrations/025_create_ticket_notes.sql
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
-- Migration: Create ticket_notes table
|
||||
-- Description: Stores Autotask ticket notes synced via webhooks
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_notes (
|
||||
id BIGINT PRIMARY KEY,
|
||||
ticket_id BIGINT NOT NULL,
|
||||
title VARCHAR(500),
|
||||
description TEXT,
|
||||
note_type INTEGER, -- Autotask noteType picklist
|
||||
publish INTEGER, -- Visibility: 1=All, 2=Internal, etc.
|
||||
creator_resource_id BIGINT,
|
||||
creator_type INTEGER, -- 1=Resource, 2=Contact, etc.
|
||||
last_activity_date TIMESTAMP WITH TIME ZONE,
|
||||
create_date_time TIMESTAMP WITH TIME ZONE,
|
||||
is_deleted BOOLEAN DEFAULT false,
|
||||
synced_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_notes_ticket_id ON ticket_notes(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_notes_creator ON ticket_notes(creator_resource_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_notes_create_date ON ticket_notes(create_date_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_notes_is_deleted ON ticket_notes(is_deleted);
|
||||
|
||||
-- Updated_at trigger
|
||||
CREATE OR REPLACE FUNCTION update_ticket_notes_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS ticket_notes_updated_at ON ticket_notes;
|
||||
CREATE TRIGGER ticket_notes_updated_at
|
||||
BEFORE UPDATE ON ticket_notes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_ticket_notes_updated_at();
|
||||
|
||||
COMMENT ON TABLE ticket_notes IS 'Autotask ticket notes synced via webhooks';
|
||||
|
||||
-- Ensure webhook_configs has rows for all webhook-supported entities
|
||||
INSERT INTO webhook_configs (entity_type, event_types, is_active) VALUES
|
||||
('TicketNotes', '["create", "update", "delete"]', true),
|
||||
('ConfigurationItems', '["create", "update", "delete"]', true)
|
||||
ON CONFLICT (entity_type) DO NOTHING;
|
||||
16
migrations/026_create_queues_table.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
-- Queues picklist table (synced from Autotask Ticket.queueID field)
|
||||
CREATE TABLE IF NOT EXISTS queues (
|
||||
value INTEGER PRIMARY KEY,
|
||||
label VARCHAR(200) NOT NULL,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
is_system BOOLEAN DEFAULT false,
|
||||
sort_order INTEGER,
|
||||
parent_value INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_deleted BOOLEAN DEFAULT false,
|
||||
deleted_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_queues_is_active ON queues (is_active);
|
||||
102
migrations/027_create_datto_rmm_tables.sql
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
-- Datto RMM sync tables: sites, devices, alerts
|
||||
-- Sites map to Autotask companies via autotask_company_id
|
||||
|
||||
CREATE TABLE IF NOT EXISTS datto_rmm_sites (
|
||||
id INTEGER PRIMARY KEY,
|
||||
uid TEXT UNIQUE NOT NULL,
|
||||
account_uid TEXT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
notes TEXT,
|
||||
on_demand BOOLEAN DEFAULT false,
|
||||
autotask_company_id INTEGER REFERENCES companies(id),
|
||||
autotask_company_name TEXT,
|
||||
number_of_devices INTEGER DEFAULT 0,
|
||||
number_of_online_devices INTEGER DEFAULT 0,
|
||||
number_of_offline_devices INTEGER DEFAULT 0,
|
||||
portal_url TEXT,
|
||||
synced_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS datto_rmm_devices (
|
||||
id INTEGER PRIMARY KEY,
|
||||
uid TEXT UNIQUE NOT NULL,
|
||||
site_id INTEGER REFERENCES datto_rmm_sites(id),
|
||||
site_uid TEXT,
|
||||
site_name TEXT,
|
||||
hostname TEXT,
|
||||
description TEXT,
|
||||
device_type_category TEXT,
|
||||
device_type TEXT,
|
||||
operating_system TEXT,
|
||||
domain TEXT,
|
||||
int_ip_address TEXT,
|
||||
ext_ip_address TEXT,
|
||||
last_logged_in_user TEXT,
|
||||
last_seen TIMESTAMPTZ,
|
||||
last_reboot TIMESTAMPTZ,
|
||||
last_audit_date TIMESTAMPTZ,
|
||||
creation_date TIMESTAMPTZ,
|
||||
online BOOLEAN DEFAULT false,
|
||||
suspended BOOLEAN DEFAULT false,
|
||||
deleted BOOLEAN DEFAULT false,
|
||||
reboot_required BOOLEAN DEFAULT false,
|
||||
a64_bit BOOLEAN DEFAULT true,
|
||||
cag_version TEXT,
|
||||
display_version TEXT,
|
||||
antivirus_product TEXT,
|
||||
antivirus_status TEXT,
|
||||
patch_status TEXT,
|
||||
patches_approved_pending INTEGER DEFAULT 0,
|
||||
patches_not_approved INTEGER DEFAULT 0,
|
||||
patches_installed INTEGER DEFAULT 0,
|
||||
software_status TEXT,
|
||||
portal_url TEXT,
|
||||
web_remote_url TEXT,
|
||||
warranty_date TIMESTAMPTZ,
|
||||
snmp_enabled BOOLEAN DEFAULT false,
|
||||
device_class TEXT,
|
||||
network_probe BOOLEAN DEFAULT false,
|
||||
udf JSONB,
|
||||
synced_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS datto_rmm_alerts (
|
||||
alert_uid TEXT PRIMARY KEY,
|
||||
device_uid TEXT,
|
||||
device_name TEXT,
|
||||
site_uid TEXT,
|
||||
site_name TEXT,
|
||||
priority TEXT,
|
||||
alert_context JSONB,
|
||||
alert_monitor_info JSONB,
|
||||
diagnostics TEXT,
|
||||
resolved BOOLEAN DEFAULT false,
|
||||
resolved_by TEXT,
|
||||
resolved_on TIMESTAMPTZ,
|
||||
muted BOOLEAN DEFAULT false,
|
||||
ticket_number TEXT,
|
||||
autoresolve_mins INTEGER,
|
||||
response_actions JSONB,
|
||||
timestamp TIMESTAMPTZ NOT NULL,
|
||||
synced_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_datto_rmm_sites_uid ON datto_rmm_sites(uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_datto_rmm_sites_company ON datto_rmm_sites(autotask_company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_datto_rmm_devices_uid ON datto_rmm_devices(uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_datto_rmm_devices_site ON datto_rmm_devices(site_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_datto_rmm_devices_hostname ON datto_rmm_devices(hostname);
|
||||
CREATE INDEX IF NOT EXISTS idx_datto_rmm_devices_online ON datto_rmm_devices(online);
|
||||
CREATE INDEX IF NOT EXISTS idx_datto_rmm_alerts_device ON datto_rmm_alerts(device_uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_datto_rmm_alerts_site ON datto_rmm_alerts(site_uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_datto_rmm_alerts_resolved ON datto_rmm_alerts(resolved);
|
||||
CREATE INDEX IF NOT EXISTS idx_datto_rmm_alerts_timestamp ON datto_rmm_alerts(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_datto_rmm_alerts_ticket ON datto_rmm_alerts(ticket_number);
|
||||
20
migrations/028_create_device_lifecycle_policies.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
-- Migration to create device lifecycle policies table
|
||||
CREATE TABLE IF NOT EXISTS device_lifecycle_policies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_type VARCHAR(255) NOT NULL UNIQUE,
|
||||
expected_months INTEGER NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
is_deleted BOOLEAN DEFAULT FALSE,
|
||||
deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- Seed some default policies based on common MSP standards
|
||||
INSERT INTO device_lifecycle_policies (device_type, expected_months) VALUES
|
||||
('Workstation', 48), -- 4 years
|
||||
('Desktop', 60), -- 5 years
|
||||
('Laptop', 48), -- 4 years
|
||||
('Server', 60), -- 5 years
|
||||
('Network', 60), -- 5 years
|
||||
('Firewall', 60) -- 5 years
|
||||
ON CONFLICT (device_type) DO NOTHING;
|
||||
62
migrations/028_create_veeam_agents_alarms.sql
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
-- Veeam Backup Agents (agent installs on managed machines)
|
||||
CREATE TABLE IF NOT EXISTS veeam_backup_agents (
|
||||
instance_uid VARCHAR(255) PRIMARY KEY,
|
||||
organization_uid VARCHAR(255) REFERENCES veeam_organizations(instance_uid) ON DELETE SET NULL,
|
||||
site_uid VARCHAR(255),
|
||||
management_agent_uid VARCHAR(255),
|
||||
name VARCHAR(500) NOT NULL,
|
||||
agent_platform VARCHAR(50),
|
||||
status VARCHAR(50),
|
||||
management_agent_status VARCHAR(50),
|
||||
operation_mode VARCHAR(50),
|
||||
gui_mode VARCHAR(50),
|
||||
platform VARCHAR(50),
|
||||
version VARCHAR(100),
|
||||
version_status VARCHAR(50),
|
||||
management_mode VARCHAR(100),
|
||||
installation_type VARCHAR(50),
|
||||
activation_time TIMESTAMPTZ,
|
||||
total_jobs_count INTEGER DEFAULT 0,
|
||||
running_jobs_count INTEGER DEFAULT 0,
|
||||
success_jobs_count INTEGER DEFAULT 0,
|
||||
synced_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Veeam VSPC Alarms (platform-level alarms, distinct from job failures)
|
||||
CREATE TABLE IF NOT EXISTS veeam_alarms (
|
||||
instance_uid VARCHAR(255) PRIMARY KEY,
|
||||
alarm_template_uid VARCHAR(255),
|
||||
repeat_count INTEGER DEFAULT 0,
|
||||
object_uid VARCHAR(255),
|
||||
object_type VARCHAR(100),
|
||||
object_name VARCHAR(500),
|
||||
object_computer_name VARCHAR(500),
|
||||
organization_uid VARCHAR(255) REFERENCES veeam_organizations(instance_uid) ON DELETE SET NULL,
|
||||
location_uid VARCHAR(255),
|
||||
management_agent_uid VARCHAR(255),
|
||||
last_activation_uid VARCHAR(255),
|
||||
last_activation_time TIMESTAMPTZ,
|
||||
last_activation_status VARCHAR(50),
|
||||
last_activation_message TEXT,
|
||||
last_activation_remark TEXT,
|
||||
area VARCHAR(50),
|
||||
resolved BOOLEAN DEFAULT false,
|
||||
synced_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_veeam_agents_org ON veeam_backup_agents(organization_uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_veeam_agents_status ON veeam_backup_agents(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_veeam_agents_mgmt_status ON veeam_backup_agents(management_agent_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_veeam_agents_platform ON veeam_backup_agents(agent_platform);
|
||||
CREATE INDEX IF NOT EXISTS idx_veeam_agents_synced ON veeam_backup_agents(synced_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_veeam_alarms_org ON veeam_alarms(organization_uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_veeam_alarms_status ON veeam_alarms(last_activation_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_veeam_alarms_resolved ON veeam_alarms(resolved);
|
||||
CREATE INDEX IF NOT EXISTS idx_veeam_alarms_time ON veeam_alarms(last_activation_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_veeam_alarms_synced ON veeam_alarms(synced_at);
|
||||
24
migrations/029_create_priorities_ticket_categories.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
-- Priorities picklist table (synced from Autotask Ticket.priority field)
|
||||
CREATE TABLE IF NOT EXISTS priorities (
|
||||
value INTEGER PRIMARY KEY,
|
||||
label VARCHAR(200) NOT NULL,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Ticket Categories picklist table (synced from Autotask Ticket.ticketCategory field)
|
||||
CREATE TABLE IF NOT EXISTS ticket_categories (
|
||||
value INTEGER PRIMARY KEY,
|
||||
label VARCHAR(200) NOT NULL,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_priorities_active ON priorities(is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_categories_active ON ticket_categories(is_active);
|
||||
349
migrations/030_create_workflow_engine_tables.sql
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
-- Workflow Engine Tables
|
||||
-- Supports robotic-first ticket triage with configurable rules and AI fallback
|
||||
|
||||
-- Classification rules: DB-driven keyword→classification mappings
|
||||
-- Replaces hardcoded keyword lists from n8n AI prompts
|
||||
CREATE TABLE IF NOT EXISTS classification_rules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
rule_type VARCHAR(50) NOT NULL, -- 'branch_routing', 'ticket_type', 'issue_classification', 'priority', 'queue_routing'
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
|
||||
-- Pattern matching
|
||||
match_field VARCHAR(50) NOT NULL, -- 'title', 'description', 'title_or_description', 'ticket_category', 'policy_name', 'device_name', 'creator_resource_id', 'priority', 'ticket_type'
|
||||
match_operator VARCHAR(50) NOT NULL, -- 'contains', 'starts_with', 'regex', 'equals', 'in', 'not_in'
|
||||
match_value JSONB NOT NULL, -- string, string[], or regex pattern
|
||||
match_case_sensitive BOOLEAN DEFAULT false,
|
||||
|
||||
-- Result: what to set when matched
|
||||
result_field VARCHAR(50) NOT NULL, -- 'branch', 'ticket_type', 'issue_type', 'sub_issue_type', 'priority', 'queue_id', 'ticket_category'
|
||||
result_value JSONB NOT NULL,
|
||||
|
||||
-- Optional secondary result (e.g., issue_type AND sub_issue_type together)
|
||||
result_field_2 VARCHAR(50),
|
||||
result_value_2 JSONB,
|
||||
|
||||
confidence VARCHAR(20) DEFAULT 'high', -- 'high', 'medium', 'low'
|
||||
stop_on_match BOOLEAN DEFAULT true,
|
||||
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Workflow rules: configurable exclusion/inclusion filters
|
||||
CREATE TABLE IF NOT EXISTS workflow_rules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
trigger_event VARCHAR(50) NOT NULL, -- 'ticket.created', 'ticket.updated'
|
||||
trigger_entity VARCHAR(50) DEFAULT 'ticket',
|
||||
stop_processing BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Rule conditions (AND within group, OR between groups)
|
||||
CREATE TABLE IF NOT EXISTS workflow_conditions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
rule_id INTEGER NOT NULL REFERENCES workflow_rules(id) ON DELETE CASCADE,
|
||||
condition_group INTEGER DEFAULT 0,
|
||||
field VARCHAR(100) NOT NULL,
|
||||
operator VARCHAR(50) NOT NULL,
|
||||
value JSONB NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Rule actions
|
||||
CREATE TABLE IF NOT EXISTS workflow_actions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
rule_id INTEGER NOT NULL REFERENCES workflow_rules(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
action_type VARCHAR(50) NOT NULL, -- 'set_field', 'classify', 'ai_enhance', 'create_note', 'update_autotask', 'delay', 'skip'
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- AI prompt templates (for cases where AI is needed)
|
||||
CREATE TABLE IF NOT EXISTS ai_prompt_templates (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
purpose VARCHAR(50) NOT NULL, -- 'title_cleanup', 'description_rewrite', 'ambiguous_classification', 'troubleshooting_steps', 'noc_format', 'soc_analysis'
|
||||
system_prompt TEXT NOT NULL,
|
||||
user_prompt_template TEXT NOT NULL,
|
||||
provider VARCHAR(50) DEFAULT 'openai',
|
||||
model VARCHAR(100) DEFAULT 'gpt-4o',
|
||||
temperature DECIMAL(3,2) DEFAULT 0.3,
|
||||
max_tokens INTEGER DEFAULT 4000,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
version INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Workflow execution log
|
||||
CREATE TABLE IF NOT EXISTS workflow_executions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
trigger_event VARCHAR(50) NOT NULL,
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id BIGINT NOT NULL,
|
||||
ticket_number VARCHAR(50),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
classification_method VARCHAR(20), -- 'robotic', 'ai', 'hybrid'
|
||||
branch VARCHAR(20), -- 'service_desk', 'noc', 'soc'
|
||||
started_at TIMESTAMP DEFAULT NOW(),
|
||||
completed_at TIMESTAMP,
|
||||
duration_ms INTEGER,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Execution step details
|
||||
CREATE TABLE IF NOT EXISTS workflow_execution_steps (
|
||||
id SERIAL PRIMARY KEY,
|
||||
execution_id INTEGER NOT NULL REFERENCES workflow_executions(id) ON DELETE CASCADE,
|
||||
step_name VARCHAR(100) NOT NULL,
|
||||
step_order INTEGER NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
method VARCHAR(20), -- 'robotic', 'ai', 'skipped'
|
||||
input_data JSONB,
|
||||
output_data JSONB,
|
||||
field_changes JSONB,
|
||||
classification_rule_id INTEGER,
|
||||
confidence VARCHAR(20),
|
||||
ai_request JSONB,
|
||||
ai_response TEXT,
|
||||
attempt_number INTEGER DEFAULT 1,
|
||||
error_message TEXT,
|
||||
duration_ms INTEGER,
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- Workflow settings
|
||||
CREATE TABLE IF NOT EXISTS workflow_settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value JSONB NOT NULL,
|
||||
description TEXT,
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO workflow_settings (key, value, description) VALUES
|
||||
('workflow_engine_enabled', 'false', 'Master enable/disable for workflow engine'),
|
||||
('default_ai_provider', '"openai"', 'Default AI provider (openai or anthropic)'),
|
||||
('openai_api_key', '""', 'OpenAI API key'),
|
||||
('openai_model', '"gpt-4o"', 'Default OpenAI model'),
|
||||
('anthropic_api_key', '""', 'Anthropic API key'),
|
||||
('anthropic_model', '"claude-sonnet-4-20250514"', 'Default Anthropic model'),
|
||||
('ai_for_title_cleanup', 'true', 'Use AI to clean up messy ticket titles'),
|
||||
('ai_for_description_rewrite', 'true', 'Use AI to restructure unstructured descriptions'),
|
||||
('ai_for_ambiguous_classification', 'true', 'Use AI when robotic classifier has no match'),
|
||||
('ai_for_troubleshooting', 'true', 'Generate AI troubleshooting steps for incidents'),
|
||||
('autotask_update_delay_ms', '30000', 'Delay before writing back to Autotask (ms)'),
|
||||
('max_ai_retries', '2', 'Max AI retry attempts on validation failure'),
|
||||
('classification_confidence_threshold', '"medium"', 'Min confidence to skip AI (high, medium, low)'),
|
||||
('log_retention_days', '90', 'Days to retain execution logs')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_classification_rules_type ON classification_rules(rule_type, is_active, sort_order);
|
||||
CREATE INDEX idx_workflow_rules_active ON workflow_rules(is_active, sort_order);
|
||||
CREATE INDEX idx_workflow_conditions_rule ON workflow_conditions(rule_id);
|
||||
CREATE INDEX idx_workflow_actions_rule ON workflow_actions(rule_id, sort_order);
|
||||
CREATE INDEX idx_workflow_executions_entity ON workflow_executions(entity_type, entity_id);
|
||||
CREATE INDEX idx_workflow_executions_status ON workflow_executions(status, created_at);
|
||||
CREATE INDEX idx_workflow_executions_created ON workflow_executions(created_at);
|
||||
CREATE INDEX idx_workflow_execution_steps_exec ON workflow_execution_steps(execution_id, step_order);
|
||||
|
||||
-- ============================================================================
|
||||
-- SEED: Default workflow filter rules (port from n8n IF nodes)
|
||||
-- ============================================================================
|
||||
|
||||
-- Rule 1: Exclude specific API users
|
||||
INSERT INTO workflow_rules (name, description, is_active, sort_order, trigger_event, stop_processing) VALUES
|
||||
('Exclude API Users', 'Skip tickets created by automation API users', true, 10, 'ticket.created', true);
|
||||
INSERT INTO workflow_conditions (rule_id, condition_group, field, operator, value) VALUES
|
||||
((SELECT id FROM workflow_rules WHERE name = 'Exclude API Users'), 0, 'person_id', 'in', '[30861575]');
|
||||
INSERT INTO workflow_actions (rule_id, sort_order, action_type, config) VALUES
|
||||
((SELECT id FROM workflow_rules WHERE name = 'Exclude API Users'), 0, 'skip', '{"reason": "API user exclusion"}');
|
||||
|
||||
-- Rule 2: Exclude specific technician creators
|
||||
INSERT INTO workflow_rules (name, description, is_active, sort_order, trigger_event, stop_processing) VALUES
|
||||
('Exclude Technician Creators', 'Skip tickets created by specific technicians', true, 20, 'ticket.created', true);
|
||||
INSERT INTO workflow_conditions (rule_id, condition_group, field, operator, value) VALUES
|
||||
((SELECT id FROM workflow_rules WHERE name = 'Exclude Technician Creators'), 0, 'creator_resource_id', 'in', '[30861471]');
|
||||
INSERT INTO workflow_actions (rule_id, sort_order, action_type, config) VALUES
|
||||
((SELECT id FROM workflow_rules WHERE name = 'Exclude Technician Creators'), 0, 'skip', '{"reason": "Technician creator exclusion"}');
|
||||
|
||||
-- Rule 3: Category filter - only process triageable categories
|
||||
INSERT INTO workflow_rules (name, description, is_active, sort_order, trigger_event, stop_processing) VALUES
|
||||
('Category Filter', 'Only process tickets in triageable categories (NOC, Service Desk, Triage, SOC)', true, 30, 'ticket.created', true);
|
||||
INSERT INTO workflow_conditions (rule_id, condition_group, field, operator, value) VALUES
|
||||
((SELECT id FROM workflow_rules WHERE name = 'Category Filter'), 0, 'ticket_category', 'not_in', '[2, 3, 159, 161]');
|
||||
INSERT INTO workflow_actions (rule_id, sort_order, action_type, config) VALUES
|
||||
((SELECT id FROM workflow_rules WHERE name = 'Category Filter'), 0, 'skip', '{"reason": "Ticket category not eligible for triage"}');
|
||||
|
||||
-- Rule 4: Company exclusions
|
||||
INSERT INTO workflow_rules (name, description, is_active, sort_order, trigger_event, stop_processing) VALUES
|
||||
('Company Exclusions', 'Skip tickets from excluded companies (tools-only clients)', true, 40, 'ticket.created', true);
|
||||
INSERT INTO workflow_conditions (rule_id, condition_group, field, operator, value) VALUES
|
||||
((SELECT id FROM workflow_rules WHERE name = 'Company Exclusions'), 0, 'company_id', 'in', '[29861409, 29783545, 29861361, 29702433]');
|
||||
INSERT INTO workflow_actions (rule_id, sort_order, action_type, config) VALUES
|
||||
((SELECT id FROM workflow_rules WHERE name = 'Company Exclusions'), 0, 'skip', '{"reason": "Company excluded from triage"}');
|
||||
|
||||
-- ============================================================================
|
||||
-- SEED: Branch routing classification rules
|
||||
-- ============================================================================
|
||||
|
||||
-- NOC branch routing
|
||||
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, confidence) VALUES
|
||||
('Datto RMM alerts (email)', 'branch_routing', 10, 'description', 'contains', '["alerts@rmm.datto.com", "monitor alert", "alert was triggered", "aem alert"]', 'branch', '"noc"', 'high'),
|
||||
('Datto RMM alerts (title)', 'branch_routing', 20, 'title', 'contains', '["datto", "rmm"]', 'branch', '"noc"', 'high'),
|
||||
('Zoom monitoring alerts', 'branch_routing', 30, 'title_or_description', 'contains', '["zoom room", "zoom monitoring", "video device disconnected", "audio device disconnected", "controller (ipad)"]', 'branch', '"noc"', 'high');
|
||||
|
||||
-- SOC branch routing
|
||||
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, confidence) VALUES
|
||||
('Phishing reports', 'branch_routing', 40, 'title', 'contains', '["phishing:", "[phish alert]"]', 'branch', '"soc"', 'high'),
|
||||
('Phishing indicators (headers)', 'branch_routing', 45, 'description', 'contains', '["received-spf: fail", "dkim=fail", "dmarc=fail", "# questionable urls detected"]', 'branch', '"soc"', 'high'),
|
||||
('Blumira security alerts', 'branch_routing', 50, 'title_or_description', 'contains', '["from blumira", "blumira@messages.blumira.com", "suspect |", "critical |", "informational |"]', 'branch', '"soc"', 'high'),
|
||||
('Security threat keywords', 'branch_routing', 60, 'title_or_description', 'contains', '["malware detected", "ransomware", "threat detected", "suspicious activity", "security breach", "unauthorized access", "compromised account", "attack detected", "exploit", "huntress detection", "duo security alert", "brute force", "stolen credentials", "credential harvesting", "account takeover", "indicator of compromise", "security alert", "security event", "malicious link", "suspicious attachment", "incident response"]', 'branch', '"soc"', 'high');
|
||||
|
||||
-- Default: service_desk (handled by code when no branch_routing rule matches)
|
||||
|
||||
-- ============================================================================
|
||||
-- SEED: Ticket type classification rules
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, confidence) VALUES
|
||||
('Incident - was working language', 'ticket_type', 10, 'title_or_description', 'contains', '["was working", "stopped working", "quit working", "suddenly stopped", "used to work", "previously worked", "it broke"]', 'ticket_type', '2', 'high'),
|
||||
('Incident - error/failure language', 'ticket_type', 20, 'title_or_description', 'contains', '["not working", "won''t open", "getting error", "unable to", "failed", "can''t access", "can''t send", "can''t receive", "disconnected", "crashes", "login failure", "access denied"]', 'ticket_type', '2', 'high'),
|
||||
('Service Request - request language', 'ticket_type', 30, 'title_or_description', 'contains', '["how do i", "please set up", "please install", "can i have", "need account", "new user", "new hire", "schedule", "consultation", "review my", "following up", "shipment"]', 'ticket_type', '1', 'high'),
|
||||
('Service Request - setup language', 'ticket_type', 40, 'title_or_description', 'contains', '["set up", "configure", "create new", "add new", "install", "deploy", "onboarding"]', 'ticket_type', '1', 'medium');
|
||||
|
||||
-- ============================================================================
|
||||
-- SEED: Issue classification rules (keyword → issueType + subIssueType)
|
||||
-- ============================================================================
|
||||
|
||||
-- Category overrides (highest priority)
|
||||
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, result_field_2, result_value_2, confidence) VALUES
|
||||
('New User (category 127)', 'issue_classification', 1, 'ticket_category', 'equals', '127', 'issue_type', '55', 'sub_issue_type', '522', 'high'),
|
||||
('User Separation (category 128)', 'issue_classification', 2, 'ticket_category', 'equals', '128', 'issue_type', '55', 'sub_issue_type', '526', 'high');
|
||||
|
||||
-- Email (issueType 34) - highest keyword priority per n8n rules
|
||||
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, result_field_2, result_value_2, confidence) VALUES
|
||||
('Email - can''t send', 'issue_classification', 10, 'title_or_description', 'contains', '["can''t send email", "unable to send email", "email not sending"]', 'issue_type', '34', 'sub_issue_type', '312', 'high'),
|
||||
('Email - can''t receive', 'issue_classification', 11, 'title_or_description', 'contains', '["can''t receive email", "not receiving email", "email not coming", "can''t access email", "unable to access email", "can''t get into email"]', 'issue_type', '34', 'sub_issue_type', '313', 'high'),
|
||||
('Email - spam/junk', 'issue_classification', 12, 'title_or_description', 'contains', '["spam email", "junk email", "junk mail", "too many emails"]', 'issue_type', '34', 'sub_issue_type', '314', 'high'),
|
||||
('Email - phishing', 'issue_classification', 13, 'title_or_description', 'contains', '["phishing email", "phishing attempt", "suspicious email"]', 'issue_type', '34', 'sub_issue_type', '316', 'high'),
|
||||
('Email - Outlook', 'issue_classification', 14, 'title_or_description', 'contains', '["outlook crash", "outlook not opening", "outlook won''t open", "outlook freezing"]', 'issue_type', '34', 'sub_issue_type', '451', 'high'),
|
||||
('Email - signatures', 'issue_classification', 15, 'title_or_description', 'contains', '["email signature"]', 'issue_type', '34', 'sub_issue_type', '568', 'high'),
|
||||
('Email - Mimecast release', 'issue_classification', 16, 'title_or_description', 'contains', '["mimecast release", "release email"]', 'issue_type', '34', 'sub_issue_type', '706', 'high'),
|
||||
('Email - Mimecast allow', 'issue_classification', 17, 'title_or_description', 'contains', '["mimecast allow", "allow sender", "whitelist sender"]', 'issue_type', '34', 'sub_issue_type', '707', 'high'),
|
||||
('Email - Mimecast block', 'issue_classification', 18, 'title_or_description', 'contains', '["mimecast block", "block sender", "blacklist sender"]', 'issue_type', '34', 'sub_issue_type', '832', 'high'),
|
||||
('Email - distribution group', 'issue_classification', 19, 'title_or_description', 'contains', '["distribution group", "distribution list", "email group"]', 'issue_type', '34', 'sub_issue_type', '771', 'high'),
|
||||
('Email - shared mailbox', 'issue_classification', 20, 'title_or_description', 'contains', '["shared mailbox", "shared email"]', 'issue_type', '34', 'sub_issue_type', '814', 'high'),
|
||||
('Email - forwarding', 'issue_classification', 21, 'title_or_description', 'contains', '["email forward", "forward setup", "forwarding"]', 'issue_type', '34', 'sub_issue_type', '862', 'high'),
|
||||
('Email - out of office', 'issue_classification', 22, 'title_or_description', 'contains', '["out of office", "auto-reply", "vacation reply"]', 'issue_type', '34', 'sub_issue_type', '839', 'high'),
|
||||
('Email - mailbox full', 'issue_classification', 23, 'title_or_description', 'contains', '["mailbox full", "mailbox size", "mailbox quota"]', 'issue_type', '34', 'sub_issue_type', '838', 'high'),
|
||||
('Email - generic', 'issue_classification', 29, 'title_or_description', 'contains', '["email", "e-mail", "inbox", "mailbox"]', 'issue_type', '34', 'sub_issue_type', '317', 'medium');
|
||||
|
||||
-- Active Directory & Accounts (issueType 76)
|
||||
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, result_field_2, result_value_2, confidence) VALUES
|
||||
('AD - password reset', 'issue_classification', 30, 'title_or_description', 'contains', '["password reset", "reset password", "forgot password", "password expired"]', 'issue_type', '76', 'sub_issue_type', '759', 'high'),
|
||||
('AD - account lockout', 'issue_classification', 31, 'title_or_description', 'contains', '["account locked", "locked out", "account lockout"]', 'issue_type', '76', 'sub_issue_type', '795', 'high'),
|
||||
('AD - DUO lockout', 'issue_classification', 32, 'title_or_description', 'contains', '["duo locked", "duo lockout", "mfa locked"]', 'issue_type', '76', 'sub_issue_type', '758', 'high'),
|
||||
('AD - file permissions', 'issue_classification', 33, 'title_or_description', 'contains', '["file permission", "folder permission", "access to folder", "shared drive access"]', 'issue_type', '76', 'sub_issue_type', '760', 'high'),
|
||||
('AD - drive mapping', 'issue_classification', 34, 'title_or_description', 'contains', '["drive mapping", "mapped drive", "network drive"]', 'issue_type', '76', 'sub_issue_type', '761', 'high'),
|
||||
('AD - admin rights', 'issue_classification', 35, 'title_or_description', 'contains', '["admin rights", "local admin", "administrator access"]', 'issue_type', '76', 'sub_issue_type', '828', 'high'),
|
||||
('AD - 365 license', 'issue_classification', 36, 'title_or_description', 'contains', '["365 license", "office license", "microsoft license"]', 'issue_type', '76', 'sub_issue_type', '829', 'high');
|
||||
|
||||
-- Peripheral Connectivity (issueType 51)
|
||||
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, result_field_2, result_value_2, confidence) VALUES
|
||||
('Peripheral - printer/printing', 'issue_classification', 40, 'title_or_description', 'contains', '["printer", "printing", "print job", "can''t print"]', 'issue_type', '51', 'sub_issue_type', '377', 'high'),
|
||||
('Peripheral - scanner', 'issue_classification', 41, 'title_or_description', 'contains', '["scanner", "scanning", "can''t scan"]', 'issue_type', '51', 'sub_issue_type', '550', 'high'),
|
||||
('Peripheral - scan to email', 'issue_classification', 42, 'title_or_description', 'contains', '["scan to email", "scan-to-email"]', 'issue_type', '51', 'sub_issue_type', '551', 'high'),
|
||||
('Peripheral - docking station', 'issue_classification', 43, 'title_or_description', 'contains', '["docking station", "dock", "undocking"]', 'issue_type', '51', 'sub_issue_type', '769', 'high'),
|
||||
('Peripheral - display/monitor', 'issue_classification', 44, 'title_or_description', 'contains', '["monitor", "display", "screen", "external display"]', 'issue_type', '51', 'sub_issue_type', '745', 'medium'),
|
||||
('Peripheral - audio', 'issue_classification', 45, 'title_or_description', 'contains', '["audio", "speakers", "headset", "microphone", "sound"]', 'issue_type', '51', 'sub_issue_type', '804', 'medium'),
|
||||
('Peripheral - fax', 'issue_classification', 46, 'title_or_description', 'contains', '["fax", "faxing"]', 'issue_type', '51', 'sub_issue_type', '847', 'high');
|
||||
|
||||
-- Software (issueType 46)
|
||||
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, result_field_2, result_value_2, confidence) VALUES
|
||||
('Software - MS Teams', 'issue_classification', 50, 'title_or_description', 'contains', '["microsoft teams", "ms teams", "teams meeting", "teams call"]', 'issue_type', '46', 'sub_issue_type', '798', 'high'),
|
||||
('Software - SharePoint', 'issue_classification', 51, 'title_or_description', 'contains', '["sharepoint"]', 'issue_type', '46', 'sub_issue_type', '850', 'high'),
|
||||
('Software - OneDrive', 'issue_classification', 52, 'title_or_description', 'contains', '["onedrive", "one drive"]', 'issue_type', '46', 'sub_issue_type', '345', 'high'),
|
||||
('Software - Excel', 'issue_classification', 53, 'title_or_description', 'contains', '["excel", "spreadsheet"]', 'issue_type', '46', 'sub_issue_type', '842', 'high'),
|
||||
('Software - Word', 'issue_classification', 54, 'title_or_description', 'contains', '["microsoft word", "ms word"]', 'issue_type', '46', 'sub_issue_type', '799', 'high'),
|
||||
('Software - PowerPoint', 'issue_classification', 55, 'title_or_description', 'contains', '["powerpoint", "power point"]', 'issue_type', '46', 'sub_issue_type', '800', 'high'),
|
||||
('Software - MS Project', 'issue_classification', 56, 'title_or_description', 'contains', '["ms project", "microsoft project"]', 'issue_type', '46', 'sub_issue_type', '836', 'high'),
|
||||
('Software - Adobe', 'issue_classification', 57, 'title_or_description', 'contains', '["adobe", "acrobat", "photoshop", "illustrator"]', 'issue_type', '46', 'sub_issue_type', '541', 'high'),
|
||||
('Software - Chrome', 'issue_classification', 58, 'title_or_description', 'contains', '["google chrome", "chrome browser"]', 'issue_type', '46', 'sub_issue_type', '542', 'high'),
|
||||
('Software - QuickBooks', 'issue_classification', 59, 'title_or_description', 'contains', '["quickbooks", "quick books"]', 'issue_type', '46', 'sub_issue_type', '583', 'high'),
|
||||
('Software - Bitwarden', 'issue_classification', 60, 'title_or_description', 'contains', '["bitwarden"]', 'issue_type', '46', 'sub_issue_type', '851', 'high'),
|
||||
('Software - MS Office generic', 'issue_classification', 69, 'title_or_description', 'contains', '["microsoft sway", "onenote", "one note", "visio", "publisher", "ms access", "microsoft forms", "planner"]', 'issue_type', '46', 'sub_issue_type', '332', 'high');
|
||||
|
||||
-- Networking (issueType 25)
|
||||
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, result_field_2, result_value_2, confidence) VALUES
|
||||
('Network - WiFi', 'issue_classification', 70, 'title_or_description', 'contains', '["wifi", "wi-fi", "wireless", "wireless network"]', 'issue_type', '25', 'sub_issue_type', '548', 'high'),
|
||||
('Network - VPN', 'issue_classification', 71, 'title_or_description', 'contains', '["vpn", "site to site"]', 'issue_type', '25', 'sub_issue_type', '358', 'medium'),
|
||||
('Network - internet down', 'issue_classification', 72, 'title_or_description', 'contains', '["internet down", "internet outage", "no internet", "internet not working"]', 'issue_type', '25', 'sub_issue_type', '260', 'high'),
|
||||
('Network - firewall', 'issue_classification', 73, 'title_or_description', 'contains', '["firewall"]', 'issue_type', '25', 'sub_issue_type', '244', 'high');
|
||||
|
||||
-- Hardware (issueType 31)
|
||||
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, result_field_2, result_value_2, confidence) VALUES
|
||||
('Hardware - slow PC', 'issue_classification', 80, 'title_or_description', 'contains', '["slow computer", "slow pc", "computer slow", "pc slow", "running slow"]', 'issue_type', '31', 'sub_issue_type', '560', 'high'),
|
||||
('Hardware - laptop', 'issue_classification', 81, 'title_or_description', 'contains', '["laptop issue", "laptop problem", "laptop broken"]', 'issue_type', '31', 'sub_issue_type', '561', 'medium'),
|
||||
('Hardware - desktop', 'issue_classification', 82, 'title_or_description', 'contains', '["desktop issue", "desktop problem", "desktop broken", "computer won''t turn on"]', 'issue_type', '31', 'sub_issue_type', '248', 'medium');
|
||||
|
||||
-- Remote Access (issueType 63)
|
||||
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, result_field_2, result_value_2, confidence) VALUES
|
||||
('Remote Access - VPN/Sophos', 'issue_classification', 90, 'title_or_description', 'contains', '["sophos vpn", "sophos connect"]', 'issue_type', '63', 'sub_issue_type', '494', 'high'),
|
||||
('Remote Access - RDP', 'issue_classification', 91, 'title_or_description', 'contains', '["rdp", "remote desktop", "rds gateway"]', 'issue_type', '63', 'sub_issue_type', '495', 'high'),
|
||||
('Remote Access - generic', 'issue_classification', 99, 'title_or_description', 'contains', '["remote access", "work from home", "remote work"]', 'issue_type', '63', 'sub_issue_type', '770', 'medium');
|
||||
|
||||
-- Collaboration (issueType 42) - for phone/video specific
|
||||
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, result_field_2, result_value_2, confidence) VALUES
|
||||
('Collaboration - Nextiva', 'issue_classification', 100, 'title_or_description', 'contains', '["nextiva", "phone system"]', 'issue_type', '42', 'sub_issue_type', '563', 'high'),
|
||||
('Collaboration - Teams Room', 'issue_classification', 101, 'title_or_description', 'contains', '["teams room", "conference room teams"]', 'issue_type', '42', 'sub_issue_type', '810', 'high');
|
||||
|
||||
-- Security (issueType 59)
|
||||
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, result_field_2, result_value_2, confidence) VALUES
|
||||
('Security - phishing', 'issue_classification', 110, 'title_or_description', 'contains', '["phishing", "phish"]', 'issue_type', '59', 'sub_issue_type', '910', 'high'),
|
||||
('Security - malware', 'issue_classification', 111, 'title_or_description', 'contains', '["malware", "virus", "trojan"]', 'issue_type', '59', 'sub_issue_type', '554', 'high'),
|
||||
('Security - ransomware', 'issue_classification', 112, 'title_or_description', 'contains', '["ransomware"]', 'issue_type', '59', 'sub_issue_type', '911', 'high'),
|
||||
('Security - compromised account', 'issue_classification', 113, 'title_or_description', 'contains', '["compromised account", "account compromised", "account hacked"]', 'issue_type', '59', 'sub_issue_type', '552', 'high'),
|
||||
('Security - 2FA/MFA setup', 'issue_classification', 114, 'title_or_description', 'contains', '["2fa setup", "mfa setup", "two factor", "multi factor"]', 'issue_type', '59', 'sub_issue_type', '860', 'high'),
|
||||
('Security - DUO enrollment', 'issue_classification', 115, 'title_or_description', 'contains', '["duo enrollment", "duo setup", "enroll duo"]', 'issue_type', '59', 'sub_issue_type', '813', 'high');
|
||||
|
||||
-- ============================================================================
|
||||
-- SEED: Priority classification rules
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, confidence) VALUES
|
||||
-- Category-based overrides (highest priority)
|
||||
('Priority - User Separation = Critical', 'priority', 1, 'ticket_category', 'equals', '128', 'priority', '4', 'high'),
|
||||
('Priority - New User = Minor Service', 'priority', 2, 'ticket_category', 'equals', '127', 'priority', '8', 'high'),
|
||||
-- Security events
|
||||
('Priority - Security keywords = Security Event', 'priority', 10, 'title_or_description', 'contains', '["security breach", "ransomware", "malware detected", "compromised", "attack"]', 'priority', '7', 'high'),
|
||||
-- Impact-based
|
||||
('Priority - Multiple users affected = Critical', 'priority', 20, 'title_or_description', 'contains', '["multiple users", "everyone", "all users", "company-wide", "entire office"]', 'priority', '4', 'high'),
|
||||
-- Simple service requests
|
||||
('Priority - How-to/consultation = Minor Service', 'priority', 30, 'title_or_description', 'contains', '["how do i", "how to", "schedule meeting", "consultation", "question about", "following up"]', 'priority', '8', 'medium');
|
||||
|
||||
-- ============================================================================
|
||||
-- SEED: Queue routing classification rules
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO classification_rules (name, rule_type, sort_order, match_field, match_operator, match_value, result_field, result_value, confidence) VALUES
|
||||
-- Critical priority override
|
||||
('Queue - Critical = Level 2', 'queue_routing', 1, 'priority', 'equals', '4', 'queue_id', '29682969', 'high'),
|
||||
-- Device name patterns (workstation)
|
||||
('Queue - DT devices = Level 1', 'queue_routing', 10, 'title_or_description', 'regex', '"\\bDT-?\\d{2,}"', 'queue_id', '29682833', 'high'),
|
||||
('Queue - LT devices = Level 1', 'queue_routing', 11, 'title_or_description', 'regex', '"\\bLT-?\\d{2,}"', 'queue_id', '29682833', 'high'),
|
||||
('Queue - SP devices = Level 1', 'queue_routing', 12, 'title_or_description', 'regex', '"\\bSP-?\\d{2,}"', 'queue_id', '29682833', 'high'),
|
||||
-- Policy name patterns
|
||||
('Queue - Workstation policy = Level 1', 'queue_routing', 20, 'title_or_description', 'contains', '["windows workstation", "workstations"]', 'queue_id', '29682833', 'high'),
|
||||
('Queue - Server policy = Level 2', 'queue_routing', 21, 'title_or_description', 'contains', '["windows server", "hypervisor", "esxi", "hyper-v"]', 'queue_id', '29682969', 'high');
|
||||
BIN
public/autotask-logo.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/logos/addigy.ico
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
public/logos/autotask.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/logos/auvik.ico
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
public/logos/datto-rmm.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/logos/veeam.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/veeam-logo.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |