wulf-pulse/components/admin/SyncControlPanel.tsx
root 6eee14f8af Add comprehensive admin features and multi-system integration
- 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
2025-11-19 14:18:16 -05:00

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>
</>
);
}