- Add admin dashboard with sync controls and data browser - Implement RMM, Auvik, and Addigy organization mappings - Add chunked ticket sync with progress tracking - Implement entity sync service with rate limiting - Add analytics engine and performance optimizer - Create data browser for all PSA entities - Add navigation components and UI improvements - Implement background processing and sync services - Add comprehensive documentation and migration scripts - Update configuration items with multi-system support - Enhance contact management and purchase history - Add issue type assignment and LLM analyzer - Improve error handling and logging utilities
402 lines
14 KiB
TypeScript
402 lines
14 KiB
TypeScript
/**
|
|
* Sync Control Panel Component
|
|
* Controls for triggering sync operations
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { EntityType, SyncType } from '@/lib/types/sync';
|
|
import EntitySelector from './EntitySelector';
|
|
import ChunkedSyncProgress from './ChunkedSyncProgress';
|
|
import EntitySyncProgress from './EntitySyncProgress';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { toast } from 'sonner';
|
|
import { Loader2, RefreshCw, PlayCircle, Zap, Calendar, Layers } from 'lucide-react';
|
|
|
|
interface SyncControlPanelProps {
|
|
selectedEntities: EntityType[];
|
|
onSelectedEntitiesChange: (entities: EntityType[]) => void;
|
|
onSyncStart: () => void;
|
|
onSyncComplete: () => void;
|
|
isSyncing: boolean;
|
|
}
|
|
|
|
export default function SyncControlPanel({
|
|
selectedEntities,
|
|
onSelectedEntitiesChange,
|
|
onSyncStart,
|
|
onSyncComplete,
|
|
isSyncing,
|
|
}: SyncControlPanelProps) {
|
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
|
const [pendingSyncType, setPendingSyncType] = useState<'full' | 'incremental' | 'entity' | 'chunked' | null>(null);
|
|
const [yearsBack, setYearsBack] = useState<number>(0.019); // Default to 7 days
|
|
|
|
// Chunked sync progress state
|
|
const [isChunkedSyncing, setIsChunkedSyncing] = useState(false);
|
|
const [chunkedProgress, setChunkedProgress] = useState({
|
|
completedChunks: 0,
|
|
totalChunks: 0,
|
|
totalRecords: 0,
|
|
currentChunk: undefined as any,
|
|
failedChunks: [] as string[],
|
|
});
|
|
|
|
// Entity sync progress tracking
|
|
const [activeSyncEntity, setActiveSyncEntity] = useState<EntityType | null>(null);
|
|
const [syncId, setSyncId] = useState<string | null>(null);
|
|
|
|
const handleSync = async (syncType: 'full' | 'incremental' | 'entity' | 'chunked') => {
|
|
if (syncType === 'entity' && selectedEntities.length === 0) {
|
|
toast.error('Please select at least one entity to sync');
|
|
return;
|
|
}
|
|
|
|
if (syncType === 'full') {
|
|
setPendingSyncType('full');
|
|
setShowConfirmDialog(true);
|
|
return;
|
|
}
|
|
|
|
if (syncType === 'chunked') {
|
|
await executeChunkedSync();
|
|
return;
|
|
}
|
|
|
|
await executeSyncRequest(syncType);
|
|
};
|
|
|
|
const executeChunkedSync = async () => {
|
|
try {
|
|
const estimatedChunks = Math.ceil(yearsBack * 12); // Monthly chunks
|
|
|
|
setIsChunkedSyncing(true);
|
|
setChunkedProgress({
|
|
completedChunks: 0,
|
|
totalChunks: estimatedChunks,
|
|
totalRecords: 0,
|
|
currentChunk: {
|
|
index: 1,
|
|
total: estimatedChunks,
|
|
description: 'Starting chunked sync...',
|
|
recordsProcessed: 0,
|
|
status: 'in_progress' as const,
|
|
},
|
|
failedChunks: [],
|
|
});
|
|
onSyncStart();
|
|
|
|
const response = await fetch('/api/sync/tickets-chunked', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
yearsBack,
|
|
triggeredBy: 'admin-ui',
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error || 'Chunked sync failed');
|
|
}
|
|
|
|
const result = await response.json();
|
|
toast.success(result.message || 'Chunked ticket sync started successfully');
|
|
|
|
// Use the estimated chunks from above
|
|
let currentPollChunk = 0;
|
|
|
|
// Poll for sync completion by checking sync history
|
|
// The sync runs in background, so we check periodically for updates
|
|
const pollInterval = setInterval(async () => {
|
|
try {
|
|
const historyResponse = await fetch('/api/sync/history?limit=1&entity=tickets');
|
|
if (historyResponse.ok) {
|
|
const historyData = await historyResponse.json();
|
|
const latestSync = historyData.history?.[0];
|
|
|
|
// Update progress estimate based on time elapsed
|
|
currentPollChunk = Math.min(currentPollChunk + 1, estimatedChunks);
|
|
setChunkedProgress(prev => ({
|
|
...prev,
|
|
completedChunks: currentPollChunk,
|
|
totalChunks: estimatedChunks,
|
|
totalRecords: latestSync?.records_added + latestSync?.records_updated || prev.totalRecords,
|
|
currentChunk: {
|
|
index: currentPollChunk,
|
|
total: estimatedChunks,
|
|
description: `Processing... (${currentPollChunk}/${estimatedChunks})`,
|
|
recordsProcessed: latestSync?.records_added + latestSync?.records_updated || 0,
|
|
status: 'in_progress' as const,
|
|
},
|
|
}));
|
|
|
|
// Check if the latest sync is completed or failed
|
|
if (latestSync && (latestSync.status === 'completed' || latestSync.status === 'failed')) {
|
|
clearInterval(pollInterval);
|
|
setIsChunkedSyncing(false);
|
|
setChunkedProgress(prev => ({
|
|
...prev,
|
|
completedChunks: estimatedChunks,
|
|
currentChunk: undefined,
|
|
}));
|
|
onSyncComplete();
|
|
|
|
if (latestSync.status === 'completed') {
|
|
toast.success(`Chunked sync completed! ${latestSync.records_added + latestSync.records_updated} records processed`);
|
|
} else {
|
|
toast.error('Chunked sync failed. Check logs for details.');
|
|
}
|
|
}
|
|
}
|
|
} catch (pollError) {
|
|
console.error('Error polling sync status:', pollError);
|
|
}
|
|
}, 5000); // Poll every 5 seconds
|
|
|
|
// Fallback: Stop polling after 30 minutes
|
|
setTimeout(() => {
|
|
clearInterval(pollInterval);
|
|
setIsChunkedSyncing(false);
|
|
onSyncComplete();
|
|
toast.info('Sync is still running. Check sync history for final status.');
|
|
}, 30 * 60 * 1000);
|
|
|
|
} catch (error) {
|
|
console.error('Chunked sync error:', error);
|
|
toast.error(error instanceof Error ? error.message : 'Failed to start chunked sync');
|
|
setIsChunkedSyncing(false);
|
|
onSyncComplete();
|
|
}
|
|
};
|
|
|
|
const executeSyncRequest = async (syncType: 'full' | 'incremental' | 'entity') => {
|
|
try {
|
|
onSyncStart();
|
|
|
|
// Track entity sync if it's a single entity
|
|
if (syncType === 'entity' && selectedEntities.length === 1) {
|
|
setActiveSyncEntity(selectedEntities[0]);
|
|
setSyncId(`${selectedEntities[0]}_${Date.now()}`);
|
|
}
|
|
|
|
let endpoint = '/api/sync/full';
|
|
let body: any = { triggeredBy: 'admin-ui' };
|
|
|
|
if (syncType === 'incremental') {
|
|
endpoint = '/api/sync/incremental';
|
|
} else if (syncType === 'entity') {
|
|
endpoint = '/api/sync/entity';
|
|
body.entities = selectedEntities;
|
|
body.syncType = SyncType.ENTITY_SPECIFIC;
|
|
}
|
|
|
|
const response = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ...body, yearsBack }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error || 'Sync failed');
|
|
}
|
|
|
|
const result = await response.json();
|
|
toast.success(result.message || 'Sync started successfully');
|
|
|
|
// Note: Sync runs in background. Dashboard will auto-refresh to show progress.
|
|
// onSyncComplete will be called when user manually refreshes or after checking status
|
|
} catch (error) {
|
|
console.error('Sync error:', error);
|
|
toast.error(error instanceof Error ? error.message : 'Failed to start sync');
|
|
onSyncComplete();
|
|
}
|
|
};
|
|
|
|
const confirmFullSync = async () => {
|
|
setShowConfirmDialog(false);
|
|
if (pendingSyncType && pendingSyncType !== 'chunked') {
|
|
await executeSyncRequest(pendingSyncType);
|
|
setPendingSyncType(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* Entity Sync Progress */}
|
|
{activeSyncEntity && syncId && (
|
|
<EntitySyncProgress
|
|
entityType={activeSyncEntity}
|
|
syncId={syncId}
|
|
onComplete={() => {
|
|
setActiveSyncEntity(null);
|
|
setSyncId(null);
|
|
onSyncComplete();
|
|
}}
|
|
onError={(error) => {
|
|
toast.error(error);
|
|
setActiveSyncEntity(null);
|
|
setSyncId(null);
|
|
onSyncComplete();
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Chunked Sync Progress */}
|
|
{(isChunkedSyncing || chunkedProgress.totalChunks > 0) && (
|
|
<ChunkedSyncProgress
|
|
isActive={isChunkedSyncing}
|
|
currentChunk={chunkedProgress.currentChunk}
|
|
completedChunks={chunkedProgress.completedChunks}
|
|
totalChunks={chunkedProgress.totalChunks}
|
|
totalRecords={chunkedProgress.totalRecords}
|
|
failedChunks={chunkedProgress.failedChunks}
|
|
/>
|
|
)}
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Sync Controls</CardTitle>
|
|
<CardDescription>
|
|
Trigger manual sync operations to update PostgreSQL database from Autotask
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{/* Entity Selector */}
|
|
<EntitySelector
|
|
selectedEntities={selectedEntities}
|
|
onChange={onSelectedEntitiesChange}
|
|
disabled={isSyncing}
|
|
/>
|
|
|
|
{/* Date Range Selector for Time-Based Entities */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="years-back" className="flex items-center gap-2">
|
|
<Calendar className="w-4 h-4" />
|
|
Date Range for Tickets/Tasks
|
|
</Label>
|
|
<Select
|
|
value={yearsBack.toString()}
|
|
onValueChange={(value) => setYearsBack(parseFloat(value))}
|
|
disabled={isSyncing}
|
|
>
|
|
<SelectTrigger id="years-back" className="w-full sm:w-64">
|
|
<SelectValue placeholder="Select date range" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="0.019">Last 7 Days</SelectItem>
|
|
<SelectItem value="0.082">Last 30 Days</SelectItem>
|
|
<SelectItem value="0.25">Last 90 Days</SelectItem>
|
|
<SelectItem value="1">Last 1 Year</SelectItem>
|
|
<SelectItem value="2">Last 2 Years (Recommended)</SelectItem>
|
|
<SelectItem value="3">Last 3 Years</SelectItem>
|
|
<SelectItem value="5">Last 5 Years</SelectItem>
|
|
<SelectItem value="10">Last 10 Years</SelectItem>
|
|
<SelectItem value="999">All Time (Slow)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground">
|
|
Limits tickets, tasks, and projects to reduce sync time and API usage.
|
|
Use "All Time" during off-hours for historical data.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Sync Buttons */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
|
<Button
|
|
onClick={() => handleSync('full')}
|
|
disabled={isSyncing || isChunkedSyncing}
|
|
size="lg"
|
|
variant="default"
|
|
className="w-full"
|
|
>
|
|
{isSyncing ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="mr-2 h-4 w-4" />
|
|
)}
|
|
Full Sync
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={() => handleSync('incremental')}
|
|
disabled={isSyncing || isChunkedSyncing}
|
|
size="lg"
|
|
variant="secondary"
|
|
className="w-full"
|
|
>
|
|
{isSyncing ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Zap className="mr-2 h-4 w-4" />
|
|
)}
|
|
Incremental Sync
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={() => handleSync('chunked')}
|
|
disabled={isSyncing || isChunkedSyncing}
|
|
size="lg"
|
|
variant="default"
|
|
className="w-full bg-blue-600 hover:bg-blue-700"
|
|
>
|
|
{isChunkedSyncing ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Layers className="mr-2 h-4 w-4" />
|
|
)}
|
|
Chunked Tickets
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={() => handleSync('entity')}
|
|
disabled={isSyncing || isChunkedSyncing || selectedEntities.length === 0}
|
|
size="lg"
|
|
variant="outline"
|
|
className="w-full"
|
|
>
|
|
{isSyncing ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<PlayCircle className="mr-2 h-4 w-4" />
|
|
)}
|
|
Sync Selected ({selectedEntities.length})
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="text-sm text-muted-foreground space-y-1">
|
|
<p><strong>Full Sync:</strong> Syncs all entities and soft-deletes missing records</p>
|
|
<p><strong>Incremental Sync:</strong> Only syncs records modified since last sync</p>
|
|
<p><strong>Chunked Tickets:</strong> Syncs tickets in monthly chunks to prevent timeouts (recommended for large date ranges)</p>
|
|
<p><strong>Sync Selected:</strong> Syncs only the selected entities</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Confirmation Dialog */}
|
|
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Confirm Full Sync</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This will sync all entities from Autotask and may take several minutes.
|
|
Records not found in Autotask will be soft-deleted. Are you sure you want to continue?
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={confirmFullSync}>
|
|
Start Full Sync
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
);
|
|
}
|