- Separate Sync Status and Sync History into tabs with icons - Fix pagination in sync history by adding offset parameter - Update getSyncHistory to support offset for proper pagination - Update API endpoint to pass offset parameter - Previous/Next buttons now work correctly to navigate pages This improves UX by organizing the sync page into logical sections and enables users to browse through historical sync records.
293 lines
9.5 KiB
TypeScript
293 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 offset = (page - 1) * limit;
|
|
const response = await fetch(`/api/sync/history?limit=${limit}&offset=${offset}`);
|
|
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>
|
|
);
|
|
}
|