diff --git a/app/admin/data-browser/page.tsx b/app/admin/data-browser/page.tsx index 2997750..93d0419 100644 --- a/app/admin/data-browser/page.tsx +++ b/app/admin/data-browser/page.tsx @@ -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' }, ]; diff --git a/app/admin/data-browser/ticket-notes/page.tsx b/app/admin/data-browser/ticket-notes/page.tsx new file mode 100644 index 0000000..ae5ce9c --- /dev/null +++ b/app/admin/data-browser/ticket-notes/page.tsx @@ -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 = { + 1: 'Task Detail', 2: 'Time Entry Note', 3: 'Ticket Detail', +}; + +const PUBLISH_MAP: Record = { + 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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selected, setSelected] = useState(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 + ? {v} + : , + }, + { + key: 'create_date_time', + label: 'Date', + sortable: true, + render: (v: string) => v + ? {new Date(v).toLocaleDateString()} + : , + }, + { + key: 'creator_name', + label: 'Creator', + sortable: false, + render: (v: string) => v + ? {v} + : , + }, + { + key: 'publish', + label: 'Visibility', + sortable: true, + render: (v: number) => { + const p = PUBLISH_MAP[v]; + return p + ? {p.label} + : {v}; + }, + }, + { + key: 'note_type', + label: 'Type', + sortable: true, + render: (v: number) => {NOTE_TYPE[v] ?? `Type ${v}`}, + }, + { + key: 'title', + label: 'Title', + sortable: true, + render: (v: string) =>
{v || No title}
, + }, + { + key: 'description', + label: 'Preview', + sortable: false, + render: (v: string) =>
{v || '—'}
, + }, + ]; + + return ( +
+
+
+ + + + +
+

Ticket Notes

+

Browse notes attached to tickets

+
+
+ +
+ + + + Filters + + +
+
+ + setSearch(e.target.value)} + onKeyDown={e => e.key === 'Enter' && fetchNotes(1)} /> +
+
+ + +
+
+ + +
+
+
+
+ + + + Ticket Notes ({totalCount.toLocaleString()} total) + Showing {notes.length} of {totalCount.toLocaleString()} • Click a row to view details + + + {error && ( +
+

{error}

+
+ )} + { setSelected(row); setShowModal(true); }} + totalCount={totalCount} + page={currentPage} + pageSize={pageSize} + onPageChange={p => fetchNotes(p)} + onSort={handleSort} + /> +
+
+ + +
+ ); +} diff --git a/app/admin/sync/addigy/page.tsx b/app/admin/sync/addigy/page.tsx new file mode 100644 index 0000000..297901f --- /dev/null +++ b/app/admin/sync/addigy/page.tsx @@ -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(null); + + useEffect(() => { + fetch('/api/integrations/status') + .then(r => r.json()) + .then(d => setStatus(d.addigy)) + .catch(() => {}); + }, []); + + return ( +
+
+ + + +
+
+ +
+
+

Apple RMM — Addigy

+

macOS/iOS device management, policies, compliance

+
+
+ +
+ + + + + Status + + + About + + + + + {!status ? ( +
+ ) : ( +
+
+
+ +
+

{status.configured ? 'Connected' : 'Not configured'}

+

{status.apiUrl}

+
+
+
+
+ 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. +
+
+ + + +
+
+ )} +
+ + +
+
+

API Endpoints Available

+
    +
  • GET /api/addigy-devices — managed Apple devices
  • +
  • GET /api/addigy-policies — device policies
  • +
  • GET /api/addigy/org-mappings — org to company mappings
  • +
+
+
+

Authentication

+

Bearer token auth via ADDIGY_API_TOKEN

+

Base URL: https://api.addigy.com/api/v2

+
+
+
+
+
+ ); +} diff --git a/app/admin/sync/autotask/page.tsx b/app/admin/sync/autotask/page.tsx new file mode 100644 index 0000000..d3c7a6c --- /dev/null +++ b/app/admin/sync/autotask/page.tsx @@ -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([]); + 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 ( +
+
+ + + +
+
+ +
+
+

PSA — Autotask

+

Tickets, companies, contacts, time entries, configuration items

+
+
+
+ + setIsSyncing(true)} + onSyncComplete={() => { setIsSyncing(false); setRefreshKey(k => k + 1); }} + isSyncing={isSyncing} + /> + + + + + + Status + + + + History + + + + Schedules + + + + + + + + + + + + +
+ ); +} diff --git a/app/admin/sync/auvik/page.tsx b/app/admin/sync/auvik/page.tsx new file mode 100644 index 0000000..3109abd --- /dev/null +++ b/app/admin/sync/auvik/page.tsx @@ -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(null); + + useEffect(() => { + fetch('/api/integrations/status') + .then(r => r.json()) + .then(d => setStatus(d.auvik)) + .catch(() => {}); + }, []); + + return ( +
+
+ + + +
+
+ +
+
+

NMS — Auvik

+

Network devices, topology, tenant mappings

+
+
+ +
+ + + + + Status + + + About + + + + + {!status ? ( +
+ ) : ( +
+
+
+ +
+

{status.configured ? 'Connected' : 'Not configured'}

+

{status.apiUrl}

+
+
+
+
+ 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. +
+
+ + + +
+
+ )} +
+ + +
+
+

API Endpoints Available

+
    +
  • GET /api/auvik/devices — network devices
  • +
  • GET /api/auvik/tenant-mappings — tenant to company mappings
  • +
  • GET /api/auvik/device-config — device configurations
  • +
+
+
+

Authentication

+

Basic auth — API user + API key (Base64 encoded)

+

Region: US5 (auvikapi.us5.my.auvik.com)

+
+
+
+
+
+ ); +} diff --git a/app/admin/sync/datto-rmm/page.tsx b/app/admin/sync/datto-rmm/page.tsx new file mode 100644 index 0000000..87041a1 --- /dev/null +++ b/app/admin/sync/datto-rmm/page.tsx @@ -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 ( +
+
+ {Icon && }{label} +
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + +function StatusTab({ data, onSync, syncing }: { data: any; onSync: () => void; syncing: boolean }) { + if (!data) return
; + + const devs = data.devices ?? {}; + const alerts = data.openAlerts ?? {}; + + return ( +
+
+
+

{data.configured ? 'Connected' : 'Not configured'}

+

Last sync: {fmtDate(data.lastSync)}

+
+
+ + + + +
+
+ +
+ + + + 0 ? 'border-yellow-500/30 bg-yellow-500/5' : ''} /> +
+ +
+ 0 ? 'border-red-500/30 bg-red-500/5' : ''} /> + 0 ? 'border-red-500/30 bg-red-500/5' : ''} /> + + +
+ + {(alerts.critical > 0 || alerts.high > 0) && ( +
+

Attention Required

+ {alerts.critical > 0 && ( +
+ {alerts.critical} critical alert{alerts.critical !== 1 ? 's' : ''} +
+ )} + {alerts.high > 0 && ( +
+ {alerts.high} high priority alert{alerts.high !== 1 ? 's' : ''} +
+ )} +
+ )} +
+ ); +} + +function HistoryTab({ refreshKey }: { refreshKey: number }) { + const [rows, setRows] = useState([]); + 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
; + if (!rows.length) return
No sync history yet — run a sync to populate
; + + return ( +
+ + + + + + + + + + + + {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 ( + + + + + + + + ); + })} + +
TypeStatusRecordsStartedDuration
{row.sync_type} + {row.status} + {row.records_added ?? 0}{fmtDate(row.started_at)}{dur != null ? `${dur}s` : '—'}
+
+ ); +} + +export default function DattoRmmPage() { + const [status, setStatus] = useState(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 ( +
+
+ + + +
+
+ +
+
+

RMM — Datto RMM

+

Sites, devices, alerts, patch management

+
+
+
+ + + + + Status + + + History + + + About + + + + + + + + + +
+
+

Synced Entities

+
    +
  • Sites — RMM sites with device counts, mapped to Autotask companies
  • +
  • Devices — all managed devices with OS, IP, AV, patch status, UDFs
  • +
  • Open Alerts — active alerts with priority, device context, ticket linkage
  • +
  • Resolved Alerts — recent resolved alerts with response action history
  • +
+
+
+

Authentication

+

OAuth2 password grant — API key + secret → Bearer token (100h TTL, refreshed at 50min)

+

Rate limit: 600 requests / 60 seconds across the account

+
+
+
+
+
+ ); +} diff --git a/app/admin/sync/page.tsx b/app/admin/sync/page.tsx index bf59fbe..29eea57 100644 --- a/app/admin/sync/page.tsx +++ b/app/admin/sync/page.tsx @@ -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([]); - const [isSyncing, setIsSyncing] = useState(false); - const [refreshKey, setRefreshKey] = useState(0); +interface IntegrationCard { + id: string; + category: string; + product: string; + description: string; + href: string; + logo: string; + color: string; +} - // Auto-refresh during sync and check if sync completed - useEffect(() => { - if (isSyncing) { - const interval = setInterval(async () => { - setRefreshKey(prev => prev + 1); - - // Check if sync is still in progress - try { - const response = await fetch('/api/sync/status'); - if (response.ok) { - const data = await response.json(); - // If no sync in progress, mark as complete - if (!data.inProgress) { - setIsSyncing(false); - } - } - } catch (error) { - console.error('Failed to check sync status:', error); - } - }, 5000); // Refresh every 5 seconds +const INTEGRATIONS: IntegrationCard[] = [ + { id: 'autotask', category: 'PSA', product: 'Autotask', description: 'Tickets, companies, contacts, time entries, configuration items', href: '/admin/sync/autotask', logo: '/logos/autotask.ico', color: 'red' }, + { id: 'veeam', category: 'Backup', product: 'Veeam VSPC', description: 'Agent jobs, backup jobs, protected workloads, compliance', href: '/admin/sync/veeam', logo: '/logos/veeam.ico', color: 'green' }, + { id: 'datto-rmm', category: 'RMM', product: 'Datto RMM', description: 'Device monitoring, alerts, patch management, remote access', href: '/admin/sync/datto-rmm', logo: '/logos/datto-rmm.ico', color: 'blue' }, + { id: 'auvik', category: 'NMS', product: 'Auvik', description: 'Network devices, topology, tenant mappings', href: '/admin/sync/auvik', logo: '/logos/auvik.ico', color: 'purple' }, + { id: 'addigy', category: 'Apple RMM', product: 'Addigy', description: 'macOS/iOS device management, policies, compliance', href: '/admin/sync/addigy', logo: '/logos/addigy.ico', color: 'gray' }, +]; - return () => clearInterval(interval); +const COLOR_MAP: Record = { + red: { bg: 'bg-red-500/5', border: 'border-red-500/20' }, + green: { bg: 'bg-green-500/5', border: 'border-green-500/20' }, + blue: { bg: 'bg-blue-500/5', border: 'border-blue-500/20' }, + purple: { bg: 'bg-purple-500/5', border: 'border-purple-500/20' }, + gray: { bg: 'bg-muted/20', border: 'border-border' }, +}; + +function fmtDate(d: string | null) { + if (!d) return 'Never'; + const date = new Date(d); + const diff = Date.now() - date.getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'Just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +} + +export default function SyncOverviewPage() { + const [status, setStatus] = useState(null); + const [autotaskSync, setAutotaskSync] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchAll = async () => { + try { + const [intRes, atRes] = await Promise.all([ + fetch('/api/integrations/status'), + fetch('/api/sync/last-sync'), + ]); + if (intRes.ok) setStatus(await intRes.json()); + if (atRes.ok) { + const d = await atRes.json(); + setAutotaskSync(d.lastSync || {}); + } + } catch (e) { + console.error(e); + } finally { + setLoading(false); } - }, [isSyncing]); - - const handleSyncStart = () => { - setIsSyncing(true); }; - const handleSyncComplete = () => { - setIsSyncing(false); - setRefreshKey(prev => prev + 1); + useEffect(() => { fetchAll(); }, []); + + const getAutotaskSummary = () => { + if (!autotaskSync) return null; + const entries = Object.values(autotaskSync) as any[]; + if (!entries.length) return null; + const latest = entries.reduce((a: any, b: any) => + new Date(a.completed_at) > new Date(b.completed_at) ? a : b + ); + const failed = entries.filter((e: any) => e.status === 'failed').length; + return { lastSync: latest.completed_at, failed, total: entries.length }; + }; + + const atSummary = getAutotaskSummary(); + + const getSummary = (id: string) => { + if (!status) return null; + if (id === 'autotask') return atSummary; + if (id === 'veeam') { + const v = status.veeam; + if (!v) return null; + const aj = v.agentJobs ?? {}; + const bj = v.backupJobs ?? {}; + return { + lastSync: v.lastSync, + failed: (aj.failed ?? 0) + (bj.failed ?? 0), + warning: (aj.warning ?? 0) + (bj.warning ?? 0), + running: aj.running ?? 0, + total: (aj.total ?? 0) + (bj.total ?? 0), + }; + } + if (id === 'datto-rmm') { + const d = status.dattoRmm; + if (!d) return null; + return { + lastSync: d.lastSync, + sites: d.sites ?? 0, + devices: d.devices?.total ?? 0, + online: d.devices?.online ?? 0, + offline: d.devices?.offline ?? 0, + openAlerts: d.openAlerts?.total ?? 0, + critical: d.openAlerts?.critical ?? 0, + }; + } + if (id === 'auvik') return { lastSync: status.auvik?.lastSync, configured: status.auvik?.configured }; + if (id === 'addigy') return { lastSync: status.addigy?.lastSync, configured: status.addigy?.configured }; + return null; + }; + + const getStatusIcon = (id: string, summary: any) => { + if (!summary) return ; + if (id === 'veeam') { + if (summary.failed > 0) return ; + if (summary.warning > 0 || summary.running > 0) return ; + return ; + } + if (id === 'autotask') { + if (summary.failed > 0) return ; + return ; + } + if (id === 'datto-rmm') { + if (summary.critical > 0) return ; + if (summary.openAlerts > 0) return ; + return ; + } + return ; }; return ( -
+
-
- - - -
-

Autotask Sync

-

- Sync Autotask data to PostgreSQL database -

-
+
+

Integrations & Sync

+

Manage data sync across all connected platforms

+
- {/* Sync Control Panel */} - + {loading ? ( +
+ +
+ ) : ( +
+ {INTEGRATIONS.map((intg) => { + const colors = COLOR_MAP[intg.color]; + const summary = getSummary(intg.id); + return ( + +
- {/* Tabs for Sync Status, History, and Schedules */} - - - - - Sync Status - - - - Sync History - - - - Schedules - - - - - - - - - - - - - - - + {/* Header: logo + names + chevron */} +
+
+ {intg.product} +
+

+ {intg.category} +

+
+ {intg.product} + {summary && getStatusIcon(intg.id, summary)} +
+
+
+ +
+ + {/* Description */} +

{intg.description}

+ + {/* Stats — pushed to bottom */} +
+ {intg.id === 'autotask' && summary && ( + <> +
+ Last sync + {fmtDate(summary.lastSync)} +
+
+ Entities tracked + {summary.total} +
+ + )} + {intg.id === 'veeam' && summary && ( + <> +
+ Last sync + {fmtDate(summary.lastSync)} +
+
+ Total jobs + {summary.total} +
+ {(summary as any).failed > 0 && ( +
+ Failed{(summary as any).failed} +
+ )} + {(summary as any).running > 0 && ( +
+ Running (stalled?){(summary as any).running} +
+ )} + {(summary as any).warning > 0 && ( +
+ Warning{(summary as any).warning} +
+ )} + + )} + {intg.id === 'datto-rmm' && summary && ( + <> +
+ Last sync + {fmtDate(summary.lastSync)} +
+
+ Sites / Devices + {(summary as any).sites} / {(summary as any).devices} +
+
+ Online / Offline + {(summary as any).online} / {(summary as any).offline} +
+ {(summary as any).openAlerts > 0 && ( +
0 ? 'text-red-600' : 'text-yellow-700'}`}> + Open alerts + {(summary as any).openAlerts} +
+ )} + + )} + {(intg.id === 'auvik' || intg.id === 'addigy') && ( +
+ Status + + {(summary as any)?.configured ? 'Connected' : 'Not configured'} + +
+ )} +
+
+ + ); + })} +
+ )}
); } diff --git a/app/admin/sync/veeam/page.tsx b/app/admin/sync/veeam/page.tsx new file mode 100644 index 0000000..328aa5c --- /dev/null +++ b/app/admin/sync/veeam/page.tsx @@ -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 ( +
+
+ {Icon && }{label} +
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + +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 {status}; +} + +// ── Status Tab ──────────────────────────────────────────────────────────────── +function VeeamStatusTab({ data, onSync, syncing }: { data: any; onSync: (t: string) => void; syncing: boolean }) { + if (!data) return ( +
+ +
+ ); + + 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 ( +
+
+
+

{data.configured ? 'Connected to VSPC' : 'Not configured'}

+

Last sync: {fmtDate(data.lastSync)}

+
+
+ + +
+
+ +
+ + + + +
+ +
+ 0 ? 'border-blue-500/30 bg-blue-500/5' : ''} /> + 0 ? 'border-red-500/30 bg-red-500/5' : ''} /> + 0 ? 'border-yellow-500/30 bg-yellow-500/5' : ''} /> + +
+ + {(totalFailed > 0 || totalWarning > 0 || totalRunning > 0) && ( +
+

Attention Required

+ {totalFailed > 0 &&
{totalFailed} job{totalFailed !== 1 ? 's' : ''} failed
} + {totalWarning > 0 &&
{totalWarning} job{totalWarning !== 1 ? 's' : ''} with warnings
} + {totalRunning > 0 &&
{totalRunning} job{totalRunning !== 1 ? 's' : ''} running
} +
+ )} +
+ ); +} + +// ── History Tab ─────────────────────────────────────────────────────────────── +const ENTITY_LABELS: Record = { + 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 ( + <> + 0 ? 'cursor-pointer' : ''}`} + onClick={() => entities.length > 0 && setExpanded(e => !e)} + > + +
+ {entities.length > 0 + ? (expanded + ? + : ) + : } + {row.sync_type} +
+ + + {(row.records_added ?? 0).toLocaleString()} + {fmtDate(row.started_at)} + {dur != null ? fmtDur(dur) : '—'} + {row.triggered_by ?? '—'} + + {expanded && entities.length > 0 && ( + + +

Entity Breakdown

+
+ {entities.map((e) => ( +
+
{ENTITY_LABELS[e.entity] ?? e.entity}
+
+ {e.success + ? <>{e.recordsUpserted.toLocaleString()} records · {fmtDur(e.duration)} + : Failed: {e.error?.substring(0, 60)}} +
+
+ ))} +
+ {row.error_message && ( +
+ {row.error_message} +
+ )} + + + )} + + ); +} + +function VeeamHistoryTab({ refreshKey }: { refreshKey: number }) { + const [rows, setRows] = useState([]); + 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
; + if (!rows.length) return
No sync history yet — run a sync to populate
; + + return ( +
+ + + + + + + + + + + + + {rows.map((row, i) => )} + +
TypeStatusRecordsStartedDurationTriggered By
+
+ ); +} + +// ── Agents Tab ──────────────────────────────────────────────────────────────── +function AgentsTab({ refreshKey }: { refreshKey: number }) { + const [data, setData] = useState(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
; + if (!data || !data.agents?.length) return
No agent data — run a sync first
; + + const s = data.summary ?? {}; + const agents: any[] = data.agents ?? []; + + return ( +
+
+ + + 0 ? 'border-red-500/30 bg-red-500/5' : ''} /> + 0 ? 'border-yellow-500/30 bg-yellow-500/5' : ''} /> + +
+ +
+ + + + + + + + + + + + + + + {agents.map((a: any) => ( + + + + + + + + + + + ))} + +
NameOrganizationPlatformStatusAgent StatusVersionModeJobs
{a.name}{a.organization_name ?? '—'}{a.agent_platform ?? '—'} + {a.status ?? '—'} + + {a.management_agent_status ?? '—'} + + + {a.version ?? '—'} + {a.version_status === 'Outdated' && ' ⚠'} + + {a.operation_mode ?? '—'} + {a.success_jobs_count ?? 0}✓ + {(a.running_jobs_count ?? 0) > 0 && {a.running_jobs_count}▶} + {(a.total_jobs_count ?? 0) - (a.success_jobs_count ?? 0) - (a.running_jobs_count ?? 0) > 0 && ( + + {(a.total_jobs_count ?? 0) - (a.success_jobs_count ?? 0) - (a.running_jobs_count ?? 0)}✗ + + )} +
+
+
+ ); +} + +// ── Alarms Tab ──────────────────────────────────────────────────────────────── +function AlarmsTab({ refreshKey }: { refreshKey: number }) { + const [data, setData] = useState(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
; + if (!data || !data.alarms?.length) return
No alarm data — run a sync first
; + + const s = data.summary ?? {}; + const alarms: any[] = data.alarms ?? []; + + return ( +
+
+ + 0 ? 'border-red-500/30 bg-red-500/5' : ''} /> + 0 ? 'border-yellow-500/30 bg-yellow-500/5' : ''} /> + +
+ +
+ + + + + + + + + + + + + + {alarms.map((a: any) => ( + + + + + + + + + + ))} + +
ObjectTypeOrganizationStatusRepeatsLast ActivationMessage
{a.object_computer_name || a.object_name || '—'}{a.object_type ?? '—'}{a.organization_name ?? '—'} + {a.last_activation_status ?? '—'} + {a.repeat_count ?? 0}{fmtDate(a.last_activation_time)} + {a.last_activation_message?.trim() || '—'} +
+
+
+ ); +} + +// ── Page ────────────────────────────────────────────────────────────────────── +export default function VeeamSyncPage() { + const [status, setStatus] = useState(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 ( +
+
+ + + +
+
+ +
+
+

Backup — Veeam VSPC

+

Agent jobs, backup jobs, protected workloads, agents, alarms

+
+
+
+ + + +
+
+ + + + Status + History + Agents + Alarms + Schedules + + + + + + + + +
+ ); +} diff --git a/app/admin/workflow/classification/page.tsx b/app/admin/workflow/classification/page.tsx new file mode 100644 index 0000000..bcdb07c --- /dev/null +++ b/app/admin/workflow/classification/page.tsx @@ -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 = { + 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([]); + const [isLoading, setIsLoading] = useState(true); + const [activeTab, setActiveTab] = useState('branch_routing'); + const [editingRule, setEditingRule] = useState | 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 ( +
+ {/* Header */} +
+
+ + + + +
+

Classification Rules

+

DB-driven keyword classification for ticket triage

+
+
+
+ + {/* Tabs by rule type */} + setActiveTab(v as RuleType)}> + + {RULE_TYPES.map((rt) => ( + + + {rt.label} + + ))} + + + {RULE_TYPES.map((rt) => ( + + + +
+
+ + + {rt.label} Rules + + {rt.description} +
+ +
+
+ + {isLoading ? ( +
+ +
+ ) : filteredRules.length === 0 ? ( +

+ No {rt.label.toLowerCase()} rules configured. +

+ ) : ( + + + + Order + Name + Pattern + Result + Confidence + Active + Actions + + + + {filteredRules.map((rule) => ( + + {rule.sort_order} + {rule.name} + +
+ {rule.match_field} + {rule.match_operator} +
+ {formatMatchValue(rule.match_value)} +
+
+
+ +
+ {rule.result_field}: {String(rule.result_value)} + {rule.result_field_2 && ( +
+ {rule.result_field_2}: {String(rule.result_value_2)} +
+ )} +
+
+ + + {rule.confidence} + + + + handleToggleActive(rule)} + /> + + +
+ + +
+
+
+ ))} +
+
+ )} +
+
+
+ ))} +
+ + {/* Edit/Create Dialog */} + !open && setEditingRule(null)}> + + + {editingRule?.id ? 'Edit' : 'New'} Classification Rule + Configure pattern matching and classification result + + + {editingRule && ( +
+ {/* Basic Info */} +
+
+ + setEditingRule({ ...editingRule, name: e.target.value })} + placeholder="Rule name" + /> +
+
+ + setEditingRule({ ...editingRule, sort_order: parseInt(e.target.value) })} + /> +
+
+ +
+ + setEditingRule({ ...editingRule, description: e.target.value })} + placeholder="Optional description" + /> +
+ + {/* Pattern Matching */} +
+

Pattern Matching

+
+
+ + +
+
+ + +
+
+
+ +