diff --git a/backend/igny8_core/business/integration/services/sync_health_service.py b/backend/igny8_core/business/integration/services/sync_health_service.py new file mode 100644 index 00000000..450b04a6 --- /dev/null +++ b/backend/igny8_core/business/integration/services/sync_health_service.py @@ -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 + diff --git a/backend/igny8_core/business/integration/services/sync_service.py b/backend/igny8_core/business/integration/services/sync_service.py index b1318a09..8a512919 100644 --- a/backend/igny8_core/business/integration/services/sync_service.py +++ b/backend/igny8_core/business/integration/services/sync_service.py @@ -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, diff --git a/backend/igny8_core/business/publishing/services/adapters/sites_renderer_adapter.py b/backend/igny8_core/business/publishing/services/adapters/sites_renderer_adapter.py index 385d12f4..c91a3cb2 100644 --- a/backend/igny8_core/business/publishing/services/adapters/sites_renderer_adapter.py +++ b/backend/igny8_core/business/publishing/services/adapters/sites_renderer_adapter.py @@ -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, diff --git a/backend/igny8_core/business/publishing/services/deployment_readiness_service.py b/backend/igny8_core/business/publishing/services/deployment_readiness_service.py new file mode 100644 index 00000000..d14a8909 --- /dev/null +++ b/backend/igny8_core/business/publishing/services/deployment_readiness_service.py @@ -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)}'] + } + diff --git a/backend/igny8_core/modules/integration/views.py b/backend/igny8_core/modules/integration/views.py index 7f003e4d..41bd7d9d 100644 --- a/backend/igny8_core/modules/integration/views.py +++ b/backend/igny8_core/modules/integration/views.py @@ -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[^/.]+)/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[^/.]+)/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[^/.]+)/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[^/.]+)/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) diff --git a/backend/igny8_core/modules/publisher/views.py b/backend/igny8_core/modules/publisher/views.py index fd30fed5..d2e57946 100644 --- a/backend/igny8_core/modules/publisher/views.py +++ b/backend/igny8_core/modules/publisher/views.py @@ -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[^/.]+)') - def deploy(self, request, blueprint_id): + @action(detail=False, methods=['get'], url_path='blueprints/(?P[^/.]+)/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[^/.]+)') + 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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 61d53aae..8a04ebe2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> + + + + } /> + + + + } /> diff --git a/frontend/src/components/sites/WordPressIntegrationCard.tsx b/frontend/src/components/sites/WordPressIntegrationCard.tsx index 19991aee..6b080345 100644 --- a/frontend/src/components/sites/WordPressIntegrationCard.tsx +++ b/frontend/src/components/sites/WordPressIntegrationCard.tsx @@ -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 ( @@ -93,15 +100,20 @@ export default function WordPressIntegrationCard({

Sync Status

- {integration.sync_status === 'success' ? ( + {integration.sync_status === 'success' || integration.sync_status === 'healthy' ? ( - ) : integration.sync_status === 'failed' ? ( + ) : integration.sync_status === 'failed' || integration.sync_status === 'error' ? ( + ) : integration.sync_status === 'warning' ? ( + ) : ( )} - {integration.sync_status} + {integration.sync_status === 'healthy' ? 'Healthy' : + integration.sync_status === 'warning' ? 'Warning' : + integration.sync_status === 'error' ? 'Error' : + integration.sync_status}
@@ -115,6 +127,48 @@ export default function WordPressIntegrationCard({ + {/* Sync Health Indicators */} + {(integration.mismatch_count !== undefined && integration.mismatch_count > 0) && ( +
+
+
+ + + {integration.mismatch_count} sync mismatch{integration.mismatch_count !== 1 ? 'es' : ''} detected + +
+ {siteId && ( + + )} +
+
+ )} + + {integration.sync_error && ( +
+
+
+ +
+

+ Sync Error +

+

+ {integration.sync_error} +

+
+
+
+
+ )} +
+ {siteId && ( + + )} {onSync && ( + +
diff --git a/frontend/src/pages/Sites/DeploymentPanel.tsx b/frontend/src/pages/Sites/DeploymentPanel.tsx new file mode 100644 index 00000000..6fb9b76d --- /dev/null +++ b/frontend/src/pages/Sites/DeploymentPanel.tsx @@ -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(null); + const [blueprints, setBlueprints] = useState([]); + const [selectedBlueprintId, setSelectedBlueprintId] = useState(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 ? ( + + ) : ( + + ); + }; + + const getCheckBadge = (passed: boolean) => { + return ( + + {passed ? 'Pass' : 'Fail'} + + ); + }; + + if (loading) { + return ( +
+ +
+
Loading deployment data...
+
+
+ ); + } + + if (blueprints.length === 0) { + return ( +
+ + +
+ +

No blueprints found for this site

+ +
+
+
+ ); + } + + const selectedBlueprint = blueprints.find((b) => b.id === selectedBlueprintId); + + return ( +
+ + + {/* Header */} +
+
+

Deployment Panel

+

+ Check readiness and deploy your site +

+
+
+ + + +
+
+ + {/* Blueprint Selector */} + {blueprints.length > 1 && ( + + + + + )} + + {selectedBlueprint && ( + +
+
+

+ {selectedBlueprint.name} +

+

+ {selectedBlueprint.description || 'No description'} +

+
+ + {selectedBlueprint.status} + +
+
+ )} + + {/* Overall Readiness Status */} + {readiness && ( + <> + +
+

+ Deployment Readiness +

+
+ {getCheckIcon(readiness.ready)} + + {readiness.ready ? 'Ready' : 'Not Ready'} + +
+
+ + {/* Errors */} + {readiness.errors.length > 0 && ( +
+

+ Blocking Issues +

+
    + {readiness.errors.map((error, idx) => ( +
  • + • {error} +
  • + ))} +
+
+ )} + + {/* Warnings */} + {readiness.warnings.length > 0 && ( +
+

+ Warnings +

+
    + {readiness.warnings.map((warning, idx) => ( +
  • + • {warning} +
  • + ))} +
+
+ )} + + {/* Readiness Checks */} +
+ {/* Cluster Coverage */} +
+
+
+ +

+ Cluster Coverage +

+
+ {getCheckBadge(readiness.checks.cluster_coverage)} +
+ {readiness.details.cluster_coverage && ( +
+

+ {readiness.details.cluster_coverage.covered_clusters} /{' '} + {readiness.details.cluster_coverage.total_clusters} clusters covered +

+ {readiness.details.cluster_coverage.incomplete_clusters.length > 0 && ( +

+ {readiness.details.cluster_coverage.incomplete_clusters.length} incomplete + cluster(s) +

+ )} +
+ )} +
+ + {/* Content Validation */} +
+
+
+ +

+ Content Validation +

+
+ {getCheckBadge(readiness.checks.content_validation)} +
+ {readiness.details.content_validation && ( +
+

+ {readiness.details.content_validation.valid_content} /{' '} + {readiness.details.content_validation.total_content} content items valid +

+ {readiness.details.content_validation.invalid_content.length > 0 && ( +

+ {readiness.details.content_validation.invalid_content.length} invalid + content item(s) +

+ )} +
+ )} +
+ + {/* Taxonomy Completeness */} +
+
+
+ +

+ Taxonomy Completeness +

+
+ {getCheckBadge(readiness.checks.taxonomy_completeness)} +
+ {readiness.details.taxonomy_completeness && ( +
+

+ {readiness.details.taxonomy_completeness.total_taxonomies} taxonomies + defined +

+ {readiness.details.taxonomy_completeness.missing_taxonomies.length > 0 && ( +

+ Missing: {readiness.details.taxonomy_completeness.missing_taxonomies.join(', ')} +

+ )} +
+ )} +
+ + {/* Sync Status */} +
+
+
+ +

Sync Status

+
+ {getCheckBadge(readiness.checks.sync_status)} +
+ {readiness.details.sync_status && ( +
+

+ {readiness.details.sync_status.has_integration + ? 'Integration configured' + : 'No integration configured'} +

+ {readiness.details.sync_status.mismatch_count > 0 && ( +

+ {readiness.details.sync_status.mismatch_count} sync mismatch(es) detected +

+ )} +
+ )} +
+
+
+ + {/* Action Buttons */} +
+ + +
+ + )} +
+ ); +} + diff --git a/frontend/src/pages/Sites/Settings.tsx b/frontend/src/pages/Sites/Settings.tsx index 9a0ecd57..bb18f7ac 100644 --- a/frontend/src/pages/Sites/Settings.tsx +++ b/frontend/src/pages/Sites/Settings.tsx @@ -563,6 +563,7 @@ export default function SiteSettings() { onManage={() => setIsIntegrationModalOpen(true)} onSync={handleSyncIntegration} loading={integrationLoading} + siteId={siteId} /> )} diff --git a/frontend/src/pages/Sites/SyncDashboard.tsx b/frontend/src/pages/Sites/SyncDashboard.tsx new file mode 100644 index 00000000..dc4fdad3 --- /dev/null +++ b/frontend/src/pages/Sites/SyncDashboard.tsx @@ -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(null); + const [mismatches, setMismatches] = useState(null); + const [logs, setLogs] = useState([]); + 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 ; + case 'warning': + return ; + case 'error': + case 'failed': + return ; + default: + return ; + } + }; + + if (loading) { + return ( +
+ +
+
Loading sync data...
+
+
+ ); + } + + if (!syncStatus) { + return ( +
+ + +
+ +

No sync data available

+
+
+
+ ); + } + + 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 ( +
+ + + {/* Header */} +
+
+

Sync Dashboard

+

+ Monitor and manage WordPress sync status +

+
+
+ + +
+
+ + {/* Overall Status */} + +
+

Overall Status

+ + {syncStatus.overall_status} + +
+
+
+
+ {syncStatus.integrations.length} +
+
Active Integrations
+
+
+
+ {totalMismatches} +
+
Mismatches
+
+
+
+ {syncStatus.last_sync_at + ? new Date(syncStatus.last_sync_at).toLocaleString() + : 'Never'} +
+
Last Sync
+
+
+
+ + {/* Integrations List */} + {hasIntegrations ? ( +
+

Integrations

+ {syncStatus.integrations.map((integration) => ( + +
+
+ {getStatusIcon(integration.is_healthy ? 'healthy' : integration.status)} +
+

+ {integration.platform.charAt(0).toUpperCase() + integration.platform.slice(1)} +

+

+ Last sync: {integration.last_sync_at + ? new Date(integration.last_sync_at).toLocaleString() + : 'Never'} +

+
+
+
+ + {integration.status} + + {integration.mismatch_count > 0 && ( + + {integration.mismatch_count} mismatch{integration.mismatch_count !== 1 ? 'es' : ''} + + )} +
+
+ + {integration.error && ( +
+
{integration.error}
+
+ )} + +
+ + +
+
+ ))} +
+ ) : ( + +
+ +

No active integrations

+ +
+
+ )} + + {/* Mismatches Section */} + {totalMismatches > 0 && ( + + + + {showMismatches && mismatches && ( +
+ {/* Taxonomy Mismatches */} + {(mismatches.taxonomies.missing_in_wordpress.length > 0 || + mismatches.taxonomies.missing_in_igny8.length > 0) && ( +
+

+ + Taxonomy Mismatches +

+
+ {mismatches.taxonomies.missing_in_wordpress.length > 0 && ( +
+
+ Missing in WordPress ({mismatches.taxonomies.missing_in_wordpress.length}) +
+
    + {mismatches.taxonomies.missing_in_wordpress.slice(0, 5).map((item, idx) => ( +
  • • {item.name} ({item.type})
  • + ))} +
+
+ )} + {mismatches.taxonomies.missing_in_igny8.length > 0 && ( +
+
+ Missing in IGNY8 ({mismatches.taxonomies.missing_in_igny8.length}) +
+
    + {mismatches.taxonomies.missing_in_igny8.slice(0, 5).map((item, idx) => ( +
  • • {item.name} ({item.type})
  • + ))} +
+
+ )} +
+
+ )} + + {/* Product Mismatches */} + {(mismatches.products.missing_in_wordpress.length > 0 || + mismatches.products.missing_in_igny8.length > 0) && ( +
+

+ + Product Mismatches +

+
+ {mismatches.products.missing_in_wordpress.length > 0 && ( +
+
+ {mismatches.products.missing_in_wordpress.length} product(s) missing in WordPress +
+
+ )} + {mismatches.products.missing_in_igny8.length > 0 && ( +
+
+ {mismatches.products.missing_in_igny8.length} product(s) missing in IGNY8 +
+
+ )} +
+
+ )} + + {/* Post Mismatches */} + {(mismatches.posts.missing_in_wordpress.length > 0 || + mismatches.posts.missing_in_igny8.length > 0) && ( +
+

+ + Post Mismatches +

+
+ {mismatches.posts.missing_in_wordpress.length > 0 && ( +
+
+ {mismatches.posts.missing_in_wordpress.length} post(s) missing in WordPress +
+
+ )} + {mismatches.posts.missing_in_igny8.length > 0 && ( +
+
+ {mismatches.posts.missing_in_igny8.length} post(s) missing in IGNY8 +
+
+ )} +
+
+ )} + +
+ +
+
+ )} +
+ )} + + {/* Sync Logs */} + + + + {showLogs && ( +
+ {logs.length > 0 ? ( + logs.map((log, idx) => ( +
+
+
+ {getStatusIcon(log.status)} + + {log.platform} + + + {new Date(log.timestamp).toLocaleString()} + +
+ + {log.status} + +
+ {log.error && ( +
+ {log.error} +
+ )} +
+ )) + ) : ( +
+

No sync logs available

+
+ )} +
+ )} +
+
+ ); +} + diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 8f8d0570..784debe1 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -2153,6 +2153,141 @@ export async function fetchSiteProgress(blueprintId: number): Promise; + 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; + }; + products: { + missing_in_wordpress: Array; + missing_in_igny8: Array; + }; + posts: { + missing_in_wordpress: Array; + missing_in_igny8: Array; + }; +} + +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 { + 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 { + 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; + errors: string[]; + warnings: string[]; + }; + content_validation: { + ready: boolean; + total_content: number; + valid_content: number; + invalid_content: Array; + 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 { + return fetchAPI(`/v1/publisher/blueprints/${blueprintId}/readiness/`); +} + export async function createSiteBlueprint(data: Partial): Promise { return fetchAPI('/v1/site-builder/blueprints/', { method: 'POST',