- 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
292 lines
9.5 KiB
TypeScript
292 lines
9.5 KiB
TypeScript
/**
|
|
* Sync History Table Component
|
|
* Displays paginated sync history from database
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { format } from 'date-fns';
|
|
import { getEntityDisplayName } from '@/lib/utils/sync-helpers';
|
|
import { EntityType } from '@/lib/types/sync';
|
|
import { ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
|
|
|
interface SyncHistoryRecord {
|
|
id: number;
|
|
entity_type: string;
|
|
sync_type: string;
|
|
status: string;
|
|
started_at: string;
|
|
completed_at?: string;
|
|
records_added: number;
|
|
records_updated: number;
|
|
records_deleted: number;
|
|
error_message?: string;
|
|
triggered_by?: string;
|
|
}
|
|
|
|
interface SyncHistoryTableProps {
|
|
refreshKey: number;
|
|
}
|
|
|
|
export default function SyncHistoryTable({ refreshKey }: SyncHistoryTableProps) {
|
|
const [history, setHistory] = useState<SyncHistoryRecord[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [page, setPage] = useState(1);
|
|
const limit = 10;
|
|
|
|
useEffect(() => {
|
|
fetchHistory();
|
|
}, [refreshKey, page]);
|
|
|
|
const fetchHistory = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await fetch(`/api/sync/history?limit=${limit}`);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setHistory(data.history || []);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch sync history:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const getStatusBadge = (status: string) => {
|
|
const variants: Record<string, 'default' | 'secondary' | 'destructive'> = {
|
|
completed: 'default',
|
|
started: 'secondary',
|
|
in_progress: 'secondary',
|
|
failed: 'destructive',
|
|
};
|
|
|
|
return (
|
|
<Badge variant={variants[status] || 'secondary'}>
|
|
{status}
|
|
</Badge>
|
|
);
|
|
};
|
|
|
|
const formatDuration = (started: string, completed?: string) => {
|
|
if (!completed) return '-';
|
|
const start = new Date(started);
|
|
const end = new Date(completed);
|
|
const duration = end.getTime() - start.getTime();
|
|
const seconds = Math.floor(duration / 1000);
|
|
const minutes = Math.floor(seconds / 60);
|
|
|
|
if (minutes > 0) {
|
|
return `${minutes}m ${seconds % 60}s`;
|
|
}
|
|
return `${seconds}s`;
|
|
};
|
|
|
|
const downloadJSON = () => {
|
|
const dataStr = JSON.stringify(history, null, 2);
|
|
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
|
const url = URL.createObjectURL(dataBlob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `sync-history-${format(new Date(), 'yyyy-MM-dd-HHmmss')}.json`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const downloadCSV = () => {
|
|
// CSV headers
|
|
const headers = [
|
|
'ID',
|
|
'Entity Type',
|
|
'Sync Type',
|
|
'Status',
|
|
'Started At',
|
|
'Completed At',
|
|
'Duration (seconds)',
|
|
'Records Added',
|
|
'Records Updated',
|
|
'Records Deleted',
|
|
'Triggered By',
|
|
'Error Message'
|
|
];
|
|
|
|
// Convert history to CSV rows
|
|
const rows = history.map(record => {
|
|
const duration = record.completed_at
|
|
? Math.floor((new Date(record.completed_at).getTime() - new Date(record.started_at).getTime()) / 1000)
|
|
: '';
|
|
|
|
return [
|
|
record.id,
|
|
getEntityDisplayName(record.entity_type as EntityType),
|
|
record.sync_type,
|
|
record.status,
|
|
format(new Date(record.started_at), 'yyyy-MM-dd HH:mm:ss'),
|
|
record.completed_at ? format(new Date(record.completed_at), 'yyyy-MM-dd HH:mm:ss') : '',
|
|
duration,
|
|
record.records_added,
|
|
record.records_updated,
|
|
record.records_deleted,
|
|
record.triggered_by || 'system',
|
|
record.error_message ? `"${record.error_message.replace(/"/g, '""')}"` : ''
|
|
];
|
|
});
|
|
|
|
// Combine headers and rows
|
|
const csvContent = [
|
|
headers.join(','),
|
|
...rows.map(row => row.join(','))
|
|
].join('\n');
|
|
|
|
// Create and download file
|
|
const dataBlob = new Blob([csvContent], { type: 'text/csv' });
|
|
const url = URL.createObjectURL(dataBlob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `sync-history-${format(new Date(), 'yyyy-MM-dd-HHmmss')}.csv`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
if (loading && history.length === 0) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Sync History</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-muted-foreground">Loading...</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
|
<div>
|
|
<CardTitle>Sync History</CardTitle>
|
|
<CardDescription>
|
|
Recent sync operations and their results
|
|
</CardDescription>
|
|
</div>
|
|
{history.length > 0 && (
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={downloadJSON}
|
|
disabled={loading}
|
|
>
|
|
<Download className="h-4 w-4 mr-2" />
|
|
JSON
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={downloadCSV}
|
|
disabled={loading}
|
|
>
|
|
<Download className="h-4 w-4 mr-2" />
|
|
CSV
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{history.length === 0 ? (
|
|
<p className="text-muted-foreground">No sync history available</p>
|
|
) : (
|
|
<>
|
|
<div className="rounded-md border overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Entity</TableHead>
|
|
<TableHead>Type</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Started</TableHead>
|
|
<TableHead>Duration</TableHead>
|
|
<TableHead className="text-right">Added</TableHead>
|
|
<TableHead className="text-right">Updated</TableHead>
|
|
<TableHead className="text-right">Deleted</TableHead>
|
|
<TableHead>Triggered By</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{history.map((record) => (
|
|
<TableRow key={record.id}>
|
|
<TableCell className="font-medium">
|
|
{getEntityDisplayName(record.entity_type as EntityType)}
|
|
</TableCell>
|
|
<TableCell className="capitalize">
|
|
{record.sync_type.replace('-', ' ')}
|
|
</TableCell>
|
|
<TableCell>{getStatusBadge(record.status)}</TableCell>
|
|
<TableCell className="text-sm">
|
|
{format(new Date(record.started_at), 'MMM d, HH:mm:ss')}
|
|
</TableCell>
|
|
<TableCell className="text-sm">
|
|
{formatDuration(record.started_at, record.completed_at)}
|
|
</TableCell>
|
|
<TableCell className="text-right text-green-600">
|
|
+{record.records_added}
|
|
</TableCell>
|
|
<TableCell className="text-right text-blue-600">
|
|
~{record.records_updated}
|
|
</TableCell>
|
|
<TableCell className="text-right text-red-600">
|
|
-{record.records_deleted}
|
|
</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{record.triggered_by || 'system'}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 mt-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Showing {history.length} records
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
|
disabled={page === 1}
|
|
>
|
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
|
<span className="hidden sm:inline">Previous</span>
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage(p => p + 1)}
|
|
disabled={history.length < limit}
|
|
>
|
|
<span className="hidden sm:inline">Next</span>
|
|
<ChevronRight className="h-4 w-4 ml-1" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|