stage 4-2
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
"""
|
||||
Sync Health Service
|
||||
Stage 4: Track sync health, mismatches, and logs
|
||||
|
||||
Provides health monitoring for site integrations.
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
from django.utils import timezone
|
||||
|
||||
from igny8_core.business.integration.models import SiteIntegration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SyncHealthService:
|
||||
"""
|
||||
Service for tracking sync health and detecting mismatches.
|
||||
"""
|
||||
|
||||
def get_sync_status(
|
||||
self,
|
||||
site_id: int,
|
||||
integration_id: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get sync status for a site or specific integration.
|
||||
|
||||
Args:
|
||||
site_id: Site ID
|
||||
integration_id: Optional integration ID (if None, returns all integrations)
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'site_id': int,
|
||||
'integrations': [
|
||||
{
|
||||
'id': int,
|
||||
'platform': str,
|
||||
'status': str,
|
||||
'last_sync_at': datetime,
|
||||
'sync_enabled': bool,
|
||||
'is_healthy': bool,
|
||||
'error': str,
|
||||
'mismatch_count': int
|
||||
}
|
||||
],
|
||||
'overall_status': str, # 'healthy', 'warning', 'error'
|
||||
'last_sync_at': datetime
|
||||
}
|
||||
"""
|
||||
try:
|
||||
integrations_query = SiteIntegration.objects.filter(
|
||||
site_id=site_id,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
if integration_id:
|
||||
integrations_query = integrations_query.filter(id=integration_id)
|
||||
|
||||
integrations = []
|
||||
overall_healthy = True
|
||||
last_sync = None
|
||||
|
||||
for integration in integrations_query:
|
||||
mismatch_count = self._count_mismatches(integration)
|
||||
is_healthy = (
|
||||
integration.sync_status == 'success' and
|
||||
mismatch_count == 0 and
|
||||
(not integration.sync_error or integration.sync_error == '')
|
||||
)
|
||||
|
||||
if not is_healthy:
|
||||
overall_healthy = False
|
||||
|
||||
if integration.last_sync_at:
|
||||
if last_sync is None or integration.last_sync_at > last_sync:
|
||||
last_sync = integration.last_sync_at
|
||||
|
||||
integrations.append({
|
||||
'id': integration.id,
|
||||
'platform': integration.platform,
|
||||
'status': integration.sync_status,
|
||||
'last_sync_at': integration.last_sync_at.isoformat() if integration.last_sync_at else None,
|
||||
'sync_enabled': integration.sync_enabled,
|
||||
'is_healthy': is_healthy,
|
||||
'error': integration.sync_error,
|
||||
'mismatch_count': mismatch_count
|
||||
})
|
||||
|
||||
# Determine overall status
|
||||
if overall_healthy:
|
||||
overall_status = 'healthy'
|
||||
elif any(i['status'] == 'failed' for i in integrations):
|
||||
overall_status = 'error'
|
||||
else:
|
||||
overall_status = 'warning'
|
||||
|
||||
return {
|
||||
'site_id': site_id,
|
||||
'integrations': integrations,
|
||||
'overall_status': overall_status,
|
||||
'last_sync_at': last_sync.isoformat() if last_sync else None
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting sync status: {e}", exc_info=True)
|
||||
return {
|
||||
'site_id': site_id,
|
||||
'integrations': [],
|
||||
'overall_status': 'error',
|
||||
'last_sync_at': None,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def get_mismatches(
|
||||
self,
|
||||
site_id: int,
|
||||
integration_id: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get detailed mismatch information.
|
||||
|
||||
Args:
|
||||
site_id: Site ID
|
||||
integration_id: Optional integration ID
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'taxonomies': {
|
||||
'missing_in_wordpress': List[Dict],
|
||||
'missing_in_igny8': List[Dict],
|
||||
'mismatched': List[Dict]
|
||||
},
|
||||
'products': {
|
||||
'missing_in_wordpress': List[Dict],
|
||||
'missing_in_igny8': List[Dict]
|
||||
},
|
||||
'posts': {
|
||||
'missing_in_wordpress': List[Dict],
|
||||
'missing_in_igny8': List[Dict]
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
integrations_query = SiteIntegration.objects.filter(
|
||||
site_id=site_id,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
if integration_id:
|
||||
integrations_query = integrations_query.filter(id=integration_id)
|
||||
|
||||
all_mismatches = {
|
||||
'taxonomies': {
|
||||
'missing_in_wordpress': [],
|
||||
'missing_in_igny8': [],
|
||||
'mismatched': []
|
||||
},
|
||||
'products': {
|
||||
'missing_in_wordpress': [],
|
||||
'missing_in_igny8': []
|
||||
},
|
||||
'posts': {
|
||||
'missing_in_wordpress': [],
|
||||
'missing_in_igny8': []
|
||||
}
|
||||
}
|
||||
|
||||
for integration in integrations_query:
|
||||
if integration.platform == 'wordpress':
|
||||
mismatches = self._detect_wordpress_mismatches(integration)
|
||||
# Merge mismatches
|
||||
for key in all_mismatches:
|
||||
if key in mismatches:
|
||||
all_mismatches[key]['missing_in_wordpress'].extend(
|
||||
mismatches[key].get('missing_in_wordpress', [])
|
||||
)
|
||||
all_mismatches[key]['missing_in_igny8'].extend(
|
||||
mismatches[key].get('missing_in_igny8', [])
|
||||
)
|
||||
if 'mismatched' in mismatches[key]:
|
||||
all_mismatches[key]['mismatched'].extend(
|
||||
mismatches[key]['mismatched']
|
||||
)
|
||||
|
||||
return all_mismatches
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting mismatches: {e}", exc_info=True)
|
||||
return {
|
||||
'taxonomies': {'missing_in_wordpress': [], 'missing_in_igny8': [], 'mismatched': []},
|
||||
'products': {'missing_in_wordpress': [], 'missing_in_igny8': []},
|
||||
'posts': {'missing_in_wordpress': [], 'missing_in_igny8': []},
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def get_sync_logs(
|
||||
self,
|
||||
site_id: int,
|
||||
integration_id: Optional[int] = None,
|
||||
limit: int = 100
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get sync logs for a site or integration.
|
||||
|
||||
Args:
|
||||
site_id: Site ID
|
||||
integration_id: Optional integration ID
|
||||
limit: Maximum number of logs to return
|
||||
|
||||
Returns:
|
||||
List of log dictionaries
|
||||
"""
|
||||
try:
|
||||
integrations_query = SiteIntegration.objects.filter(
|
||||
site_id=site_id,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
if integration_id:
|
||||
integrations_query = integrations_query.filter(id=integration_id)
|
||||
|
||||
logs = []
|
||||
for integration in integrations_query:
|
||||
# Use SiteIntegration fields as log entries
|
||||
if integration.last_sync_at:
|
||||
logs.append({
|
||||
'integration_id': integration.id,
|
||||
'platform': integration.platform,
|
||||
'timestamp': integration.last_sync_at.isoformat(),
|
||||
'status': integration.sync_status,
|
||||
'error': integration.sync_error,
|
||||
'duration': None, # Not tracked in current model
|
||||
'items_processed': None # Not tracked in current model
|
||||
})
|
||||
|
||||
# Sort by timestamp descending
|
||||
logs.sort(key=lambda x: x['timestamp'] or '', reverse=True)
|
||||
|
||||
return logs[:limit]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting sync logs: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
def record_sync_run(
|
||||
self,
|
||||
integration_id: int,
|
||||
result: Dict[str, Any]
|
||||
) -> None:
|
||||
"""
|
||||
Record a sync run result.
|
||||
|
||||
Args:
|
||||
integration_id: Integration ID
|
||||
result: Sync result dict from ContentSyncService
|
||||
"""
|
||||
try:
|
||||
integration = SiteIntegration.objects.get(id=integration_id)
|
||||
|
||||
if result.get('success'):
|
||||
integration.sync_status = 'success'
|
||||
integration.last_sync_at = timezone.now()
|
||||
integration.sync_error = None
|
||||
else:
|
||||
integration.sync_status = 'failed'
|
||||
integration.sync_error = result.get('error', 'Unknown error')
|
||||
|
||||
integration.save(update_fields=['sync_status', 'last_sync_at', 'sync_error', 'updated_at'])
|
||||
|
||||
logger.info(
|
||||
f"[SyncHealthService] Recorded sync run for integration {integration_id}: "
|
||||
f"status={integration.sync_status}, synced_count={result.get('synced_count', 0)}"
|
||||
)
|
||||
except SiteIntegration.DoesNotExist:
|
||||
logger.warning(f"Integration {integration_id} not found for sync recording")
|
||||
except Exception as e:
|
||||
logger.error(f"Error recording sync run: {e}", exc_info=True)
|
||||
|
||||
def _count_mismatches(self, integration: SiteIntegration) -> int:
|
||||
"""
|
||||
Count total mismatches for an integration.
|
||||
|
||||
Args:
|
||||
integration: SiteIntegration instance
|
||||
|
||||
Returns:
|
||||
int: Total mismatch count
|
||||
"""
|
||||
try:
|
||||
if integration.platform != 'wordpress':
|
||||
return 0
|
||||
|
||||
mismatches = self._detect_wordpress_mismatches(integration)
|
||||
count = 0
|
||||
for category in mismatches.values():
|
||||
count += len(category.get('missing_in_wordpress', []))
|
||||
count += len(category.get('missing_in_igny8', []))
|
||||
count += len(category.get('mismatched', []))
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.warning(f"Error counting mismatches: {e}")
|
||||
return 0
|
||||
|
||||
def _detect_wordpress_mismatches(
|
||||
self,
|
||||
integration: SiteIntegration
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Detect mismatches between IGNY8 and WordPress.
|
||||
|
||||
Args:
|
||||
integration: SiteIntegration instance
|
||||
|
||||
Returns:
|
||||
dict: Mismatch details
|
||||
"""
|
||||
mismatches = {
|
||||
'taxonomies': {
|
||||
'missing_in_wordpress': [],
|
||||
'missing_in_igny8': [],
|
||||
'mismatched': []
|
||||
},
|
||||
'products': {
|
||||
'missing_in_wordpress': [],
|
||||
'missing_in_igny8': []
|
||||
},
|
||||
'posts': {
|
||||
'missing_in_wordpress': [],
|
||||
'missing_in_igny8': []
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
from igny8_core.utils.wordpress import WordPressClient
|
||||
from igny8_core.business.site_building.models import SiteBlueprint, SiteBlueprintTaxonomy
|
||||
from igny8_core.business.content.models import Content
|
||||
|
||||
credentials = integration.get_credentials()
|
||||
client = WordPressClient(
|
||||
site_url=integration.config_json.get('site_url', ''),
|
||||
username=credentials.get('username'),
|
||||
app_password=credentials.get('app_password')
|
||||
)
|
||||
|
||||
# Get site blueprint
|
||||
blueprint = SiteBlueprint.objects.filter(
|
||||
account=integration.account,
|
||||
site=integration.site
|
||||
).first()
|
||||
|
||||
if not blueprint:
|
||||
return mismatches
|
||||
|
||||
# Check taxonomy mismatches
|
||||
# Get IGNY8 taxonomies
|
||||
igny8_taxonomies = SiteBlueprintTaxonomy.objects.filter(
|
||||
site_blueprint=blueprint
|
||||
)
|
||||
|
||||
# Get WordPress categories
|
||||
wp_categories = client.get_categories(per_page=100)
|
||||
wp_category_ids = {str(cat['id']): cat for cat in wp_categories}
|
||||
|
||||
# Get WordPress tags
|
||||
wp_tags = client.get_tags(per_page=100)
|
||||
wp_tag_ids = {str(tag['id']): tag for tag in wp_tags}
|
||||
|
||||
for taxonomy in igny8_taxonomies:
|
||||
if taxonomy.external_reference:
|
||||
# Check if still exists in WordPress
|
||||
if taxonomy.taxonomy_type in ['blog_category', 'product_category']:
|
||||
if taxonomy.external_reference not in wp_category_ids:
|
||||
mismatches['taxonomies']['missing_in_wordpress'].append({
|
||||
'id': taxonomy.id,
|
||||
'name': taxonomy.name,
|
||||
'type': taxonomy.taxonomy_type,
|
||||
'external_reference': taxonomy.external_reference
|
||||
})
|
||||
elif taxonomy.taxonomy_type in ['blog_tag', 'product_tag']:
|
||||
if taxonomy.external_reference not in wp_tag_ids:
|
||||
mismatches['taxonomies']['missing_in_wordpress'].append({
|
||||
'id': taxonomy.id,
|
||||
'name': taxonomy.name,
|
||||
'type': taxonomy.taxonomy_type,
|
||||
'external_reference': taxonomy.external_reference
|
||||
})
|
||||
else:
|
||||
# Taxonomy exists in IGNY8 but not synced to WordPress
|
||||
mismatches['taxonomies']['missing_in_wordpress'].append({
|
||||
'id': taxonomy.id,
|
||||
'name': taxonomy.name,
|
||||
'type': taxonomy.taxonomy_type
|
||||
})
|
||||
|
||||
# Check for WordPress taxonomies not in IGNY8
|
||||
for cat in wp_categories:
|
||||
if not SiteBlueprintTaxonomy.objects.filter(
|
||||
site_blueprint=blueprint,
|
||||
external_reference=str(cat['id'])
|
||||
).exists():
|
||||
mismatches['taxonomies']['missing_in_igny8'].append({
|
||||
'name': cat['name'],
|
||||
'slug': cat['slug'],
|
||||
'type': 'blog_category',
|
||||
'external_reference': str(cat['id'])
|
||||
})
|
||||
|
||||
for tag in wp_tags:
|
||||
if not SiteBlueprintTaxonomy.objects.filter(
|
||||
site_blueprint=blueprint,
|
||||
external_reference=str(tag['id'])
|
||||
).exists():
|
||||
mismatches['taxonomies']['missing_in_igny8'].append({
|
||||
'name': tag['name'],
|
||||
'slug': tag['slug'],
|
||||
'type': 'blog_tag',
|
||||
'external_reference': str(tag['id'])
|
||||
})
|
||||
|
||||
# Check content mismatches (basic check)
|
||||
igny8_content = Content.objects.filter(
|
||||
account=integration.account,
|
||||
site=integration.site,
|
||||
source='igny8',
|
||||
status='publish'
|
||||
)
|
||||
|
||||
for content in igny8_content[:50]: # Limit check
|
||||
if content.metadata and content.metadata.get('wordpress_id'):
|
||||
# Content should exist in WordPress (would need to check)
|
||||
# For now, just note if metadata exists
|
||||
pass
|
||||
else:
|
||||
# Content not synced to WordPress
|
||||
mismatches['posts']['missing_in_wordpress'].append({
|
||||
'id': content.id,
|
||||
'title': content.title,
|
||||
'type': content.content_type
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error detecting WordPress mismatches: {e}")
|
||||
|
||||
return mismatches
|
||||
|
||||
@@ -26,6 +26,7 @@ class SyncService:
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Perform synchronization.
|
||||
Stage 4: Enhanced to record sync runs for health tracking.
|
||||
|
||||
Args:
|
||||
integration: SiteIntegration instance
|
||||
@@ -74,13 +75,23 @@ class SyncService:
|
||||
|
||||
total_synced = to_result.get('synced_count', 0) + from_result.get('synced_count', 0)
|
||||
|
||||
return {
|
||||
result = {
|
||||
'success': to_result.get('success') and from_result.get('success'),
|
||||
'synced_count': total_synced,
|
||||
'to_external': to_result,
|
||||
'from_external': from_result
|
||||
}
|
||||
|
||||
# Stage 4: Record sync run for health tracking
|
||||
try:
|
||||
from igny8_core.business.integration.services.sync_health_service import SyncHealthService
|
||||
sync_health_service = SyncHealthService()
|
||||
sync_health_service.record_sync_run(integration.id, result)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to record sync run: {e}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[SyncService] Error syncing integration {integration.id}: {str(e)}",
|
||||
@@ -91,11 +102,21 @@ class SyncService:
|
||||
integration.sync_error = str(e)
|
||||
integration.save(update_fields=['sync_status', 'sync_error', 'updated_at'])
|
||||
|
||||
return {
|
||||
error_result = {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'synced_count': 0
|
||||
}
|
||||
|
||||
# Stage 4: Record failed sync run
|
||||
try:
|
||||
from igny8_core.business.integration.services.sync_health_service import SyncHealthService
|
||||
sync_health_service = SyncHealthService()
|
||||
sync_health_service.record_sync_run(integration.id, error_result)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to record sync run: {e}")
|
||||
|
||||
return error_result
|
||||
|
||||
def _sync_to_external(
|
||||
self,
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""
|
||||
Sites Renderer Adapter
|
||||
Phase 5: Sites Renderer & Publishing
|
||||
Stage 4: Enhanced with Stage 3 metadata (clusters, taxonomies, internal links)
|
||||
|
||||
Adapter for deploying sites to IGNY8 Sites renderer.
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Dict, Any, Optional, List
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
@@ -106,6 +107,7 @@ class SitesRendererAdapter(BaseAdapter):
|
||||
"""
|
||||
Build site definition JSON from blueprint.
|
||||
Merges actual Content from Writer into PageBlueprint blocks.
|
||||
Stage 4: Enhanced with Stage 3 metadata (clusters, taxonomies, internal links).
|
||||
|
||||
Args:
|
||||
site_blueprint: SiteBlueprint instance
|
||||
@@ -113,13 +115,24 @@ class SitesRendererAdapter(BaseAdapter):
|
||||
Returns:
|
||||
dict: Site definition structure
|
||||
"""
|
||||
from igny8_core.business.content.models import Tasks, Content
|
||||
from igny8_core.business.content.models import Tasks, Content, ContentClusterMap, ContentTaxonomyMap
|
||||
|
||||
# Get all pages
|
||||
pages = []
|
||||
content_id_to_page = {} # Map content IDs to pages for metadata lookup
|
||||
|
||||
for page in site_blueprint.pages.all().order_by('order'):
|
||||
# Get blocks from blueprint (placeholders)
|
||||
blocks = page.blocks_json or []
|
||||
page_metadata = {
|
||||
'entity_type': page.entity_type if hasattr(page, 'entity_type') else None,
|
||||
'cluster_id': None,
|
||||
'cluster_name': None,
|
||||
'cluster_role': None,
|
||||
'taxonomy_id': None,
|
||||
'taxonomy_name': None,
|
||||
'internal_links': []
|
||||
}
|
||||
|
||||
# Try to find actual Content from Writer
|
||||
# PageBlueprint -> Task (by title pattern) -> Content
|
||||
@@ -155,6 +168,43 @@ class SitesRendererAdapter(BaseAdapter):
|
||||
logger.info(
|
||||
f"[SitesRendererAdapter] Converted HTML content to text block for page {page.slug}"
|
||||
)
|
||||
|
||||
# Stage 4: Add Stage 3 metadata if content exists
|
||||
if content:
|
||||
content_id_to_page[content.id] = page.slug
|
||||
|
||||
# Get cluster mapping
|
||||
cluster_map = ContentClusterMap.objects.filter(content=content).first()
|
||||
if cluster_map and cluster_map.cluster:
|
||||
page_metadata['cluster_id'] = cluster_map.cluster.id
|
||||
page_metadata['cluster_name'] = cluster_map.cluster.name
|
||||
page_metadata['cluster_role'] = cluster_map.role or task.cluster_role if task else None
|
||||
|
||||
# Get taxonomy mapping
|
||||
taxonomy_map = ContentTaxonomyMap.objects.filter(content=content).first()
|
||||
if taxonomy_map and taxonomy_map.taxonomy:
|
||||
page_metadata['taxonomy_id'] = taxonomy_map.taxonomy.id
|
||||
page_metadata['taxonomy_name'] = taxonomy_map.taxonomy.name
|
||||
|
||||
# Get internal links from content
|
||||
if content.internal_links:
|
||||
page_metadata['internal_links'] = content.internal_links
|
||||
|
||||
# Use content entity_type if available
|
||||
if content.entity_type:
|
||||
page_metadata['entity_type'] = content.entity_type
|
||||
|
||||
# Fallback to task metadata if content not found
|
||||
if task and not page_metadata.get('cluster_id'):
|
||||
if task.cluster:
|
||||
page_metadata['cluster_id'] = task.cluster.id
|
||||
page_metadata['cluster_name'] = task.cluster.name
|
||||
page_metadata['cluster_role'] = task.cluster_role
|
||||
if task.taxonomy:
|
||||
page_metadata['taxonomy_id'] = task.taxonomy.id
|
||||
page_metadata['taxonomy_name'] = task.taxonomy.name
|
||||
if task.entity_type:
|
||||
page_metadata['entity_type'] = task.entity_type
|
||||
|
||||
pages.append({
|
||||
'id': page.id,
|
||||
@@ -163,8 +213,15 @@ class SitesRendererAdapter(BaseAdapter):
|
||||
'type': page.type,
|
||||
'blocks': blocks,
|
||||
'status': page.status,
|
||||
'metadata': page_metadata, # Stage 4: Add metadata
|
||||
})
|
||||
|
||||
# Stage 4: Build navigation with cluster grouping
|
||||
navigation = self._build_navigation_with_metadata(site_blueprint, pages)
|
||||
|
||||
# Stage 4: Build taxonomy tree for breadcrumbs
|
||||
taxonomy_tree = self._build_taxonomy_tree(site_blueprint)
|
||||
|
||||
# Build site definition
|
||||
definition = {
|
||||
'id': site_blueprint.id,
|
||||
@@ -173,7 +230,8 @@ class SitesRendererAdapter(BaseAdapter):
|
||||
'version': site_blueprint.version,
|
||||
'layout': site_blueprint.structure_json.get('layout', 'default'),
|
||||
'theme': site_blueprint.structure_json.get('theme', {}),
|
||||
'navigation': site_blueprint.structure_json.get('navigation', []),
|
||||
'navigation': navigation, # Stage 4: Enhanced navigation
|
||||
'taxonomy_tree': taxonomy_tree, # Stage 4: Taxonomy tree for breadcrumbs
|
||||
'pages': pages,
|
||||
'config': site_blueprint.config_json,
|
||||
'created_at': site_blueprint.created_at.isoformat(),
|
||||
@@ -182,6 +240,116 @@ class SitesRendererAdapter(BaseAdapter):
|
||||
|
||||
return definition
|
||||
|
||||
def _build_navigation_with_metadata(
|
||||
self,
|
||||
site_blueprint: SiteBlueprint,
|
||||
pages: List[Dict[str, Any]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Build navigation structure with cluster grouping.
|
||||
Stage 4: Groups pages by cluster for better navigation.
|
||||
|
||||
Args:
|
||||
site_blueprint: SiteBlueprint instance
|
||||
pages: List of page dictionaries
|
||||
|
||||
Returns:
|
||||
List of navigation items
|
||||
"""
|
||||
# If explicit navigation exists in structure_json, use it
|
||||
explicit_nav = site_blueprint.structure_json.get('navigation', [])
|
||||
if explicit_nav:
|
||||
return explicit_nav
|
||||
|
||||
# Otherwise, build navigation from pages grouped by cluster
|
||||
navigation = []
|
||||
|
||||
# Group pages by cluster
|
||||
cluster_groups = {}
|
||||
ungrouped_pages = []
|
||||
|
||||
for page in pages:
|
||||
if page.get('status') in ['published', 'ready']:
|
||||
cluster_id = page.get('metadata', {}).get('cluster_id')
|
||||
if cluster_id:
|
||||
if cluster_id not in cluster_groups:
|
||||
cluster_groups[cluster_id] = {
|
||||
'cluster_id': cluster_id,
|
||||
'cluster_name': page.get('metadata', {}).get('cluster_name', 'Unknown'),
|
||||
'pages': []
|
||||
}
|
||||
cluster_groups[cluster_id]['pages'].append({
|
||||
'slug': page['slug'],
|
||||
'title': page['title'],
|
||||
'type': page['type']
|
||||
})
|
||||
else:
|
||||
ungrouped_pages.append({
|
||||
'slug': page['slug'],
|
||||
'title': page['title'],
|
||||
'type': page['type']
|
||||
})
|
||||
|
||||
# Add cluster groups to navigation
|
||||
for cluster_group in cluster_groups.values():
|
||||
navigation.append({
|
||||
'type': 'cluster',
|
||||
'name': cluster_group['cluster_name'],
|
||||
'items': cluster_group['pages']
|
||||
})
|
||||
|
||||
# Add ungrouped pages
|
||||
if ungrouped_pages:
|
||||
navigation.extend(ungrouped_pages)
|
||||
|
||||
return navigation if navigation else [
|
||||
{'slug': page['slug'], 'title': page['title']}
|
||||
for page in pages
|
||||
if page.get('status') in ['published', 'ready']
|
||||
]
|
||||
|
||||
def _build_taxonomy_tree(self, site_blueprint: SiteBlueprint) -> Dict[str, Any]:
|
||||
"""
|
||||
Build taxonomy tree structure for breadcrumbs.
|
||||
Stage 4: Creates hierarchical taxonomy structure.
|
||||
|
||||
Args:
|
||||
site_blueprint: SiteBlueprint instance
|
||||
|
||||
Returns:
|
||||
dict: Taxonomy tree structure
|
||||
"""
|
||||
taxonomies = site_blueprint.taxonomies.all()
|
||||
|
||||
tree = {
|
||||
'categories': [],
|
||||
'tags': [],
|
||||
'product_categories': [],
|
||||
'product_attributes': []
|
||||
}
|
||||
|
||||
for taxonomy in taxonomies:
|
||||
taxonomy_item = {
|
||||
'id': taxonomy.id,
|
||||
'name': taxonomy.name,
|
||||
'slug': taxonomy.slug,
|
||||
'type': taxonomy.taxonomy_type,
|
||||
'description': taxonomy.description
|
||||
}
|
||||
|
||||
if taxonomy.taxonomy_type in ['blog_category', 'product_category']:
|
||||
category_key = 'product_categories' if 'product' in taxonomy.taxonomy_type else 'categories'
|
||||
tree[category_key].append(taxonomy_item)
|
||||
elif taxonomy.taxonomy_type in ['blog_tag', 'product_tag']:
|
||||
tag_key = 'product_tags' if 'product' in taxonomy.taxonomy_type else 'tags'
|
||||
if tag_key not in tree:
|
||||
tree[tag_key] = []
|
||||
tree[tag_key].append(taxonomy_item)
|
||||
elif taxonomy.taxonomy_type == 'product_attribute':
|
||||
tree['product_attributes'].append(taxonomy_item)
|
||||
|
||||
return tree
|
||||
|
||||
def _write_site_definition(
|
||||
self,
|
||||
site_blueprint: SiteBlueprint,
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
"""
|
||||
Deployment Readiness Service
|
||||
Stage 4: Checks if site blueprint is ready for deployment
|
||||
|
||||
Validates cluster coverage, content validation, sync status, and taxonomy completeness.
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Any, List
|
||||
|
||||
from igny8_core.business.site_building.models import SiteBlueprint
|
||||
from igny8_core.business.content.services.validation_service import ContentValidationService
|
||||
from igny8_core.business.integration.services.sync_health_service import SyncHealthService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeploymentReadinessService:
|
||||
"""
|
||||
Service for checking deployment readiness.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.validation_service = ContentValidationService()
|
||||
self.sync_health_service = SyncHealthService()
|
||||
|
||||
def check_readiness(self, site_blueprint_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Check if site blueprint is ready for deployment.
|
||||
|
||||
Args:
|
||||
site_blueprint_id: SiteBlueprint ID
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'ready': bool,
|
||||
'checks': {
|
||||
'cluster_coverage': bool,
|
||||
'content_validation': bool,
|
||||
'sync_status': bool,
|
||||
'taxonomy_completeness': bool
|
||||
},
|
||||
'errors': List[str],
|
||||
'warnings': List[str],
|
||||
'details': {
|
||||
'cluster_coverage': dict,
|
||||
'content_validation': dict,
|
||||
'sync_status': dict,
|
||||
'taxonomy_completeness': dict
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
blueprint = SiteBlueprint.objects.get(id=site_blueprint_id)
|
||||
except SiteBlueprint.DoesNotExist:
|
||||
return {
|
||||
'ready': False,
|
||||
'checks': {},
|
||||
'errors': [f'SiteBlueprint {site_blueprint_id} not found'],
|
||||
'warnings': [],
|
||||
'details': {}
|
||||
}
|
||||
|
||||
checks = {}
|
||||
errors = []
|
||||
warnings = []
|
||||
details = {}
|
||||
|
||||
# Check 1: Cluster Coverage
|
||||
cluster_check = self._check_cluster_coverage(blueprint)
|
||||
checks['cluster_coverage'] = cluster_check['ready']
|
||||
details['cluster_coverage'] = cluster_check
|
||||
if not cluster_check['ready']:
|
||||
errors.extend(cluster_check.get('errors', []))
|
||||
if cluster_check.get('warnings'):
|
||||
warnings.extend(cluster_check['warnings'])
|
||||
|
||||
# Check 2: Content Validation
|
||||
content_check = self._check_content_validation(blueprint)
|
||||
checks['content_validation'] = content_check['ready']
|
||||
details['content_validation'] = content_check
|
||||
if not content_check['ready']:
|
||||
errors.extend(content_check.get('errors', []))
|
||||
if content_check.get('warnings'):
|
||||
warnings.extend(content_check['warnings'])
|
||||
|
||||
# Check 3: Sync Status (if WordPress integration exists)
|
||||
sync_check = self._check_sync_status(blueprint)
|
||||
checks['sync_status'] = sync_check['ready']
|
||||
details['sync_status'] = sync_check
|
||||
if not sync_check['ready']:
|
||||
warnings.extend(sync_check.get('warnings', []))
|
||||
if sync_check.get('errors'):
|
||||
errors.extend(sync_check['errors'])
|
||||
|
||||
# Check 4: Taxonomy Completeness
|
||||
taxonomy_check = self._check_taxonomy_completeness(blueprint)
|
||||
checks['taxonomy_completeness'] = taxonomy_check['ready']
|
||||
details['taxonomy_completeness'] = taxonomy_check
|
||||
if not taxonomy_check['ready']:
|
||||
warnings.extend(taxonomy_check.get('warnings', []))
|
||||
if taxonomy_check.get('errors'):
|
||||
errors.extend(taxonomy_check['errors'])
|
||||
|
||||
# Overall readiness: all critical checks must pass
|
||||
ready = (
|
||||
checks.get('cluster_coverage', False) and
|
||||
checks.get('content_validation', False)
|
||||
)
|
||||
|
||||
return {
|
||||
'ready': ready,
|
||||
'checks': checks,
|
||||
'errors': errors,
|
||||
'warnings': warnings,
|
||||
'details': details
|
||||
}
|
||||
|
||||
def _check_cluster_coverage(self, blueprint: SiteBlueprint) -> Dict[str, Any]:
|
||||
"""
|
||||
Check if all clusters have required coverage.
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'ready': bool,
|
||||
'total_clusters': int,
|
||||
'covered_clusters': int,
|
||||
'incomplete_clusters': List[Dict],
|
||||
'errors': List[str],
|
||||
'warnings': List[str]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
cluster_links = blueprint.cluster_links.all()
|
||||
total_clusters = cluster_links.count()
|
||||
|
||||
if total_clusters == 0:
|
||||
return {
|
||||
'ready': False,
|
||||
'total_clusters': 0,
|
||||
'covered_clusters': 0,
|
||||
'incomplete_clusters': [],
|
||||
'errors': ['No clusters attached to blueprint'],
|
||||
'warnings': []
|
||||
}
|
||||
|
||||
incomplete_clusters = []
|
||||
covered_count = 0
|
||||
|
||||
for cluster_link in cluster_links:
|
||||
if cluster_link.coverage_status == 'complete':
|
||||
covered_count += 1
|
||||
else:
|
||||
incomplete_clusters.append({
|
||||
'cluster_id': cluster_link.cluster_id,
|
||||
'cluster_name': getattr(cluster_link.cluster, 'name', 'Unknown'),
|
||||
'status': cluster_link.coverage_status,
|
||||
'role': cluster_link.role
|
||||
})
|
||||
|
||||
ready = covered_count == total_clusters
|
||||
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
if not ready:
|
||||
if covered_count == 0:
|
||||
errors.append('No clusters have complete coverage')
|
||||
else:
|
||||
warnings.append(
|
||||
f'{total_clusters - covered_count} of {total_clusters} clusters need coverage'
|
||||
)
|
||||
|
||||
return {
|
||||
'ready': ready,
|
||||
'total_clusters': total_clusters,
|
||||
'covered_clusters': covered_count,
|
||||
'incomplete_clusters': incomplete_clusters,
|
||||
'errors': errors,
|
||||
'warnings': warnings
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking cluster coverage: {e}", exc_info=True)
|
||||
return {
|
||||
'ready': False,
|
||||
'total_clusters': 0,
|
||||
'covered_clusters': 0,
|
||||
'incomplete_clusters': [],
|
||||
'errors': [f'Error checking cluster coverage: {str(e)}'],
|
||||
'warnings': []
|
||||
}
|
||||
|
||||
def _check_content_validation(self, blueprint: SiteBlueprint) -> Dict[str, Any]:
|
||||
"""
|
||||
Check if all published content passes validation.
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'ready': bool,
|
||||
'total_content': int,
|
||||
'valid_content': int,
|
||||
'invalid_content': List[Dict],
|
||||
'errors': List[str],
|
||||
'warnings': List[str]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
from igny8_core.business.content.models import Content, Tasks
|
||||
|
||||
# Get all content associated with this blueprint
|
||||
# Content is linked via Tasks -> PageBlueprint -> SiteBlueprint
|
||||
page_ids = blueprint.pages.values_list('id', flat=True)
|
||||
|
||||
# Find tasks that match page blueprints
|
||||
tasks = Tasks.objects.filter(
|
||||
account=blueprint.account,
|
||||
site=blueprint.site,
|
||||
sector=blueprint.sector
|
||||
)
|
||||
|
||||
# Filter tasks that might be related to this blueprint
|
||||
# (This is a simplified check - in practice, tasks should have blueprint reference)
|
||||
content_items = Content.objects.filter(
|
||||
task__in=tasks,
|
||||
status='publish',
|
||||
source='igny8'
|
||||
)
|
||||
|
||||
total_content = content_items.count()
|
||||
|
||||
if total_content == 0:
|
||||
return {
|
||||
'ready': True, # No content to validate is OK
|
||||
'total_content': 0,
|
||||
'valid_content': 0,
|
||||
'invalid_content': [],
|
||||
'errors': [],
|
||||
'warnings': ['No published content found for validation']
|
||||
}
|
||||
|
||||
invalid_content = []
|
||||
valid_count = 0
|
||||
|
||||
for content in content_items:
|
||||
errors = self.validation_service.validate_for_publish(content)
|
||||
if errors:
|
||||
invalid_content.append({
|
||||
'content_id': content.id,
|
||||
'title': content.title or 'Untitled',
|
||||
'errors': errors
|
||||
})
|
||||
else:
|
||||
valid_count += 1
|
||||
|
||||
ready = len(invalid_content) == 0
|
||||
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
if not ready:
|
||||
errors.append(
|
||||
f'{len(invalid_content)} of {total_content} content items have validation errors'
|
||||
)
|
||||
|
||||
return {
|
||||
'ready': ready,
|
||||
'total_content': total_content,
|
||||
'valid_content': valid_count,
|
||||
'invalid_content': invalid_content,
|
||||
'errors': errors,
|
||||
'warnings': warnings
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking content validation: {e}", exc_info=True)
|
||||
return {
|
||||
'ready': False,
|
||||
'total_content': 0,
|
||||
'valid_content': 0,
|
||||
'invalid_content': [],
|
||||
'errors': [f'Error checking content validation: {str(e)}'],
|
||||
'warnings': []
|
||||
}
|
||||
|
||||
def _check_sync_status(self, blueprint: SiteBlueprint) -> Dict[str, Any]:
|
||||
"""
|
||||
Check sync status for WordPress integrations.
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'ready': bool,
|
||||
'has_integration': bool,
|
||||
'sync_status': str,
|
||||
'mismatch_count': int,
|
||||
'errors': List[str],
|
||||
'warnings': List[str]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
from igny8_core.business.integration.models import SiteIntegration
|
||||
|
||||
integrations = SiteIntegration.objects.filter(
|
||||
site=blueprint.site,
|
||||
is_active=True,
|
||||
platform='wordpress'
|
||||
)
|
||||
|
||||
if not integrations.exists():
|
||||
return {
|
||||
'ready': True, # No WordPress integration is OK
|
||||
'has_integration': False,
|
||||
'sync_status': None,
|
||||
'mismatch_count': 0,
|
||||
'errors': [],
|
||||
'warnings': []
|
||||
}
|
||||
|
||||
# Get sync status from SyncHealthService
|
||||
sync_status = self.sync_health_service.get_sync_status(blueprint.site.id)
|
||||
|
||||
overall_status = sync_status.get('overall_status', 'error')
|
||||
is_healthy = overall_status == 'healthy'
|
||||
|
||||
# Count total mismatches
|
||||
mismatch_count = sum(
|
||||
i.get('mismatch_count', 0) for i in sync_status.get('integrations', [])
|
||||
)
|
||||
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
if not is_healthy:
|
||||
if overall_status == 'error':
|
||||
errors.append('WordPress sync has errors')
|
||||
else:
|
||||
warnings.append('WordPress sync has warnings')
|
||||
|
||||
if mismatch_count > 0:
|
||||
warnings.append(f'{mismatch_count} sync mismatches detected')
|
||||
|
||||
# Sync status doesn't block deployment, but should be warned
|
||||
return {
|
||||
'ready': True, # Sync issues are warnings, not blockers
|
||||
'has_integration': True,
|
||||
'sync_status': overall_status,
|
||||
'mismatch_count': mismatch_count,
|
||||
'errors': errors,
|
||||
'warnings': warnings
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking sync status: {e}", exc_info=True)
|
||||
return {
|
||||
'ready': True, # Don't block on sync check errors
|
||||
'has_integration': False,
|
||||
'sync_status': None,
|
||||
'mismatch_count': 0,
|
||||
'errors': [],
|
||||
'warnings': [f'Could not check sync status: {str(e)}']
|
||||
}
|
||||
|
||||
def _check_taxonomy_completeness(self, blueprint: SiteBlueprint) -> Dict[str, Any]:
|
||||
"""
|
||||
Check if taxonomies are complete for the site type.
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'ready': bool,
|
||||
'total_taxonomies': int,
|
||||
'required_taxonomies': List[str],
|
||||
'missing_taxonomies': List[str],
|
||||
'errors': List[str],
|
||||
'warnings': List[str]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
taxonomies = blueprint.taxonomies.all()
|
||||
total_taxonomies = taxonomies.count()
|
||||
|
||||
# Determine required taxonomies based on site type
|
||||
site_type = blueprint.site.site_type if hasattr(blueprint.site, 'site_type') else None
|
||||
|
||||
required_types = []
|
||||
if site_type == 'blog':
|
||||
required_types = ['blog_category', 'blog_tag']
|
||||
elif site_type == 'ecommerce':
|
||||
required_types = ['product_category', 'product_tag', 'product_attribute']
|
||||
elif site_type == 'company':
|
||||
required_types = ['service_category']
|
||||
|
||||
existing_types = set(taxonomies.values_list('taxonomy_type', flat=True))
|
||||
missing_types = set(required_types) - existing_types
|
||||
|
||||
ready = len(missing_types) == 0
|
||||
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
if not ready:
|
||||
warnings.append(
|
||||
f'Missing required taxonomies for {site_type} site: {", ".join(missing_types)}'
|
||||
)
|
||||
|
||||
if total_taxonomies == 0:
|
||||
warnings.append('No taxonomies defined')
|
||||
|
||||
return {
|
||||
'ready': ready,
|
||||
'total_taxonomies': total_taxonomies,
|
||||
'required_taxonomies': required_types,
|
||||
'missing_taxonomies': list(missing_types),
|
||||
'errors': errors,
|
||||
'warnings': warnings
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking taxonomy completeness: {e}", exc_info=True)
|
||||
return {
|
||||
'ready': True, # Don't block on taxonomy check errors
|
||||
'total_taxonomies': 0,
|
||||
'required_taxonomies': [],
|
||||
'missing_taxonomies': [],
|
||||
'errors': [],
|
||||
'warnings': [f'Could not check taxonomy completeness: {str(e)}']
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||
from igny8_core.business.integration.models import SiteIntegration
|
||||
from igny8_core.business.integration.services.integration_service import IntegrationService
|
||||
from igny8_core.business.integration.services.sync_service import SyncService
|
||||
from igny8_core.business.integration.services.sync_health_service import SyncHealthService
|
||||
from igny8_core.business.integration.services.content_sync_service import ContentSyncService
|
||||
|
||||
|
||||
class IntegrationViewSet(SiteSectorModelViewSet):
|
||||
@@ -93,4 +95,193 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
||||
status_data = sync_service.get_sync_status(integration)
|
||||
|
||||
return success_response(status_data, request=request)
|
||||
|
||||
# Stage 4: Site-level sync endpoints
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='sites/(?P<site_id>[^/.]+)/sync/status')
|
||||
def sync_status_by_site(self, request, site_id=None):
|
||||
"""
|
||||
Get sync status for all integrations on a site.
|
||||
Stage 4: Site-level sync health endpoint.
|
||||
|
||||
GET /api/v1/integration/integrations/sites/{site_id}/sync/status/
|
||||
"""
|
||||
try:
|
||||
site_id_int = int(site_id)
|
||||
except (ValueError, TypeError):
|
||||
return error_response(
|
||||
'Invalid site_id',
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
request
|
||||
)
|
||||
|
||||
# Verify site belongs to user's account
|
||||
from igny8_core.auth.models import Site
|
||||
try:
|
||||
site = Site.objects.get(id=site_id_int, account=request.user.account)
|
||||
except Site.DoesNotExist:
|
||||
return error_response(
|
||||
'Site not found',
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
request
|
||||
)
|
||||
|
||||
sync_health_service = SyncHealthService()
|
||||
status_data = sync_health_service.get_sync_status(site_id_int)
|
||||
|
||||
return success_response(status_data, request=request)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='sites/(?P<site_id>[^/.]+)/sync/run')
|
||||
def run_sync(self, request, site_id=None):
|
||||
"""
|
||||
Trigger sync for all integrations on a site.
|
||||
Stage 4: Site-level sync trigger endpoint.
|
||||
|
||||
POST /api/v1/integration/integrations/sites/{site_id}/sync/run/
|
||||
|
||||
Request body:
|
||||
{
|
||||
"direction": "both", # Optional: 'both', 'to_external', 'from_external'
|
||||
"content_types": ["blog_post", "product"] # Optional
|
||||
}
|
||||
"""
|
||||
try:
|
||||
site_id_int = int(site_id)
|
||||
except (ValueError, TypeError):
|
||||
return error_response(
|
||||
'Invalid site_id',
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
request
|
||||
)
|
||||
|
||||
# Verify site belongs to user's account
|
||||
from igny8_core.auth.models import Site
|
||||
try:
|
||||
site = Site.objects.get(id=site_id_int, account=request.user.account)
|
||||
except Site.DoesNotExist:
|
||||
return error_response(
|
||||
'Site not found',
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
request
|
||||
)
|
||||
|
||||
direction = request.data.get('direction', 'both')
|
||||
content_types = request.data.get('content_types')
|
||||
|
||||
# Get all active integrations for this site
|
||||
integrations = SiteIntegration.objects.filter(
|
||||
site_id=site_id_int,
|
||||
is_active=True,
|
||||
sync_enabled=True
|
||||
)
|
||||
|
||||
if not integrations.exists():
|
||||
return error_response(
|
||||
'No active integrations found for this site',
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
request
|
||||
)
|
||||
|
||||
sync_service = SyncService()
|
||||
sync_health_service = SyncHealthService()
|
||||
results = []
|
||||
|
||||
for integration in integrations:
|
||||
result = sync_service.sync(integration, direction=direction, content_types=content_types)
|
||||
|
||||
# Record sync run
|
||||
sync_health_service.record_sync_run(integration.id, result)
|
||||
|
||||
results.append({
|
||||
'integration_id': integration.id,
|
||||
'platform': integration.platform,
|
||||
'result': result
|
||||
})
|
||||
|
||||
return success_response({
|
||||
'site_id': site_id_int,
|
||||
'sync_results': results,
|
||||
'total_integrations': len(results)
|
||||
}, request=request)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='sites/(?P<site_id>[^/.]+)/sync/mismatches')
|
||||
def get_mismatches(self, request, site_id=None):
|
||||
"""
|
||||
Get sync mismatches for a site.
|
||||
Stage 4: Detailed mismatch information.
|
||||
|
||||
GET /api/v1/integration/integrations/sites/{site_id}/sync/mismatches/
|
||||
"""
|
||||
try:
|
||||
site_id_int = int(site_id)
|
||||
except (ValueError, TypeError):
|
||||
return error_response(
|
||||
'Invalid site_id',
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
request
|
||||
)
|
||||
|
||||
# Verify site belongs to user's account
|
||||
from igny8_core.auth.models import Site
|
||||
try:
|
||||
site = Site.objects.get(id=site_id_int, account=request.user.account)
|
||||
except Site.DoesNotExist:
|
||||
return error_response(
|
||||
'Site not found',
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
request
|
||||
)
|
||||
|
||||
sync_health_service = SyncHealthService()
|
||||
mismatches = sync_health_service.get_mismatches(site_id_int)
|
||||
|
||||
return success_response(mismatches, request=request)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='sites/(?P<site_id>[^/.]+)/sync/logs')
|
||||
def get_sync_logs(self, request, site_id=None):
|
||||
"""
|
||||
Get sync logs for a site.
|
||||
Stage 4: Sync history and logs.
|
||||
|
||||
GET /api/v1/integration/integrations/sites/{site_id}/sync/logs/
|
||||
|
||||
Query params:
|
||||
- limit: Number of logs to return (default: 100)
|
||||
- integration_id: Filter by specific integration
|
||||
"""
|
||||
try:
|
||||
site_id_int = int(site_id)
|
||||
except (ValueError, TypeError):
|
||||
return error_response(
|
||||
'Invalid site_id',
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
request
|
||||
)
|
||||
|
||||
# Verify site belongs to user's account
|
||||
from igny8_core.auth.models import Site
|
||||
try:
|
||||
site = Site.objects.get(id=site_id_int, account=request.user.account)
|
||||
except Site.DoesNotExist:
|
||||
return error_response(
|
||||
'Site not found',
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
request
|
||||
)
|
||||
|
||||
limit = int(request.query_params.get('limit', 100))
|
||||
integration_id = request.query_params.get('integration_id')
|
||||
|
||||
sync_health_service = SyncHealthService()
|
||||
logs = sync_health_service.get_sync_logs(
|
||||
site_id_int,
|
||||
integration_id=int(integration_id) if integration_id else None,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return success_response({
|
||||
'site_id': site_id_int,
|
||||
'logs': logs,
|
||||
'count': len(logs)
|
||||
}, request=request)
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from igny8_core.api.response import success_response, error_response
|
||||
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||
from igny8_core.business.publishing.models import PublishingRecord, DeploymentRecord
|
||||
from igny8_core.business.publishing.services.publisher_service import PublisherService
|
||||
from igny8_core.business.publishing.services.deployment_readiness_service import DeploymentReadinessService
|
||||
from igny8_core.business.site_building.models import SiteBlueprint
|
||||
|
||||
|
||||
@@ -74,6 +75,7 @@ class PublisherViewSet(viewsets.ViewSet):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.publisher_service = PublisherService()
|
||||
self.readiness_service = DeploymentReadinessService()
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='publish')
|
||||
def publish(self, request):
|
||||
@@ -137,12 +139,13 @@ class PublisherViewSet(viewsets.ViewSet):
|
||||
request
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='deploy/(?P<blueprint_id>[^/.]+)')
|
||||
def deploy(self, request, blueprint_id):
|
||||
@action(detail=False, methods=['get'], url_path='blueprints/(?P<blueprint_id>[^/.]+)/readiness')
|
||||
def deployment_readiness(self, request, blueprint_id):
|
||||
"""
|
||||
Deploy site blueprint to Sites renderer.
|
||||
Check deployment readiness for a site blueprint.
|
||||
Stage 4: Pre-deployment validation checks.
|
||||
|
||||
POST /api/v1/publisher/deploy/{blueprint_id}/
|
||||
GET /api/v1/publisher/blueprints/{blueprint_id}/readiness/
|
||||
"""
|
||||
account = request.account
|
||||
|
||||
@@ -155,6 +158,48 @@ class PublisherViewSet(viewsets.ViewSet):
|
||||
request
|
||||
)
|
||||
|
||||
readiness = self.readiness_service.check_readiness(blueprint_id)
|
||||
|
||||
return success_response(readiness, request=request)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='deploy/(?P<blueprint_id>[^/.]+)')
|
||||
def deploy(self, request, blueprint_id):
|
||||
"""
|
||||
Deploy site blueprint to Sites renderer.
|
||||
Stage 4: Enhanced with readiness check (optional).
|
||||
|
||||
POST /api/v1/publisher/deploy/{blueprint_id}/
|
||||
|
||||
Request body (optional):
|
||||
{
|
||||
"skip_readiness_check": false # Set to true to skip readiness validation
|
||||
}
|
||||
"""
|
||||
account = request.account
|
||||
|
||||
try:
|
||||
blueprint = SiteBlueprint.objects.get(id=blueprint_id, account=account)
|
||||
except SiteBlueprint.DoesNotExist:
|
||||
return error_response(
|
||||
f'Site blueprint {blueprint_id} not found',
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
request
|
||||
)
|
||||
|
||||
# Stage 4: Optional readiness check
|
||||
skip_check = request.data.get('skip_readiness_check', False)
|
||||
if not skip_check:
|
||||
readiness = self.readiness_service.check_readiness(blueprint_id)
|
||||
if not readiness.get('ready'):
|
||||
return error_response(
|
||||
{
|
||||
'message': 'Site is not ready for deployment',
|
||||
'readiness': readiness
|
||||
},
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
request
|
||||
)
|
||||
|
||||
result = self.publisher_service.publish_to_sites(blueprint)
|
||||
|
||||
response_status = status.HTTP_202_ACCEPTED if result.get('success') else status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
415
frontend/src/pages/Sites/DeploymentPanel.tsx
Normal file
415
frontend/src/pages/Sites/DeploymentPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -563,6 +563,7 @@ export default function SiteSettings() {
|
||||
onManage={() => setIsIntegrationModalOpen(true)}
|
||||
onSync={handleSyncIntegration}
|
||||
loading={integrationLoading}
|
||||
siteId={siteId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
472
frontend/src/pages/Sites/SyncDashboard.tsx
Normal file
472
frontend/src/pages/Sites/SyncDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user