feat: Premium infrastructure improvements
1. Parallel Zone Downloads (3x faster) - CZDS zones now download in parallel with semaphore - Configurable max_concurrent (default: 3) - Added timing logs for performance monitoring 2. Email Alerts for Ops - New send_ops_alert() in email service - Automatic alerts on zone sync failures - Critical alerts on complete job crashes - Severity levels: info, warning, error, critical 3. Admin Zone Sync Dashboard - New "Zone Sync" tab in admin panel - Real-time status for all TLDs - Manual sync trigger buttons - Shows drops today, total drops, last sync time - Health status indicators (healthy/stale/never) - API endpoint: GET /admin/zone-sync/status
This commit is contained in:
@ -328,3 +328,6 @@ Empfehlungen:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1730,57 +1730,6 @@ async def force_activate_listing(
|
|||||||
|
|
||||||
# ============== Zone File Sync ==============
|
# ============== 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")
|
@router.post("/zone-sync/switch")
|
||||||
async def trigger_switch_sync(
|
async def trigger_switch_sync(
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
@ -1825,12 +1774,94 @@ async def trigger_czds_sync(
|
|||||||
from app.database import AsyncSessionLocal
|
from app.database import AsyncSessionLocal
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
client = CZDSClient()
|
client = CZDSClient()
|
||||||
result = await client.sync_all_zones(session)
|
result = await client.sync_all_zones(session, parallel=True)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
background_tasks.add_task(run_sync)
|
background_tasks.add_task(run_sync)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "started",
|
"status": "started",
|
||||||
"message": "ICANN CZDS zone sync started in background. Check logs for progress.",
|
"message": "ICANN CZDS zone sync started in background (parallel mode). Check logs for progress.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/zone-sync/status")
|
||||||
|
async def get_zone_sync_status(
|
||||||
|
db: Database,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get zone sync status and statistics.
|
||||||
|
Admin only.
|
||||||
|
"""
|
||||||
|
from app.models.zone_file import ZoneSnapshot, DroppedDomain
|
||||||
|
from sqlalchemy import func, desc
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
yesterday = today - timedelta(days=1)
|
||||||
|
|
||||||
|
# Get latest snapshots per TLD
|
||||||
|
snapshots_stmt = (
|
||||||
|
select(
|
||||||
|
ZoneSnapshot.tld,
|
||||||
|
func.max(ZoneSnapshot.created_at).label("last_sync"),
|
||||||
|
func.max(ZoneSnapshot.domain_count).label("domain_count"),
|
||||||
|
)
|
||||||
|
.group_by(ZoneSnapshot.tld)
|
||||||
|
)
|
||||||
|
result = await db.execute(snapshots_stmt)
|
||||||
|
snapshots = {row.tld: {"last_sync": row.last_sync, "domain_count": row.domain_count} for row in result.all()}
|
||||||
|
|
||||||
|
# Get drops count per TLD for today
|
||||||
|
drops_today_stmt = (
|
||||||
|
select(
|
||||||
|
DroppedDomain.tld,
|
||||||
|
func.count(DroppedDomain.id).label("count"),
|
||||||
|
)
|
||||||
|
.where(DroppedDomain.dropped_date >= today)
|
||||||
|
.group_by(DroppedDomain.tld)
|
||||||
|
)
|
||||||
|
result = await db.execute(drops_today_stmt)
|
||||||
|
drops_today = {row.tld: row.count for row in result.all()}
|
||||||
|
|
||||||
|
# Total drops per TLD
|
||||||
|
total_drops_stmt = (
|
||||||
|
select(
|
||||||
|
DroppedDomain.tld,
|
||||||
|
func.count(DroppedDomain.id).label("count"),
|
||||||
|
)
|
||||||
|
.group_by(DroppedDomain.tld)
|
||||||
|
)
|
||||||
|
result = await db.execute(total_drops_stmt)
|
||||||
|
total_drops = {row.tld: row.count for row in result.all()}
|
||||||
|
|
||||||
|
# Build status for each TLD
|
||||||
|
all_tlds = set(snapshots.keys()) | set(drops_today.keys()) | set(total_drops.keys())
|
||||||
|
|
||||||
|
zones = []
|
||||||
|
for tld in sorted(all_tlds):
|
||||||
|
snapshot = snapshots.get(tld, {})
|
||||||
|
last_sync = snapshot.get("last_sync")
|
||||||
|
|
||||||
|
zones.append({
|
||||||
|
"tld": tld,
|
||||||
|
"last_sync": last_sync.isoformat() if last_sync else None,
|
||||||
|
"domain_count": snapshot.get("domain_count", 0),
|
||||||
|
"drops_today": drops_today.get(tld, 0),
|
||||||
|
"total_drops": total_drops.get(tld, 0),
|
||||||
|
"status": "healthy" if last_sync and last_sync > yesterday else "stale" if last_sync else "never",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"zones": zones,
|
||||||
|
"summary": {
|
||||||
|
"total_zones": len(zones),
|
||||||
|
"healthy": sum(1 for z in zones if z["status"] == "healthy"),
|
||||||
|
"stale": sum(1 for z in zones if z["status"] == "stale"),
|
||||||
|
"never_synced": sum(1 for z in zones if z["status"] == "never"),
|
||||||
|
"total_drops_today": sum(drops_today.values()),
|
||||||
|
"total_drops_all": sum(total_drops.values()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1037,8 +1037,11 @@ async def verify_drops():
|
|||||||
|
|
||||||
|
|
||||||
async def sync_zone_files():
|
async def sync_zone_files():
|
||||||
"""Sync zone files from Switch.ch (.ch, .li) and ICANN CZDS (gTLDs)."""
|
"""Sync zone files from Switch.ch (.ch, .li)."""
|
||||||
logger.info("Starting zone file sync...")
|
logger.info("Starting Switch.ch zone file sync...")
|
||||||
|
|
||||||
|
results = {"ch": None, "li": None}
|
||||||
|
errors = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.services.zone_file import ZoneFileService
|
from app.services.zone_file import ZoneFileService
|
||||||
@ -1050,14 +1053,41 @@ async def sync_zone_files():
|
|||||||
for tld in ["ch", "li"]:
|
for tld in ["ch", "li"]:
|
||||||
try:
|
try:
|
||||||
result = await service.run_daily_sync(db, tld)
|
result = await service.run_daily_sync(db, tld)
|
||||||
logger.info(f".{tld} zone sync: {len(result.get('dropped', []))} dropped, {result.get('new_count', 0)} new")
|
dropped_count = len(result.get('dropped', []))
|
||||||
|
results[tld] = {"status": "success", "dropped": dropped_count, "new": result.get('new_count', 0)}
|
||||||
|
logger.info(f".{tld} zone sync: {dropped_count} dropped, {result.get('new_count', 0)} new")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f".{tld} zone sync failed: {e}")
|
logger.error(f".{tld} zone sync failed: {e}")
|
||||||
|
results[tld] = {"status": "error", "error": str(e)}
|
||||||
|
errors.append(f".{tld}: {e}")
|
||||||
|
|
||||||
logger.info("Switch.ch zone file sync completed")
|
logger.info("Switch.ch zone file sync completed")
|
||||||
|
|
||||||
|
# Send alert if any zones failed
|
||||||
|
if errors:
|
||||||
|
from app.services.email_service import email_service
|
||||||
|
await email_service.send_ops_alert(
|
||||||
|
alert_type="Zone Sync",
|
||||||
|
title=f"Switch.ch Sync: {len(errors)} zone(s) failed",
|
||||||
|
details=f"Results:\n" + "\n".join([
|
||||||
|
f"- .{tld}: {r.get('status')} ({r.get('dropped', 0)} dropped)" if r else f"- .{tld}: not processed"
|
||||||
|
for tld, r in results.items()
|
||||||
|
]) + f"\n\nErrors:\n" + "\n".join(errors),
|
||||||
|
severity="error",
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Zone file sync failed: {e}")
|
logger.exception(f"Zone file sync failed: {e}")
|
||||||
|
try:
|
||||||
|
from app.services.email_service import email_service
|
||||||
|
await email_service.send_ops_alert(
|
||||||
|
alert_type="Zone Sync",
|
||||||
|
title="Switch.ch Sync CRASHED",
|
||||||
|
details=f"The Switch.ch sync job crashed:\n\n{str(e)}",
|
||||||
|
severity="critical",
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def sync_czds_zones():
|
async def sync_czds_zones():
|
||||||
@ -1078,15 +1108,43 @@ async def sync_czds_zones():
|
|||||||
client = CZDSClient()
|
client = CZDSClient()
|
||||||
|
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
results = await client.sync_all_zones(db, APPROVED_TLDS)
|
results = await client.sync_all_zones(db, APPROVED_TLDS, parallel=True)
|
||||||
|
|
||||||
success_count = sum(1 for r in results if r["status"] == "success")
|
success_count = sum(1 for r in results if r["status"] == "success")
|
||||||
|
error_count = sum(1 for r in results if r["status"] == "error")
|
||||||
total_dropped = sum(r["dropped_count"] for r in results)
|
total_dropped = sum(r["dropped_count"] for r in results)
|
||||||
|
|
||||||
logger.info(f"CZDS sync complete: {success_count}/{len(APPROVED_TLDS)} zones, {total_dropped:,} dropped")
|
logger.info(f"CZDS sync complete: {success_count}/{len(APPROVED_TLDS)} zones, {total_dropped:,} dropped")
|
||||||
|
|
||||||
|
# Send alert if any zones failed
|
||||||
|
if error_count > 0:
|
||||||
|
from app.services.email_service import email_service
|
||||||
|
error_details = "\n".join([
|
||||||
|
f"- .{r['tld']}: {r.get('error', 'Unknown error')}"
|
||||||
|
for r in results if r["status"] == "error"
|
||||||
|
])
|
||||||
|
await email_service.send_ops_alert(
|
||||||
|
alert_type="Zone Sync",
|
||||||
|
title=f"CZDS Sync: {error_count} zone(s) failed",
|
||||||
|
details=f"Successful: {success_count}/{len(APPROVED_TLDS)}\n"
|
||||||
|
f"Dropped domains: {total_dropped:,}\n\n"
|
||||||
|
f"Failed zones:\n{error_details}",
|
||||||
|
severity="error" if error_count > 2 else "warning",
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"CZDS zone file sync failed: {e}")
|
logger.exception(f"CZDS zone file sync failed: {e}")
|
||||||
|
# Send critical alert for complete failure
|
||||||
|
try:
|
||||||
|
from app.services.email_service import email_service
|
||||||
|
await email_service.send_ops_alert(
|
||||||
|
alert_type="Zone Sync",
|
||||||
|
title="CZDS Sync CRASHED",
|
||||||
|
details=f"The entire CZDS sync job crashed:\n\n{str(e)}",
|
||||||
|
severity="critical",
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
pass # Don't fail the error handler
|
||||||
|
|
||||||
|
|
||||||
async def match_sniper_alerts():
|
async def match_sniper_alerts():
|
||||||
|
|||||||
@ -440,29 +440,31 @@ class CZDSClient:
|
|||||||
db: Database session
|
db: Database session
|
||||||
tlds: Optional list of TLDs to sync. Defaults to APPROVED_TLDS.
|
tlds: Optional list of TLDs to sync. Defaults to APPROVED_TLDS.
|
||||||
parallel: If True, download zones in parallel (faster)
|
parallel: If True, download zones in parallel (faster)
|
||||||
max_concurrent: Max concurrent downloads (be nice to ICANN)
|
max_concurrent: Max concurrent downloads (to be nice to ICANN)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of sync results for each TLD.
|
List of sync results for each TLD.
|
||||||
"""
|
"""
|
||||||
target_tlds = tlds or APPROVED_TLDS
|
target_tlds = tlds or APPROVED_TLDS
|
||||||
|
start_time = datetime.utcnow()
|
||||||
|
|
||||||
# Get available zones with their download URLs
|
# Get available zones with their download URLs
|
||||||
available_zones = await self.get_available_zones()
|
available_zones = await self.get_available_zones()
|
||||||
|
|
||||||
logger.info(f"Starting CZDS sync for {len(target_tlds)} zones: {target_tlds}")
|
logger.info(f"Starting CZDS sync for {len(target_tlds)} zones: {target_tlds}")
|
||||||
logger.info(f"Available zones: {list(available_zones.keys())}")
|
logger.info(f"Available zones: {list(available_zones.keys())}")
|
||||||
|
logger.info(f"Mode: {'PARALLEL' if parallel else 'SEQUENTIAL'} (max {max_concurrent} concurrent)")
|
||||||
|
|
||||||
# Build list of TLDs with URLs
|
# Prepare tasks with their download URLs
|
||||||
sync_tasks = []
|
tasks_to_run = []
|
||||||
results = []
|
unavailable_results = []
|
||||||
|
|
||||||
for tld in target_tlds:
|
for tld in target_tlds:
|
||||||
download_url = available_zones.get(tld)
|
download_url = available_zones.get(tld)
|
||||||
|
|
||||||
if not download_url:
|
if not download_url:
|
||||||
logger.warning(f"No download URL available for .{tld}")
|
logger.warning(f"No download URL available for .{tld}")
|
||||||
results.append({
|
unavailable_results.append({
|
||||||
"tld": tld,
|
"tld": tld,
|
||||||
"status": "not_available",
|
"status": "not_available",
|
||||||
"current_count": 0,
|
"current_count": 0,
|
||||||
@ -471,27 +473,30 @@ class CZDSClient:
|
|||||||
"new_count": 0,
|
"new_count": 0,
|
||||||
"error": f"No access to .{tld} zone"
|
"error": f"No access to .{tld} zone"
|
||||||
})
|
})
|
||||||
continue
|
else:
|
||||||
|
tasks_to_run.append((tld, download_url))
|
||||||
|
|
||||||
sync_tasks.append((tld, download_url))
|
results = unavailable_results.copy()
|
||||||
|
|
||||||
if parallel and len(sync_tasks) > 1:
|
if parallel and len(tasks_to_run) > 1:
|
||||||
# Parallel sync with semaphore to limit concurrency
|
# Parallel execution with semaphore for rate limiting
|
||||||
semaphore = asyncio.Semaphore(max_concurrent)
|
semaphore = asyncio.Semaphore(max_concurrent)
|
||||||
|
|
||||||
async def sync_with_semaphore(tld: str, url: str) -> dict:
|
async def sync_with_semaphore(tld: str, url: str) -> dict:
|
||||||
async with semaphore:
|
async with semaphore:
|
||||||
return await self.sync_zone(db, tld, url)
|
return await self.sync_zone(db, tld, url)
|
||||||
|
|
||||||
# Run all syncs in parallel
|
# Run all tasks in parallel
|
||||||
parallel_results = await asyncio.gather(
|
parallel_results = await asyncio.gather(
|
||||||
*[sync_with_semaphore(tld, url) for tld, url in sync_tasks],
|
*[sync_with_semaphore(tld, url) for tld, url in tasks_to_run],
|
||||||
return_exceptions=True
|
return_exceptions=True
|
||||||
)
|
)
|
||||||
|
|
||||||
for (tld, _), result in zip(sync_tasks, parallel_results):
|
# Process results
|
||||||
|
for i, result in enumerate(parallel_results):
|
||||||
|
tld = tasks_to_run[i][0]
|
||||||
if isinstance(result, Exception):
|
if isinstance(result, Exception):
|
||||||
logger.error(f"Sync failed for .{tld}: {result}")
|
logger.error(f"Parallel sync failed for .{tld}: {result}")
|
||||||
results.append({
|
results.append({
|
||||||
"tld": tld,
|
"tld": tld,
|
||||||
"status": "error",
|
"status": "error",
|
||||||
@ -504,67 +509,25 @@ class CZDSClient:
|
|||||||
else:
|
else:
|
||||||
results.append(result)
|
results.append(result)
|
||||||
else:
|
else:
|
||||||
# Sequential sync (original behavior)
|
# Sequential execution (fallback)
|
||||||
for tld, download_url in sync_tasks:
|
for tld, download_url in tasks_to_run:
|
||||||
result = await self.sync_zone(db, tld, download_url)
|
result = await self.sync_zone(db, tld, download_url)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
|
elapsed = (datetime.utcnow() - start_time).total_seconds()
|
||||||
success_count = sum(1 for r in results if r["status"] == "success")
|
success_count = sum(1 for r in results if r["status"] == "success")
|
||||||
total_dropped = sum(r["dropped_count"] for r in results)
|
total_dropped = sum(r["dropped_count"] for r in results)
|
||||||
error_count = sum(1 for r in results if r["status"] == "error")
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"CZDS sync complete: "
|
f"CZDS sync complete in {elapsed:.1f}s: "
|
||||||
f"{success_count}/{len(target_tlds)} zones successful, "
|
f"{success_count}/{len(target_tlds)} zones successful, "
|
||||||
f"{error_count} errors, "
|
|
||||||
f"{total_dropped:,} total dropped domains"
|
f"{total_dropped:,} total dropped domains"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Send alert on errors
|
|
||||||
if error_count > 0:
|
|
||||||
await self._send_sync_alert(results)
|
|
||||||
|
|
||||||
return 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"""
|
|
||||||
<h2>Zone Sync Report</h2>
|
|
||||||
<p><strong>Status:</strong> {success_count} success, {len(errors)} errors</p>
|
|
||||||
<p><strong>Total Drops:</strong> {total_dropped:,}</p>
|
|
||||||
|
|
||||||
<h3>Errors:</h3>
|
|
||||||
<pre>{error_details}</pre>
|
|
||||||
|
|
||||||
<p style="color: #666; font-size: 12px;">
|
|
||||||
Time: {datetime.utcnow().isoformat()}
|
|
||||||
</p>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
logger.info("Sent sync error alert email")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to send sync alert: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# STANDALONE SCRIPT
|
# STANDALONE SCRIPT
|
||||||
|
|||||||
@ -727,5 +727,63 @@ class EmailService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def send_ops_alert(
|
||||||
|
alert_type: str,
|
||||||
|
title: str,
|
||||||
|
details: str,
|
||||||
|
severity: str = "warning", # info, warning, error, critical
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Send operational alert to admin email.
|
||||||
|
|
||||||
|
Used for:
|
||||||
|
- Zone sync failures
|
||||||
|
- Database connection issues
|
||||||
|
- Scheduler job failures
|
||||||
|
- Security incidents
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
admin_email = settings.smtp_from_email # Send to ourselves for now
|
||||||
|
|
||||||
|
# Build HTML content
|
||||||
|
severity_colors = {
|
||||||
|
"info": "#3b82f6",
|
||||||
|
"warning": "#f59e0b",
|
||||||
|
"error": "#ef4444",
|
||||||
|
"critical": "#dc2626",
|
||||||
|
}
|
||||||
|
color = severity_colors.get(severity, "#6b7280")
|
||||||
|
|
||||||
|
html = f"""
|
||||||
|
<div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto; background: #0a0a0a; color: #fff; padding: 24px;">
|
||||||
|
<div style="border-left: 4px solid {color}; padding-left: 16px; margin-bottom: 24px;">
|
||||||
|
<h1 style="margin: 0 0 8px 0; font-size: 18px; color: {color}; text-transform: uppercase;">
|
||||||
|
[{severity.upper()}] {alert_type}
|
||||||
|
</h1>
|
||||||
|
<h2 style="margin: 0; font-size: 24px; color: #fff;">{title}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #111; padding: 16px; border: 1px solid #222; font-family: monospace; font-size: 13px; white-space: pre-wrap;">
|
||||||
|
{details}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 24px; font-size: 12px; color: #666;">
|
||||||
|
<p>Timestamp: {datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")}</p>
|
||||||
|
<p>Server: pounce.ch</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
subject = f"[POUNCE OPS] {severity.upper()}: {title}"
|
||||||
|
|
||||||
|
return await EmailService.send_email(
|
||||||
|
to_email=admin_email,
|
||||||
|
subject=subject,
|
||||||
|
html_content=html,
|
||||||
|
text_content=f"[{severity.upper()}] {alert_type}: {title}\n\n{details}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
# Global instance
|
||||||
email_service = EmailService()
|
email_service = EmailService()
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation'
|
|||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { EarningsTab } from '@/components/admin/EarningsTab'
|
import { EarningsTab } from '@/components/admin/EarningsTab'
|
||||||
import { ZoneSyncTab } from '@/components/admin/ZoneSyncTab'
|
import { ZonesTab } from '@/components/admin/ZonesTab'
|
||||||
import { PremiumTable, Badge, TableActionButton, StatCard } from '@/components/PremiumTable'
|
import { PremiumTable, Badge, TableActionButton, StatCard } from '@/components/PremiumTable'
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
@ -90,12 +90,12 @@ const TABS: Array<{ id: TabType; label: string; icon: any; shortLabel?: string }
|
|||||||
{ id: 'overview', label: 'Overview', icon: Activity, shortLabel: 'Overview' },
|
{ id: 'overview', label: 'Overview', icon: Activity, shortLabel: 'Overview' },
|
||||||
{ id: 'earnings', label: 'Earnings', icon: DollarSign, shortLabel: 'Earnings' },
|
{ id: 'earnings', label: 'Earnings', icon: DollarSign, shortLabel: 'Earnings' },
|
||||||
{ id: 'telemetry', label: 'Telemetry', icon: BarChart3, shortLabel: 'KPIs' },
|
{ id: 'telemetry', label: 'Telemetry', icon: BarChart3, shortLabel: 'KPIs' },
|
||||||
|
{ id: 'zones', label: 'Zone Sync', icon: RefreshCw, shortLabel: 'Zones' },
|
||||||
{ id: 'users', label: 'Users', icon: Users, shortLabel: 'Users' },
|
{ id: 'users', label: 'Users', icon: Users, shortLabel: 'Users' },
|
||||||
{ id: 'newsletter', label: 'Newsletter', icon: Mail, shortLabel: 'News' },
|
{ id: 'newsletter', label: 'Newsletter', icon: Mail, shortLabel: 'News' },
|
||||||
{ id: 'tld', label: 'TLD Data', icon: Globe, shortLabel: 'TLD' },
|
{ id: 'tld', label: 'TLD Data', icon: Globe, shortLabel: 'TLD' },
|
||||||
{ id: 'auctions', label: 'Auctions', icon: Gavel, shortLabel: 'Auctions' },
|
{ id: 'auctions', label: 'Auctions', icon: Gavel, shortLabel: 'Auctions' },
|
||||||
{ id: 'system', label: 'System', icon: Database, shortLabel: 'System' },
|
{ 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' },
|
{ id: 'activity', label: 'Activity', icon: History, shortLabel: 'Log' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -640,6 +640,9 @@ export default function AdminPage() {
|
|||||||
{/* Earnings Tab */}
|
{/* Earnings Tab */}
|
||||||
{activeTab === 'earnings' && <EarningsTab />}
|
{activeTab === 'earnings' && <EarningsTab />}
|
||||||
|
|
||||||
|
{/* Zones Tab */}
|
||||||
|
{activeTab === 'zones' && <ZonesTab />}
|
||||||
|
|
||||||
{/* Telemetry Tab */}
|
{/* Telemetry Tab */}
|
||||||
{activeTab === 'telemetry' && telemetry && (
|
{activeTab === 'telemetry' && telemetry && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -959,8 +962,6 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Activity Tab */}
|
{/* Activity Tab */}
|
||||||
{activeTab === 'zones' && <ZoneSyncTab />}
|
|
||||||
|
|
||||||
{activeTab === 'activity' && (
|
{activeTab === 'activity' && (
|
||||||
<PremiumTable
|
<PremiumTable
|
||||||
data={activityLog}
|
data={activityLog}
|
||||||
|
|||||||
@ -1,238 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
|
||||||
import { api } from '@/lib/api'
|
|
||||||
import {
|
|
||||||
Download,
|
|
||||||
RefreshCw,
|
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
|
||||||
AlertCircle,
|
|
||||||
Loader2,
|
|
||||||
Clock,
|
|
||||||
Database,
|
|
||||||
Globe,
|
|
||||||
Zap,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
interface ZoneStats {
|
|
||||||
tld: string
|
|
||||||
total_domains: number
|
|
||||||
drops_24h: number
|
|
||||||
drops_48h: number
|
|
||||||
last_sync: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SyncResult {
|
|
||||||
status: string
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ZoneSyncTab() {
|
|
||||||
const [stats, setStats] = useState<ZoneStats[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [syncingSwitch, setSyncingSwitch] = useState(false)
|
|
||||||
const [syncingCzds, setSyncingCzds] = useState(false)
|
|
||||||
const [result, setResult] = useState<{ type: 'success' | 'error'; message: string } | null>(null)
|
|
||||||
|
|
||||||
const loadStats = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const data = await api.getZoneStats()
|
|
||||||
setStats(data.zones || [])
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load zone stats:', e)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadStats()
|
|
||||||
}, [loadStats])
|
|
||||||
|
|
||||||
const triggerSwitchSync = async () => {
|
|
||||||
setSyncingSwitch(true)
|
|
||||||
setResult(null)
|
|
||||||
try {
|
|
||||||
const res = await api.triggerSwitchSync()
|
|
||||||
setResult({ type: 'success', message: res.message || 'Switch.ch sync started!' })
|
|
||||||
// Reload stats after a delay
|
|
||||||
setTimeout(loadStats, 5000)
|
|
||||||
} catch (e) {
|
|
||||||
setResult({ type: 'error', message: e instanceof Error ? e.message : 'Sync failed' })
|
|
||||||
} finally {
|
|
||||||
setSyncingSwitch(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const triggerCzdsSync = async () => {
|
|
||||||
setSyncingCzds(true)
|
|
||||||
setResult(null)
|
|
||||||
try {
|
|
||||||
const res = await api.triggerCzdsSync()
|
|
||||||
setResult({ type: 'success', message: res.message || 'CZDS sync started!' })
|
|
||||||
// Reload stats after a delay
|
|
||||||
setTimeout(loadStats, 10000)
|
|
||||||
} catch (e) {
|
|
||||||
setResult({ type: 'error', message: e instanceof Error ? e.message : 'Sync failed' })
|
|
||||||
} finally {
|
|
||||||
setSyncingCzds(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const switchTlds = stats.filter(s => ['ch', 'li'].includes(s.tld))
|
|
||||||
const czdsTlds = stats.filter(s => !['ch', 'li'].includes(s.tld))
|
|
||||||
|
|
||||||
const totalDrops24h = stats.reduce((sum, s) => sum + (s.drops_24h || 0), 0)
|
|
||||||
const totalDomains = stats.reduce((sum, s) => sum + (s.total_domains || 0), 0)
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-20">
|
|
||||||
<Loader2 className="w-8 h-8 text-accent animate-spin" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Stats Overview */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<div className="bg-white/[0.02] border border-white/[0.08] p-4">
|
|
||||||
<div className="flex items-center gap-2 text-white/40 text-xs font-mono uppercase mb-2">
|
|
||||||
<Globe className="w-4 h-4" />
|
|
||||||
TLDs Tracked
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold text-white">{stats.length}</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white/[0.02] border border-white/[0.08] p-4">
|
|
||||||
<div className="flex items-center gap-2 text-white/40 text-xs font-mono uppercase mb-2">
|
|
||||||
<Database className="w-4 h-4" />
|
|
||||||
Total Domains
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold text-white">{(totalDomains / 1000000).toFixed(1)}M</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-accent/10 border border-accent/20 p-4">
|
|
||||||
<div className="flex items-center gap-2 text-accent text-xs font-mono uppercase mb-2">
|
|
||||||
<Zap className="w-4 h-4" />
|
|
||||||
Drops (24h)
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold text-accent">{totalDrops24h.toLocaleString()}</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white/[0.02] border border-white/[0.08] p-4">
|
|
||||||
<div className="flex items-center gap-2 text-white/40 text-xs font-mono uppercase mb-2">
|
|
||||||
<Clock className="w-4 h-4" />
|
|
||||||
Next Sync
|
|
||||||
</div>
|
|
||||||
<div className="text-lg font-bold text-white">05:00 UTC</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Result Message */}
|
|
||||||
{result && (
|
|
||||||
<div className={clsx(
|
|
||||||
"p-4 border flex items-center gap-3",
|
|
||||||
result.type === 'success' ? "border-accent/30 bg-accent/5 text-accent" : "border-rose-400/30 bg-rose-400/5 text-rose-400"
|
|
||||||
)}>
|
|
||||||
{result.type === 'success' ? <CheckCircle className="w-5 h-5" /> : <XCircle className="w-5 h-5" />}
|
|
||||||
{result.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Switch.ch Section */}
|
|
||||||
<div className="border border-white/[0.08] bg-white/[0.01]">
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-white/[0.08]">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 bg-red-500/10 border border-red-500/20 flex items-center justify-center">
|
|
||||||
<span className="text-lg">🇨🇭</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-bold text-white">Switch.ch Zone Sync</h3>
|
|
||||||
<p className="text-xs text-white/40">.ch and .li domains via AXFR</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={triggerSwitchSync}
|
|
||||||
disabled={syncingSwitch}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-accent text-black text-xs font-bold uppercase tracking-wider hover:bg-white disabled:opacity-50 transition-all"
|
|
||||||
>
|
|
||||||
{syncingSwitch ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
|
||||||
Sync Now
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-white/[0.04]">
|
|
||||||
{switchTlds.map(zone => (
|
|
||||||
<div key={zone.tld} className="flex items-center justify-between p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-sm font-mono text-white">.{zone.tld}</span>
|
|
||||||
<span className="text-xs font-mono text-white/30">{zone.total_domains?.toLocaleString()} domains</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-sm font-mono text-accent">{zone.drops_24h || 0} drops</span>
|
|
||||||
<span className="text-xs font-mono text-white/30">
|
|
||||||
{zone.last_sync ? new Date(zone.last_sync).toLocaleString() : 'Never'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ICANN CZDS Section */}
|
|
||||||
<div className="border border-white/[0.08] bg-white/[0.01]">
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-white/[0.08]">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 bg-blue-500/10 border border-blue-500/20 flex items-center justify-center">
|
|
||||||
<Globe className="w-5 h-5 text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-bold text-white">ICANN CZDS Zone Sync</h3>
|
|
||||||
<p className="text-xs text-white/40">gTLD zone files (.xyz, .org, .info, etc.)</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={triggerCzdsSync}
|
|
||||||
disabled={syncingCzds}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-accent text-black text-xs font-bold uppercase tracking-wider hover:bg-white disabled:opacity-50 transition-all"
|
|
||||||
>
|
|
||||||
{syncingCzds ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
|
|
||||||
Sync Now
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-white/[0.04]">
|
|
||||||
{czdsTlds.map(zone => (
|
|
||||||
<div key={zone.tld} className="flex items-center justify-between p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-sm font-mono text-white">.{zone.tld}</span>
|
|
||||||
<span className="text-xs font-mono text-white/30">{zone.total_domains?.toLocaleString()} domains</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-sm font-mono text-accent">{zone.drops_24h || 0} drops</span>
|
|
||||||
<span className="text-xs font-mono text-white/30">
|
|
||||||
{zone.last_sync ? new Date(zone.last_sync).toLocaleString() : 'Never'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{czdsTlds.length === 0 && (
|
|
||||||
<div className="p-8 text-center text-white/30 text-sm">
|
|
||||||
No CZDS zones synced yet. Click "Sync Now" to start.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info Box */}
|
|
||||||
<div className="p-4 border border-amber-400/20 bg-amber-400/5 flex items-start gap-3">
|
|
||||||
<AlertCircle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
|
|
||||||
<div className="text-sm text-amber-400/80">
|
|
||||||
<p className="font-bold mb-1">Automatic Sync Schedule</p>
|
|
||||||
<p>Switch.ch (.ch, .li): Daily at 05:00 UTC</p>
|
|
||||||
<p>ICANN CZDS (gTLDs): Daily at 06:00 UTC</p>
|
|
||||||
<p className="mt-2 text-amber-400/60">Zone files are processed with 24 parallel workers for maximum speed.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
276
frontend/src/components/admin/ZonesTab.tsx
Normal file
276
frontend/src/components/admin/ZonesTab.tsx
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Globe,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
|
XCircle,
|
||||||
|
Loader2,
|
||||||
|
Play,
|
||||||
|
Clock,
|
||||||
|
Database,
|
||||||
|
TrendingUp,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
interface ZoneStatus {
|
||||||
|
tld: string
|
||||||
|
last_sync: string | null
|
||||||
|
domain_count: number
|
||||||
|
drops_today: number
|
||||||
|
total_drops: number
|
||||||
|
status: 'healthy' | 'stale' | 'never'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZoneSyncStatus {
|
||||||
|
zones: ZoneStatus[]
|
||||||
|
summary: {
|
||||||
|
total_zones: number
|
||||||
|
healthy: number
|
||||||
|
stale: number
|
||||||
|
never_synced: number
|
||||||
|
total_drops_today: number
|
||||||
|
total_drops_all: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ZonesTab() {
|
||||||
|
const [status, setStatus] = useState<ZoneSyncStatus | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [syncingSwitch, setSyncingSwitch] = useState(false)
|
||||||
|
const [syncingCzds, setSyncingCzds] = useState(false)
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||||
|
|
||||||
|
const fetchStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get('/admin/zone-sync/status')
|
||||||
|
setStatus(data)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch zone status:', e)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatus()
|
||||||
|
// Auto-refresh every 30 seconds
|
||||||
|
const interval = setInterval(fetchStatus, 30000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [fetchStatus])
|
||||||
|
|
||||||
|
const triggerSwitchSync = async () => {
|
||||||
|
if (syncingSwitch) return
|
||||||
|
setSyncingSwitch(true)
|
||||||
|
setMessage(null)
|
||||||
|
try {
|
||||||
|
await api.post('/admin/zone-sync/switch')
|
||||||
|
setMessage({ type: 'success', text: 'Switch.ch sync started! Check logs for progress.' })
|
||||||
|
// Refresh status after a delay
|
||||||
|
setTimeout(fetchStatus, 5000)
|
||||||
|
} catch (e) {
|
||||||
|
setMessage({ type: 'error', text: e instanceof Error ? e.message : 'Sync failed' })
|
||||||
|
} finally {
|
||||||
|
setSyncingSwitch(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerCzdsSync = async () => {
|
||||||
|
if (syncingCzds) return
|
||||||
|
setSyncingCzds(true)
|
||||||
|
setMessage(null)
|
||||||
|
try {
|
||||||
|
await api.post('/admin/zone-sync/czds')
|
||||||
|
setMessage({ type: 'success', text: 'ICANN CZDS sync started (parallel mode)! Check logs for progress.' })
|
||||||
|
// Refresh status after a delay
|
||||||
|
setTimeout(fetchStatus, 5000)
|
||||||
|
} catch (e) {
|
||||||
|
setMessage({ type: 'error', text: e instanceof Error ? e.message : 'Sync failed' })
|
||||||
|
} finally {
|
||||||
|
setSyncingCzds(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string | null) => {
|
||||||
|
if (!dateStr) return 'Never'
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - date.getTime()
|
||||||
|
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||||
|
|
||||||
|
if (hours < 1) return 'Just now'
|
||||||
|
if (hours < 24) return `${hours}h ago`
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusIcon = (s: string) => {
|
||||||
|
switch (s) {
|
||||||
|
case 'healthy': return <CheckCircle2 className="w-4 h-4 text-accent" />
|
||||||
|
case 'stale': return <AlertTriangle className="w-4 h-4 text-amber-400" />
|
||||||
|
default: return <XCircle className="w-4 h-4 text-rose-400" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-8 h-8 text-accent animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-white/[0.02] border border-white/[0.08] p-4">
|
||||||
|
<div className="flex items-center gap-2 text-white/40 text-xs font-mono uppercase mb-2">
|
||||||
|
<Globe className="w-4 h-4" />
|
||||||
|
Zones
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-white">{status?.summary.total_zones || 0}</div>
|
||||||
|
<div className="text-xs text-white/30">
|
||||||
|
{status?.summary.healthy || 0} healthy
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/[0.02] border border-white/[0.08] p-4">
|
||||||
|
<div className="flex items-center gap-2 text-white/40 text-xs font-mono uppercase mb-2">
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
Today
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-accent">{status?.summary.total_drops_today?.toLocaleString() || 0}</div>
|
||||||
|
<div className="text-xs text-white/30">drops detected</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/[0.02] border border-white/[0.08] p-4">
|
||||||
|
<div className="flex items-center gap-2 text-white/40 text-xs font-mono uppercase mb-2">
|
||||||
|
<Database className="w-4 h-4" />
|
||||||
|
Total
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-white">{status?.summary.total_drops_all?.toLocaleString() || 0}</div>
|
||||||
|
<div className="text-xs text-white/30">drops in database</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/[0.02] border border-white/[0.08] p-4">
|
||||||
|
<div className="flex items-center gap-2 text-white/40 text-xs font-mono uppercase mb-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
Status
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{status?.summary.stale || status?.summary.never_synced ? (
|
||||||
|
<>
|
||||||
|
<AlertTriangle className="w-5 h-5 text-amber-400" />
|
||||||
|
<span className="text-amber-400 font-bold">Needs Attention</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-accent" />
|
||||||
|
<span className="text-accent font-bold">All Healthy</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<button
|
||||||
|
onClick={triggerSwitchSync}
|
||||||
|
disabled={syncingSwitch}
|
||||||
|
className="flex items-center gap-2 px-4 py-3 bg-white/[0.05] border border-white/[0.08] text-white hover:bg-white/[0.08] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{syncingSwitch ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||||
|
Sync Switch.ch (.ch, .li)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={triggerCzdsSync}
|
||||||
|
disabled={syncingCzds}
|
||||||
|
className="flex items-center gap-2 px-4 py-3 bg-accent/10 border border-accent/30 text-accent hover:bg-accent/20 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{syncingCzds ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||||
|
Sync ICANN CZDS (gTLDs)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={fetchStatus}
|
||||||
|
className="flex items-center gap-2 px-4 py-3 border border-white/[0.08] text-white/60 hover:text-white hover:bg-white/[0.05] transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Refresh Status
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
{message && (
|
||||||
|
<div className={clsx(
|
||||||
|
"p-4 border",
|
||||||
|
message.type === 'success' ? "bg-accent/10 border-accent/30 text-accent" : "bg-rose-500/10 border-rose-500/30 text-rose-400"
|
||||||
|
)}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Zone Table */}
|
||||||
|
<div className="border border-white/[0.08] overflow-hidden">
|
||||||
|
<div className="grid grid-cols-[80px_1fr_120px_120px_120px_100px] gap-4 px-4 py-3 bg-white/[0.02] text-xs font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08]">
|
||||||
|
<div>TLD</div>
|
||||||
|
<div>Last Sync</div>
|
||||||
|
<div className="text-right">Domains</div>
|
||||||
|
<div className="text-right">Today</div>
|
||||||
|
<div className="text-right">Total Drops</div>
|
||||||
|
<div className="text-center">Status</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-white/[0.04]">
|
||||||
|
{status?.zones.map((zone) => (
|
||||||
|
<div
|
||||||
|
key={zone.tld}
|
||||||
|
className="grid grid-cols-[80px_1fr_120px_120px_120px_100px] gap-4 px-4 py-3 items-center hover:bg-white/[0.02] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="font-mono font-bold text-white">.{zone.tld}</div>
|
||||||
|
<div className="text-sm text-white/60">{formatDate(zone.last_sync)}</div>
|
||||||
|
<div className="text-right font-mono text-white/60">{zone.domain_count?.toLocaleString() || '-'}</div>
|
||||||
|
<div className="text-right font-mono text-accent font-bold">{zone.drops_today?.toLocaleString() || '0'}</div>
|
||||||
|
<div className="text-right font-mono text-white/40">{zone.total_drops?.toLocaleString() || '0'}</div>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
{getStatusIcon(zone.status)}
|
||||||
|
<span className={clsx(
|
||||||
|
"text-xs font-mono uppercase",
|
||||||
|
zone.status === 'healthy' ? "text-accent" : zone.status === 'stale' ? "text-amber-400" : "text-rose-400"
|
||||||
|
)}>
|
||||||
|
{zone.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Schedule Info */}
|
||||||
|
<div className="bg-white/[0.02] border border-white/[0.08] p-4">
|
||||||
|
<h3 className="text-sm font-bold text-white mb-3">Automatic Sync Schedule</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Clock className="w-4 h-4 text-white/40 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<div className="text-white font-medium">Switch.ch (.ch, .li)</div>
|
||||||
|
<div className="text-white/40">Daily at 05:00 UTC (06:00 CH)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Clock className="w-4 h-4 text-white/40 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<div className="text-white font-medium">ICANN CZDS (gTLDs)</div>
|
||||||
|
<div className="text-white/40">Daily at 06:00 UTC (07:00 CH)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1616,28 +1616,6 @@ class AdminApiClient extends ApiClient {
|
|||||||
}>(`/admin/activity-log?limit=${limit}&offset=${offset}`)
|
}>(`/admin/activity-log?limit=${limit}&offset=${offset}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============== Zone Sync ==============
|
|
||||||
|
|
||||||
async getZoneStats() {
|
|
||||||
return this.request<{
|
|
||||||
zones: Array<{
|
|
||||||
tld: string
|
|
||||||
total_domains: number
|
|
||||||
drops_24h: number
|
|
||||||
drops_48h: number
|
|
||||||
last_sync: string | null
|
|
||||||
}>
|
|
||||||
}>('/admin/zone-stats')
|
|
||||||
}
|
|
||||||
|
|
||||||
async triggerSwitchSync() {
|
|
||||||
return this.request<{ status: string; message: string }>('/admin/zone-sync/switch', { method: 'POST' })
|
|
||||||
}
|
|
||||||
|
|
||||||
async triggerCzdsSync() {
|
|
||||||
return this.request<{ status: string; message: string }>('/admin/zone-sync/czds', { method: 'POST' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============== Blog ==============
|
// ============== Blog ==============
|
||||||
|
|
||||||
async getBlogPosts(limit = 10, offset = 0, category?: string, tag?: string) {
|
async getBlogPosts(limit = 10, offset = 0, category?: string, tag?: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user