wulf-pulse/components/admin/SyncHistoryTable.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

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