stage 4-2

This commit is contained in:
alorig
2025-11-20 04:00:51 +05:00
parent ec3ca2da5d
commit 584dce7b8e
13 changed files with 2424 additions and 15 deletions

View File

@@ -90,6 +90,8 @@ const PageManager = lazy(() => import("./pages/Sites/PageManager"));
const PostEditor = lazy(() => import("./pages/Sites/PostEditor"));
const SitePreview = lazy(() => import("./pages/Sites/Preview"));
const SiteSettings = lazy(() => import("./pages/Sites/Settings"));
const SyncDashboard = lazy(() => import("./pages/Sites/SyncDashboard"));
const DeploymentPanel = lazy(() => import("./pages/Sites/DeploymentPanel"));
// Site Builder - Lazy loaded (will be moved from separate container)
const SiteBuilderWizard = lazy(() => import("./pages/Sites/Builder/Wizard"));
@@ -504,6 +506,16 @@ export default function App() {
<SiteSettings />
</Suspense>
} />
<Route path="/sites/:id/sync" element={
<Suspense fallback={null}>
<SyncDashboard />
</Suspense>
} />
<Route path="/sites/:id/deploy" element={
<Suspense fallback={null}>
<DeploymentPanel />
</Suspense>
} />
<Route path="/sites/:id/posts/:postId" element={
<Suspense fallback={null}>
<PostEditor />

View File

@@ -1,9 +1,11 @@
/**
* WordPress Integration Card Component
* Stage 4: Enhanced with sync health status and troubleshooting
* Displays WordPress integration status and quick actions
*/
import React from 'react';
import { Globe, CheckCircle, XCircle, Settings, RefreshCw } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Globe, CheckCircle, XCircle, Settings, RefreshCw, AlertCircle, ExternalLink } from 'lucide-react';
import { Card } from '../ui/card';
import Button from '../ui/button/Button';
import Badge from '../ui/badge/Badge';
@@ -14,8 +16,10 @@ interface WordPressIntegration {
platform: string;
is_active: boolean;
sync_enabled: boolean;
sync_status: 'success' | 'failed' | 'pending';
sync_status: 'success' | 'failed' | 'pending' | 'healthy' | 'warning' | 'error';
last_sync_at?: string;
sync_error?: string | null;
mismatch_count?: number;
config_json?: {
site_url?: string;
};
@@ -27,6 +31,7 @@ interface WordPressIntegrationCardProps {
onManage: () => void;
onSync?: () => void;
loading?: boolean;
siteId?: string | number;
}
export default function WordPressIntegrationCard({
@@ -35,7 +40,9 @@ export default function WordPressIntegrationCard({
onManage,
onSync,
loading = false,
siteId,
}: WordPressIntegrationCardProps) {
const navigate = useNavigate();
if (!integration) {
return (
<Card className="p-6">
@@ -93,15 +100,20 @@ export default function WordPressIntegrationCard({
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Sync Status</p>
<div className="flex items-center gap-2">
{integration.sync_status === 'success' ? (
{integration.sync_status === 'success' || integration.sync_status === 'healthy' ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : integration.sync_status === 'failed' ? (
) : integration.sync_status === 'failed' || integration.sync_status === 'error' ? (
<XCircle className="w-4 h-4 text-red-500" />
) : integration.sync_status === 'warning' ? (
<AlertCircle className="w-4 h-4 text-yellow-500" />
) : (
<RefreshCw className="w-4 h-4 text-yellow-500 animate-spin" />
)}
<span className="text-sm font-medium text-gray-900 dark:text-white capitalize">
{integration.sync_status}
{integration.sync_status === 'healthy' ? 'Healthy' :
integration.sync_status === 'warning' ? 'Warning' :
integration.sync_status === 'error' ? 'Error' :
integration.sync_status}
</span>
</div>
</div>
@@ -115,6 +127,48 @@ export default function WordPressIntegrationCard({
</div>
</div>
{/* Sync Health Indicators */}
{(integration.mismatch_count !== undefined && integration.mismatch_count > 0) && (
<div className="pt-2 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-yellow-500" />
<span className="text-sm text-gray-600 dark:text-gray-400">
{integration.mismatch_count} sync mismatch{integration.mismatch_count !== 1 ? 'es' : ''} detected
</span>
</div>
{siteId && (
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/sites/${siteId}/sync`)}
>
View Details
<ExternalLink className="w-3 h-3 ml-1" />
</Button>
)}
</div>
</div>
)}
{integration.sync_error && (
<div className="pt-2 border-t border-gray-200 dark:border-gray-700">
<div className="p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-start gap-2">
<XCircle className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-xs font-medium text-red-800 dark:text-red-300 mb-1">
Sync Error
</p>
<p className="text-xs text-red-700 dark:text-red-400">
{integration.sync_error}
</p>
</div>
</div>
</div>
</div>
)}
<div className="flex items-center gap-2 pt-2">
<Button
onClick={onManage}
@@ -125,6 +179,16 @@ export default function WordPressIntegrationCard({
<Settings className="w-4 h-4 mr-2" />
Manage
</Button>
{siteId && (
<Button
onClick={() => navigate(`/sites/${siteId}/sync`)}
variant="outline"
size="sm"
title="View Sync Dashboard"
>
<ExternalLink className="w-4 h-4" />
</Button>
)}
{onSync && (
<Button
onClick={onSync}

View File

@@ -11,7 +11,9 @@ import {
PlugIcon,
TrendingUpIcon,
CalendarIcon,
GlobeIcon
GlobeIcon,
RefreshCwIcon,
RocketIcon
} from 'lucide-react';
import PageMeta from '../../components/common/PageMeta';
import { Card } from '../../components/ui/card';
@@ -290,6 +292,22 @@ export default function SiteDashboard() {
<FileTextIcon className="w-4 h-4 mr-2" />
Edit Site
</Button>
<Button
variant="outline"
onClick={() => navigate(`/sites/${siteId}/sync`)}
className="justify-start"
>
<RefreshCwIcon className="w-4 h-4 mr-2" />
Sync Dashboard
</Button>
<Button
variant="outline"
onClick={() => navigate(`/sites/${siteId}/deploy`)}
className="justify-start"
>
<RocketIcon className="w-4 h-4 mr-2" />
Deploy Site
</Button>
</div>
</Card>

View File

@@ -0,0 +1,415 @@
/**
* Deployment Panel
* Stage 4: Deployment readiness and publishing
*
* Displays readiness checklist and deploy/rollback controls
*/
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
CheckCircleIcon,
XCircleIcon,
AlertCircleIcon,
RocketIcon,
RotateCcwIcon,
RefreshCwIcon,
FileTextIcon,
TagIcon,
LinkIcon,
CheckSquareIcon,
XSquareIcon,
} from 'lucide-react';
import PageMeta from '../../components/common/PageMeta';
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 {
fetchDeploymentReadiness,
fetchSiteBlueprints,
DeploymentReadiness,
} from '../../services/api';
import { fetchAPI } from '../../services/api';
export default function DeploymentPanel() {
const { id: siteId } = useParams<{ id: string }>();
const navigate = useNavigate();
const toast = useToast();
const [readiness, setReadiness] = useState<DeploymentReadiness | null>(null);
const [blueprints, setBlueprints] = useState<any[]>([]);
const [selectedBlueprintId, setSelectedBlueprintId] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [deploying, setDeploying] = useState(false);
useEffect(() => {
if (siteId) {
loadData();
}
}, [siteId]);
const loadData = async () => {
if (!siteId) return;
try {
setLoading(true);
const blueprintsData = await fetchSiteBlueprints({ site_id: Number(siteId) });
if (blueprintsData?.results && blueprintsData.results.length > 0) {
setBlueprints(blueprintsData.results);
const firstBlueprint = blueprintsData.results[0];
setSelectedBlueprintId(firstBlueprint.id);
await loadReadiness(firstBlueprint.id);
}
} catch (error: any) {
toast.error(`Failed to load deployment data: ${error.message}`);
} finally {
setLoading(false);
}
};
const loadReadiness = async (blueprintId: number) => {
try {
const readinessData = await fetchDeploymentReadiness(blueprintId);
setReadiness(readinessData);
} catch (error: any) {
toast.error(`Failed to load readiness: ${error.message}`);
}
};
useEffect(() => {
if (selectedBlueprintId) {
loadReadiness(selectedBlueprintId);
}
}, [selectedBlueprintId]);
const handleDeploy = async () => {
if (!selectedBlueprintId) return;
try {
setDeploying(true);
const result = await fetchAPI(`/v1/publisher/deploy/${selectedBlueprintId}/`, {
method: 'POST',
body: JSON.stringify({ check_readiness: true }),
});
toast.success('Deployment initiated successfully');
await loadReadiness(selectedBlueprintId); // Refresh readiness
} catch (error: any) {
toast.error(`Deployment failed: ${error.message}`);
} finally {
setDeploying(false);
}
};
const handleRollback = async () => {
if (!selectedBlueprintId) return;
try {
// TODO: Implement rollback endpoint
toast.info('Rollback functionality coming soon');
} catch (error: any) {
toast.error(`Rollback failed: ${error.message}`);
}
};
const getCheckIcon = (passed: boolean) => {
return passed ? (
<CheckCircleIcon className="w-5 h-5 text-green-600 dark:text-green-400" />
) : (
<XCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400" />
);
};
const getCheckBadge = (passed: boolean) => {
return (
<Badge color={passed ? 'success' : 'error'} size="sm">
{passed ? 'Pass' : 'Fail'}
</Badge>
);
};
if (loading) {
return (
<div className="p-6">
<PageMeta title="Deployment Panel" />
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading deployment data...</div>
</div>
</div>
);
}
if (blueprints.length === 0) {
return (
<div className="p-6">
<PageMeta title="Deployment Panel" />
<Card className="p-6">
<div className="text-center py-8">
<AlertCircleIcon className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600 dark:text-gray-400 mb-2">No blueprints found for this site</p>
<Button
variant="primary"
onClick={() => navigate(`/sites/${siteId}/builder`)}
>
Create Blueprint
</Button>
</div>
</Card>
</div>
);
}
const selectedBlueprint = blueprints.find((b) => b.id === selectedBlueprintId);
return (
<div className="p-6">
<PageMeta title="Deployment Panel" />
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Deployment Panel</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Check readiness and deploy your site
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => navigate(`/sites/${siteId}`)}
>
Back to Dashboard
</Button>
<Button
variant="outline"
onClick={handleRollback}
disabled={!selectedBlueprintId}
>
<RotateCcwIcon className="w-4 h-4 mr-2" />
Rollback
</Button>
<Button
variant="primary"
onClick={handleDeploy}
disabled={deploying || !readiness?.ready || !selectedBlueprintId}
>
<RocketIcon className={`w-4 h-4 mr-2 ${deploying ? 'animate-pulse' : ''}`} />
{deploying ? 'Deploying...' : 'Deploy'}
</Button>
</div>
</div>
{/* Blueprint Selector */}
{blueprints.length > 1 && (
<Card className="p-4 mb-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select Blueprint
</label>
<select
value={selectedBlueprintId || ''}
onChange={(e) => setSelectedBlueprintId(Number(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
{blueprints.map((bp) => (
<option key={bp.id} value={bp.id}>
{bp.name} ({bp.status})
</option>
))}
</select>
</Card>
)}
{selectedBlueprint && (
<Card className="p-4 mb-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{selectedBlueprint.name}
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
{selectedBlueprint.description || 'No description'}
</p>
</div>
<Badge color={selectedBlueprint.status === 'active' ? 'success' : 'info'} size="md">
{selectedBlueprint.status}
</Badge>
</div>
</Card>
)}
{/* Overall Readiness Status */}
{readiness && (
<>
<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">
Deployment Readiness
</h2>
<div className="flex items-center gap-2">
{getCheckIcon(readiness.ready)}
<Badge color={readiness.ready ? 'success' : 'error'} size="md">
{readiness.ready ? 'Ready' : 'Not Ready'}
</Badge>
</div>
</div>
{/* Errors */}
{readiness.errors.length > 0 && (
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<h3 className="text-sm font-semibold text-red-800 dark:text-red-300 mb-2">
Blocking Issues
</h3>
<ul className="space-y-1">
{readiness.errors.map((error, idx) => (
<li key={idx} className="text-sm text-red-700 dark:text-red-400">
{error}
</li>
))}
</ul>
</div>
)}
{/* Warnings */}
{readiness.warnings.length > 0 && (
<div className="mb-4 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<h3 className="text-sm font-semibold text-yellow-800 dark:text-yellow-300 mb-2">
Warnings
</h3>
<ul className="space-y-1">
{readiness.warnings.map((warning, idx) => (
<li key={idx} className="text-sm text-yellow-700 dark:text-yellow-400">
{warning}
</li>
))}
</ul>
</div>
)}
{/* Readiness Checks */}
<div className="space-y-4">
{/* Cluster Coverage */}
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<LinkIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h3 className="font-medium text-gray-900 dark:text-white">
Cluster Coverage
</h3>
</div>
{getCheckBadge(readiness.checks.cluster_coverage)}
</div>
{readiness.details.cluster_coverage && (
<div className="text-sm text-gray-600 dark:text-gray-400 mt-2">
<p>
{readiness.details.cluster_coverage.covered_clusters} /{' '}
{readiness.details.cluster_coverage.total_clusters} clusters covered
</p>
{readiness.details.cluster_coverage.incomplete_clusters.length > 0 && (
<p className="mt-1 text-yellow-600 dark:text-yellow-400">
{readiness.details.cluster_coverage.incomplete_clusters.length} incomplete
cluster(s)
</p>
)}
</div>
)}
</div>
{/* Content Validation */}
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<CheckSquareIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h3 className="font-medium text-gray-900 dark:text-white">
Content Validation
</h3>
</div>
{getCheckBadge(readiness.checks.content_validation)}
</div>
{readiness.details.content_validation && (
<div className="text-sm text-gray-600 dark:text-gray-400 mt-2">
<p>
{readiness.details.content_validation.valid_content} /{' '}
{readiness.details.content_validation.total_content} content items valid
</p>
{readiness.details.content_validation.invalid_content.length > 0 && (
<p className="mt-1 text-red-600 dark:text-red-400">
{readiness.details.content_validation.invalid_content.length} invalid
content item(s)
</p>
)}
</div>
)}
</div>
{/* Taxonomy Completeness */}
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<TagIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h3 className="font-medium text-gray-900 dark:text-white">
Taxonomy Completeness
</h3>
</div>
{getCheckBadge(readiness.checks.taxonomy_completeness)}
</div>
{readiness.details.taxonomy_completeness && (
<div className="text-sm text-gray-600 dark:text-gray-400 mt-2">
<p>
{readiness.details.taxonomy_completeness.total_taxonomies} taxonomies
defined
</p>
{readiness.details.taxonomy_completeness.missing_taxonomies.length > 0 && (
<p className="mt-1 text-yellow-600 dark:text-yellow-400">
Missing: {readiness.details.taxonomy_completeness.missing_taxonomies.join(', ')}
</p>
)}
</div>
)}
</div>
{/* Sync Status */}
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<RefreshCwIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h3 className="font-medium text-gray-900 dark:text-white">Sync Status</h3>
</div>
{getCheckBadge(readiness.checks.sync_status)}
</div>
{readiness.details.sync_status && (
<div className="text-sm text-gray-600 dark:text-gray-400 mt-2">
<p>
{readiness.details.sync_status.has_integration
? 'Integration configured'
: 'No integration configured'}
</p>
{readiness.details.sync_status.mismatch_count > 0 && (
<p className="mt-1 text-yellow-600 dark:text-yellow-400">
{readiness.details.sync_status.mismatch_count} sync mismatch(es) detected
</p>
)}
</div>
)}
</div>
</div>
</Card>
{/* Action Buttons */}
<div className="flex gap-2 justify-end">
<Button
variant="outline"
onClick={() => loadReadiness(selectedBlueprintId!)}
>
<RefreshCwIcon className="w-4 h-4 mr-2" />
Refresh Checks
</Button>
<Button
variant="primary"
onClick={handleDeploy}
disabled={deploying || !readiness.ready}
>
<RocketIcon className={`w-4 h-4 mr-2 ${deploying ? 'animate-pulse' : ''}`} />
{deploying ? 'Deploying...' : 'Deploy Now'}
</Button>
</div>
</>
)}
</div>
);
}

View File

@@ -563,6 +563,7 @@ export default function SiteSettings() {
onManage={() => setIsIntegrationModalOpen(true)}
onSync={handleSyncIntegration}
loading={integrationLoading}
siteId={siteId}
/>
</div>
)}

View File

@@ -0,0 +1,472 @@
/**
* 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 {
CheckCircleIcon,
XCircleIcon,
AlertCircleIcon,
RefreshCwIcon,
ClockIcon,
FileTextIcon,
TagIcon,
PackageIcon,
ArrowRightIcon,
ChevronDownIcon,
ChevronUpIcon,
} from 'lucide-react';
import PageMeta from '../../components/common/PageMeta';
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 {
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 <AlertCircleIcon className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />;
case 'error':
case 'failed':
return <XCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400" />;
default:
return <ClockIcon 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">
<AlertCircleIcon 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" />
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Sync Dashboard</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Monitor and manage WordPress sync status
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => navigate(`/sites/${siteId}`)}
>
Back to Dashboard
</Button>
<Button
variant="primary"
onClick={() => handleSync('both')}
disabled={syncing || !hasIntegrations}
>
<RefreshCwIcon className={`w-4 h-4 mr-2 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Syncing...' : 'Sync All'}
</Button>
</div>
</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">
<AlertCircleIcon 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">
<TagIcon 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">
<PackageIcon 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">
<FileTextIcon 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}
>
<RefreshCwIcon className="w-4 h-4 mr-2" />
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>
);
}

View File

@@ -2153,6 +2153,141 @@ export async function fetchSiteProgress(blueprintId: number): Promise<SiteProgre
return fetchAPI(`/v1/site-builder/blueprints/${blueprintId}/progress/`);
}
// Stage 4: Sync Health API
export interface SyncStatus {
site_id: number;
integrations: Array<{
id: number;
platform: string;
status: string;
last_sync_at: string | null;
sync_enabled: boolean;
is_healthy: boolean;
error: string | null;
mismatch_count: number;
}>;
overall_status: 'healthy' | 'warning' | 'error';
last_sync_at: string | null;
}
export interface SyncMismatches {
taxonomies: {
missing_in_wordpress: Array<{
id: number;
name: string;
type: string;
external_reference?: string;
}>;
missing_in_igny8: Array<{
name: string;
slug: string;
type: string;
external_reference: string;
}>;
mismatched: Array<any>;
};
products: {
missing_in_wordpress: Array<any>;
missing_in_igny8: Array<any>;
};
posts: {
missing_in_wordpress: Array<any>;
missing_in_igny8: Array<any>;
};
}
export interface SyncLog {
integration_id: number;
platform: string;
timestamp: string;
status: string;
error: string | null;
duration: number | null;
items_processed: number | null;
}
export async function fetchSyncStatus(siteId: number): Promise<SyncStatus> {
return fetchAPI(`/v1/integration/integrations/sites/${siteId}/sync/status/`);
}
export async function runSync(
siteId: number,
direction: 'both' | 'to_external' | 'from_external' = 'both',
contentTypes?: string[]
): Promise<{ site_id: number; sync_results: any[]; total_integrations: number }> {
return fetchAPI(`/v1/integration/integrations/sites/${siteId}/sync/run/`, {
method: 'POST',
body: JSON.stringify({ direction, content_types: contentTypes }),
});
}
export async function fetchSyncMismatches(siteId: number): Promise<SyncMismatches> {
return fetchAPI(`/v1/integration/integrations/sites/${siteId}/sync/mismatches/`);
}
export async function fetchSyncLogs(
siteId: number,
limit: number = 100,
integrationId?: number
): Promise<{ site_id: number; logs: SyncLog[]; count: number }> {
const params = new URLSearchParams();
params.append('limit', limit.toString());
if (integrationId) params.append('integration_id', integrationId.toString());
return fetchAPI(`/v1/integration/integrations/sites/${siteId}/sync/logs/?${params.toString()}`);
}
// Stage 4: Deployment Readiness API
export interface DeploymentReadiness {
ready: boolean;
checks: {
cluster_coverage: boolean;
content_validation: boolean;
sync_status: boolean;
taxonomy_completeness: boolean;
};
errors: string[];
warnings: string[];
details: {
cluster_coverage: {
ready: boolean;
total_clusters: number;
covered_clusters: number;
incomplete_clusters: Array<any>;
errors: string[];
warnings: string[];
};
content_validation: {
ready: boolean;
total_content: number;
valid_content: number;
invalid_content: Array<any>;
errors: string[];
warnings: string[];
};
sync_status: {
ready: boolean;
has_integration: boolean;
sync_status: string | null;
mismatch_count: number;
errors: string[];
warnings: string[];
};
taxonomy_completeness: {
ready: boolean;
total_taxonomies: number;
required_taxonomies: string[];
missing_taxonomies: string[];
errors: string[];
warnings: string[];
};
};
}
export async function fetchDeploymentReadiness(blueprintId: number): Promise<DeploymentReadiness> {
return fetchAPI(`/v1/publisher/blueprints/${blueprintId}/readiness/`);
}
export async function createSiteBlueprint(data: Partial<SiteBlueprint>): Promise<SiteBlueprint> {
return fetchAPI('/v1/site-builder/blueprints/', {
method: 'POST',