diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 810f93f..cc7af95 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -1730,6 +1730,57 @@ async def force_activate_listing( # ============== Zone File Sync ============== +@router.get("/zone-stats") +async def get_zone_stats( + db: Database, + admin: User = Depends(require_admin), +): + """ + Get zone file statistics for all tracked TLDs. + Admin only. + """ + from sqlalchemy import func, text + from app.models.zone_file import DroppedDomain, ZoneSnapshot + + # Get drop counts per TLD + drops_query = await db.execute( + text(""" + SELECT + tld, + COUNT(*) FILTER (WHERE dropped_date >= NOW() - INTERVAL '24 hours') as drops_24h, + COUNT(*) FILTER (WHERE dropped_date >= NOW() - INTERVAL '48 hours') as drops_48h, + COUNT(*) as total_drops + FROM dropped_domains + GROUP BY tld + """) + ) + drop_stats = {row[0]: {"drops_24h": row[1], "drops_48h": row[2], "total_drops": row[3]} for row in drops_query.fetchall()} + + # Get latest snapshot per TLD + snapshots_query = await db.execute( + text(""" + SELECT DISTINCT ON (tld) tld, domain_count, created_at + FROM zone_snapshots + ORDER BY tld, created_at DESC + """) + ) + snapshot_stats = {row[0]: {"total_domains": row[1], "last_sync": row[2].isoformat() if row[2] else None} for row in snapshots_query.fetchall()} + + # Combine stats + all_tlds = set(drop_stats.keys()) | set(snapshot_stats.keys()) + zones = [] + for tld in sorted(all_tlds): + zones.append({ + "tld": tld, + "total_domains": snapshot_stats.get(tld, {}).get("total_domains", 0), + "drops_24h": drop_stats.get(tld, {}).get("drops_24h", 0), + "drops_48h": drop_stats.get(tld, {}).get("drops_48h", 0), + "last_sync": snapshot_stats.get(tld, {}).get("last_sync"), + }) + + return {"zones": zones} + + @router.post("/zone-sync/switch") async def trigger_switch_sync( background_tasks: BackgroundTasks, diff --git a/backend/app/services/czds_client.py b/backend/app/services/czds_client.py index 0a0ca6c..a165719 100644 --- a/backend/app/services/czds_client.py +++ b/backend/app/services/czds_client.py @@ -429,7 +429,9 @@ class CZDSClient: async def sync_all_zones( self, db: AsyncSession, - tlds: Optional[list[str]] = None + tlds: Optional[list[str]] = None, + parallel: bool = True, + max_concurrent: int = 3 ) -> list[dict]: """ Sync all approved zone files. @@ -437,6 +439,8 @@ class CZDSClient: Args: db: Database session tlds: Optional list of TLDs to sync. Defaults to APPROVED_TLDS. + parallel: If True, download zones in parallel (faster) + max_concurrent: Max concurrent downloads (be nice to ICANN) Returns: List of sync results for each TLD. @@ -449,9 +453,11 @@ class CZDSClient: logger.info(f"Starting CZDS sync for {len(target_tlds)} zones: {target_tlds}") logger.info(f"Available zones: {list(available_zones.keys())}") + # Build list of TLDs with URLs + sync_tasks = [] results = [] + for tld in target_tlds: - # Get the actual download URL for this TLD download_url = available_zones.get(tld) if not download_url: @@ -467,23 +473,97 @@ class CZDSClient: }) continue - result = await self.sync_zone(db, tld, download_url) - results.append(result) + sync_tasks.append((tld, download_url)) + + if parallel and len(sync_tasks) > 1: + # Parallel sync with semaphore to limit concurrency + semaphore = asyncio.Semaphore(max_concurrent) - # Small delay between zones to be nice to ICANN servers - await asyncio.sleep(2) + async def sync_with_semaphore(tld: str, url: str) -> dict: + async with semaphore: + return await self.sync_zone(db, tld, url) + + # Run all syncs in parallel + parallel_results = await asyncio.gather( + *[sync_with_semaphore(tld, url) for tld, url in sync_tasks], + return_exceptions=True + ) + + for (tld, _), result in zip(sync_tasks, parallel_results): + if isinstance(result, Exception): + logger.error(f"Sync failed for .{tld}: {result}") + results.append({ + "tld": tld, + "status": "error", + "current_count": 0, + "previous_count": 0, + "dropped_count": 0, + "new_count": 0, + "error": str(result) + }) + else: + results.append(result) + else: + # Sequential sync (original behavior) + for tld, download_url in sync_tasks: + result = await self.sync_zone(db, tld, download_url) + results.append(result) + await asyncio.sleep(2) # Summary success_count = sum(1 for r in results if r["status"] == "success") total_dropped = sum(r["dropped_count"] for r in results) + error_count = sum(1 for r in results if r["status"] == "error") logger.info( f"CZDS sync complete: " f"{success_count}/{len(target_tlds)} zones successful, " + f"{error_count} errors, " f"{total_dropped:,} total dropped domains" ) + # Send alert on errors + if error_count > 0: + await self._send_sync_alert(results) + return results + + async def _send_sync_alert(self, results: list[dict]): + """Send email alert when sync has errors.""" + try: + from app.services.email_service import email_service + + errors = [r for r in results if r["status"] == "error"] + if not errors: + return + + error_details = "\n".join([ + f" • .{r['tld']}: {r.get('error', 'Unknown error')}" + for r in errors + ]) + + success_count = sum(1 for r in results if r["status"] == "success") + total_dropped = sum(r["dropped_count"] for r in results) + + await email_service.send_email( + to_email=settings.smtp_from_email, # Send to admin + subject=f"⚠️ CZDS Zone Sync Alert - {len(errors)} errors", + html_content=f""" +
Status: {success_count} success, {len(errors)} errors
+Total Drops: {total_dropped:,}
+ +{error_details}
+
+ + Time: {datetime.utcnow().isoformat()} +
+ """ + ) + logger.info("Sent sync error alert email") + except Exception as e: + logger.error(f"Failed to send sync alert: {e}") # ============================================================================ diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 3a6040b..183f2f5 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation' import { useStore } from '@/lib/store' import { api } from '@/lib/api' import { EarningsTab } from '@/components/admin/EarningsTab' +import { ZoneSyncTab } from '@/components/admin/ZoneSyncTab' import { PremiumTable, Badge, TableActionButton, StatCard } from '@/components/PremiumTable' import { Users, @@ -56,7 +57,7 @@ import Image from 'next/image' // TYPES // ============================================================================ -type TabType = 'overview' | 'earnings' | 'telemetry' | 'users' | 'alerts' | 'newsletter' | 'tld' | 'auctions' | 'blog' | 'system' | 'activity' +type TabType = 'overview' | 'earnings' | 'telemetry' | 'users' | 'alerts' | 'newsletter' | 'tld' | 'auctions' | 'blog' | 'system' | 'activity' | 'zones' interface AdminStats { users: { total: number; active: number; verified: number; new_this_week: number } @@ -94,6 +95,7 @@ const TABS: Array<{ id: TabType; label: string; icon: any; shortLabel?: string } { id: 'tld', label: 'TLD Data', icon: Globe, shortLabel: 'TLD' }, { id: 'auctions', label: 'Auctions', icon: Gavel, shortLabel: 'Auctions' }, { id: 'system', label: 'System', icon: Database, shortLabel: 'System' }, + { id: 'zones', label: 'Zone Sync', icon: Download, shortLabel: 'Zones' }, { id: 'activity', label: 'Activity', icon: History, shortLabel: 'Log' }, ] @@ -957,6 +959,8 @@ export default function AdminPage() { )} {/* Activity Tab */} + {activeTab === 'zones' &&.ch and .li domains via AXFR
+gTLD zone files (.xyz, .org, .info, etc.)
+Automatic Sync Schedule
+Switch.ch (.ch, .li): Daily at 05:00 UTC
+ICANN CZDS (gTLDs): Daily at 06:00 UTC
+Zone files are processed with 24 parallel workers for maximum speed.
+