stage 4-2

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

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)}']
}

View File

@@ -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)

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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