470 lines
18 KiB
TypeScript
470 lines
18 KiB
TypeScript
/**
|
|
* Sync Dashboard
|
|
* Stage 4: WordPress sync health and management
|
|
*
|
|
* Displays sync status, parity indicators, and sync controls
|
|
*/
|
|
import React, { useState, useEffect } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import PageMeta from '../../components/common/PageMeta';
|
|
import PageHeader from '../../components/common/PageHeader';
|
|
import { Card } from '../../components/ui/card';
|
|
import Button from '../../components/ui/button/Button';
|
|
import Badge from '../../components/ui/badge/Badge';
|
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
|
import {
|
|
CheckCircleIcon,
|
|
ErrorIcon,
|
|
AlertIcon,
|
|
BoltIcon,
|
|
TimeIcon,
|
|
FileIcon,
|
|
BoxIcon,
|
|
ArrowRightIcon,
|
|
ChevronDownIcon,
|
|
ChevronUpIcon,
|
|
PlugInIcon
|
|
} from '../../icons';
|
|
import {
|
|
fetchSyncStatus,
|
|
runSync,
|
|
fetchSyncMismatches,
|
|
fetchSyncLogs,
|
|
SyncStatus,
|
|
SyncMismatches,
|
|
SyncLog,
|
|
} from '../../services/api';
|
|
|
|
export default function SyncDashboard() {
|
|
const { id: siteId } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const toast = useToast();
|
|
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
|
const [mismatches, setMismatches] = useState<SyncMismatches | null>(null);
|
|
const [logs, setLogs] = useState<SyncLog[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [syncing, setSyncing] = useState(false);
|
|
const [showMismatches, setShowMismatches] = useState(false);
|
|
const [showLogs, setShowLogs] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (siteId) {
|
|
loadSyncData();
|
|
}
|
|
}, [siteId]);
|
|
|
|
const loadSyncData = async () => {
|
|
if (!siteId) return;
|
|
try {
|
|
setLoading(true);
|
|
const [statusData, mismatchesData, logsData] = await Promise.all([
|
|
fetchSyncStatus(Number(siteId)),
|
|
fetchSyncMismatches(Number(siteId)),
|
|
fetchSyncLogs(Number(siteId), 50),
|
|
]);
|
|
setSyncStatus(statusData);
|
|
setMismatches(mismatchesData);
|
|
setLogs(logsData.logs || []);
|
|
} catch (error: any) {
|
|
toast.error(`Failed to load sync data: ${error.message}`);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSync = async (direction: 'both' | 'to_external' | 'from_external' = 'both') => {
|
|
if (!siteId) return;
|
|
try {
|
|
setSyncing(true);
|
|
const result = await runSync(Number(siteId), direction);
|
|
toast.success(`Sync completed: ${result.total_integrations} integration(s) synced`);
|
|
await loadSyncData(); // Refresh data
|
|
} catch (error: any) {
|
|
toast.error(`Sync failed: ${error.message}`);
|
|
} finally {
|
|
setSyncing(false);
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case 'healthy':
|
|
case 'success':
|
|
return 'success';
|
|
case 'warning':
|
|
return 'warning';
|
|
case 'error':
|
|
case 'failed':
|
|
return 'error';
|
|
default:
|
|
return 'info';
|
|
}
|
|
};
|
|
|
|
const getStatusIcon = (status: string) => {
|
|
switch (status) {
|
|
case 'healthy':
|
|
case 'success':
|
|
return <CheckCircleIcon className="w-5 h-5 text-green-600 dark:text-green-400" />;
|
|
case 'warning':
|
|
return <AlertIcon className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />;
|
|
case 'error':
|
|
case 'failed':
|
|
return <ErrorIcon className="w-5 h-5 text-red-600 dark:text-red-400" />;
|
|
default:
|
|
return <TimeIcon className="w-5 h-5 text-gray-400" />;
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="p-6">
|
|
<PageMeta title="Sync Dashboard" />
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="text-gray-500">Loading sync data...</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!syncStatus) {
|
|
return (
|
|
<div className="p-6">
|
|
<PageMeta title="Sync Dashboard" />
|
|
<Card className="p-6">
|
|
<div className="text-center py-8">
|
|
<AlertIcon className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
|
<p className="text-gray-600 dark:text-gray-400">No sync data available</p>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const hasIntegrations = syncStatus.integrations.length > 0;
|
|
const totalMismatches =
|
|
(mismatches?.taxonomies.missing_in_wordpress.length || 0) +
|
|
(mismatches?.taxonomies.missing_in_igny8.length || 0) +
|
|
(mismatches?.products.missing_in_wordpress.length || 0) +
|
|
(mismatches?.products.missing_in_igny8.length || 0) +
|
|
(mismatches?.posts.missing_in_wordpress.length || 0) +
|
|
(mismatches?.posts.missing_in_igny8.length || 0);
|
|
|
|
return (
|
|
<div className="p-6">
|
|
<PageMeta title="Sync Dashboard" />
|
|
|
|
<PageHeader
|
|
title="Sync Dashboard"
|
|
badge={{ icon: <PlugInIcon />, color: 'blue' }}
|
|
hideSiteSector
|
|
/>
|
|
<div className="mb-6 flex justify-end gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => navigate(`/sites/${siteId}`)}
|
|
>
|
|
Back to Dashboard
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => handleSync('both')}
|
|
disabled={syncing || !hasIntegrations}
|
|
>
|
|
<BoltIcon className={`w-4 h-4 mr-2 ${syncing ? 'animate-spin' : ''}`} />
|
|
{syncing ? 'Syncing...' : 'Sync All'}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Overall Status */}
|
|
<Card className="p-6 mb-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Overall Status</h2>
|
|
<Badge color={getStatusColor(syncStatus.overall_status)} size="md">
|
|
{syncStatus.overall_status}
|
|
</Badge>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div className="text-center p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
{syncStatus.integrations.length}
|
|
</div>
|
|
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">Active Integrations</div>
|
|
</div>
|
|
<div className="text-center p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
{totalMismatches}
|
|
</div>
|
|
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">Mismatches</div>
|
|
</div>
|
|
<div className="text-center p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
|
{syncStatus.last_sync_at
|
|
? new Date(syncStatus.last_sync_at).toLocaleString()
|
|
: 'Never'}
|
|
</div>
|
|
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">Last Sync</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Integrations List */}
|
|
{hasIntegrations ? (
|
|
<div className="space-y-4 mb-6">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Integrations</h2>
|
|
{syncStatus.integrations.map((integration) => (
|
|
<Card key={integration.id} className="p-6">
|
|
<div className="flex items-start justify-between mb-4">
|
|
<div className="flex items-center gap-3">
|
|
{getStatusIcon(integration.is_healthy ? 'healthy' : integration.status)}
|
|
<div>
|
|
<h3 className="font-semibold text-gray-900 dark:text-white">
|
|
{integration.platform.charAt(0).toUpperCase() + integration.platform.slice(1)}
|
|
</h3>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
Last sync: {integration.last_sync_at
|
|
? new Date(integration.last_sync_at).toLocaleString()
|
|
: 'Never'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Badge color={getStatusColor(integration.status)} size="sm">
|
|
{integration.status}
|
|
</Badge>
|
|
{integration.mismatch_count > 0 && (
|
|
<Badge color="warning" size="sm">
|
|
{integration.mismatch_count} mismatch{integration.mismatch_count !== 1 ? 'es' : ''}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{integration.error && (
|
|
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
|
<div className="text-sm text-red-800 dark:text-red-300">{integration.error}</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleSync('to_external')}
|
|
disabled={syncing || !integration.sync_enabled}
|
|
>
|
|
<ArrowRightIcon className="w-4 h-4 mr-1" />
|
|
Sync to WordPress
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleSync('from_external')}
|
|
disabled={syncing || !integration.sync_enabled}
|
|
>
|
|
<ArrowRightIcon className="w-4 h-4 mr-1 rotate-180" />
|
|
Sync from WordPress
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Card className="p-6 mb-6">
|
|
<div className="text-center py-8">
|
|
<AlertIcon className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
|
<p className="text-gray-600 dark:text-gray-400 mb-2">No active integrations</p>
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => navigate(`/sites/${siteId}/settings?tab=integrations`)}
|
|
>
|
|
Configure Integration
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Mismatches Section */}
|
|
{totalMismatches > 0 && (
|
|
<Card className="p-6 mb-6">
|
|
<button
|
|
onClick={() => setShowMismatches(!showMismatches)}
|
|
className="w-full flex items-center justify-between mb-4"
|
|
>
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
Mismatches ({totalMismatches})
|
|
</h2>
|
|
{showMismatches ? (
|
|
<ChevronUpIcon className="w-5 h-5 text-gray-400" />
|
|
) : (
|
|
<ChevronDownIcon className="w-5 h-5 text-gray-400" />
|
|
)}
|
|
</button>
|
|
|
|
{showMismatches && mismatches && (
|
|
<div className="space-y-4">
|
|
{/* Taxonomy Mismatches */}
|
|
{(mismatches.taxonomies.missing_in_wordpress.length > 0 ||
|
|
mismatches.taxonomies.missing_in_igny8.length > 0) && (
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
|
|
<BoxIcon className="w-4 h-4" />
|
|
Taxonomy Mismatches
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{mismatches.taxonomies.missing_in_wordpress.length > 0 && (
|
|
<div className="p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
|
|
<div className="text-sm font-medium text-yellow-800 dark:text-yellow-300 mb-1">
|
|
Missing in WordPress ({mismatches.taxonomies.missing_in_wordpress.length})
|
|
</div>
|
|
<ul className="text-sm text-yellow-700 dark:text-yellow-400 space-y-1">
|
|
{mismatches.taxonomies.missing_in_wordpress.slice(0, 5).map((item, idx) => (
|
|
<li key={idx}>• {item.name} ({item.type})</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
{mismatches.taxonomies.missing_in_igny8.length > 0 && (
|
|
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
|
<div className="text-sm font-medium text-blue-800 dark:text-blue-300 mb-1">
|
|
Missing in IGNY8 ({mismatches.taxonomies.missing_in_igny8.length})
|
|
</div>
|
|
<ul className="text-sm text-blue-700 dark:text-blue-400 space-y-1">
|
|
{mismatches.taxonomies.missing_in_igny8.slice(0, 5).map((item, idx) => (
|
|
<li key={idx}>• {item.name} ({item.type})</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Product Mismatches */}
|
|
{(mismatches.products.missing_in_wordpress.length > 0 ||
|
|
mismatches.products.missing_in_igny8.length > 0) && (
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
|
|
<BoxIcon className="w-4 h-4" />
|
|
Product Mismatches
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{mismatches.products.missing_in_wordpress.length > 0 && (
|
|
<div className="p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
|
|
<div className="text-sm text-yellow-800 dark:text-yellow-300">
|
|
{mismatches.products.missing_in_wordpress.length} product(s) missing in WordPress
|
|
</div>
|
|
</div>
|
|
)}
|
|
{mismatches.products.missing_in_igny8.length > 0 && (
|
|
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
|
<div className="text-sm text-blue-800 dark:text-blue-300">
|
|
{mismatches.products.missing_in_igny8.length} product(s) missing in IGNY8
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Post Mismatches */}
|
|
{(mismatches.posts.missing_in_wordpress.length > 0 ||
|
|
mismatches.posts.missing_in_igny8.length > 0) && (
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
|
|
<FileIcon className="w-4 h-4" />
|
|
Post Mismatches
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{mismatches.posts.missing_in_wordpress.length > 0 && (
|
|
<div className="p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
|
|
<div className="text-sm text-yellow-800 dark:text-yellow-300">
|
|
{mismatches.posts.missing_in_wordpress.length} post(s) missing in WordPress
|
|
</div>
|
|
</div>
|
|
)}
|
|
{mismatches.posts.missing_in_igny8.length > 0 && (
|
|
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
|
<div className="text-sm text-blue-800 dark:text-blue-300">
|
|
{mismatches.posts.missing_in_igny8.length} post(s) missing in IGNY8
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-4">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleSync('both')}
|
|
disabled={syncing}
|
|
startIcon={<BoltIcon className="w-4 h-4" />}
|
|
>
|
|
Retry Sync to Resolve
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
)}
|
|
|
|
{/* Sync Logs */}
|
|
<Card className="p-6">
|
|
<button
|
|
onClick={() => setShowLogs(!showLogs)}
|
|
className="w-full flex items-center justify-between mb-4"
|
|
>
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
Sync History ({logs.length})
|
|
</h2>
|
|
{showLogs ? (
|
|
<ChevronUpIcon className="w-5 h-5 text-gray-400" />
|
|
) : (
|
|
<ChevronDownIcon className="w-5 h-5 text-gray-400" />
|
|
)}
|
|
</button>
|
|
|
|
{showLogs && (
|
|
<div className="space-y-2">
|
|
{logs.length > 0 ? (
|
|
logs.map((log, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
{getStatusIcon(log.status)}
|
|
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
|
{log.platform}
|
|
</span>
|
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
{new Date(log.timestamp).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
<Badge color={getStatusColor(log.status)} size="sm">
|
|
{log.status}
|
|
</Badge>
|
|
</div>
|
|
{log.error && (
|
|
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
|
|
{log.error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="text-center py-4 text-gray-500 dark:text-gray-400">
|
|
<p>No sync logs available</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|