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
This commit is contained in:
parent
e8462ef301
commit
6eee14f8af
171 changed files with 32671 additions and 621 deletions
292
components/admin/SyncHistoryTable.tsx
Normal file
292
components/admin/SyncHistoryTable.tsx
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue