Spaces:
Running
Running
| from fastapi import FastAPI, HTTPException, Response, Request | |
| from fastapi.responses import HTMLResponse | |
| from pydantic import BaseModel, validator | |
| import requests | |
| import os | |
| import base64 | |
| import json | |
| from typing import Optional, List | |
| import random | |
| import time | |
| from datetime import datetime | |
| import psutil | |
| import logging | |
| # Configure logging | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| def update_deployment_status(repo_owner, repo_name, workflow_run_id, status, conclusion=None, workflow_url=None): | |
| """Forward deployment status to VistaPanel Backend API""" | |
| try: | |
| payload = { | |
| 'repo_owner': repo_owner, | |
| 'repo_name': repo_name, | |
| 'workflow_run_id': workflow_run_id, | |
| 'status': status, | |
| 'conclusion': conclusion, | |
| 'workflow_url': workflow_url | |
| } | |
| # The VistaPanel endpoint URL | |
| api_url = os.getenv('CELESTINE_API_URL', 'https://celestine.indevs.in/api/webhook-receiver.php') | |
| secret = os.getenv('WEBHOOK_SECRET', 'savage') | |
| headers = { | |
| 'X-Celestine-Secret': secret, | |
| 'Content-Type': 'application/json', | |
| 'User-Agent': 'Celestine-HF-Space/1.0' | |
| } | |
| logger.info(f"📤 Forwarding to VistaPanel: {api_url}") | |
| logger.info(f" Payload: {json.dumps(payload, indent=2)}") | |
| # Increase timeout and add retry logic | |
| response = requests.post( | |
| api_url, | |
| json=payload, | |
| headers=headers, | |
| timeout=15, | |
| allow_redirects=True | |
| ) | |
| logger.info(f" Response Status: {response.status_code}") | |
| logger.info(f" Response Body: {response.text[:200]}") | |
| if response.status_code == 200: | |
| logger.info(f"✓ Forwarded status to Celestine: {repo_owner}/{repo_name} -> {status}") | |
| return True | |
| else: | |
| logger.error(f"Failed to forward status: HTTP {response.status_code} - {response.text}") | |
| return False | |
| except requests.exceptions.Timeout: | |
| logger.error(f"Webhook forwarding timeout after 15s") | |
| return False | |
| except requests.exceptions.ConnectionError as e: | |
| logger.error(f"Webhook forwarding connection error: {e}") | |
| return False | |
| except Exception as e: | |
| logger.error(f"Webhook forwarding error: {e}") | |
| return False | |
| app = FastAPI(title="MOFH API Proxy + IONA AI") | |
| # Track uptime | |
| START_TIME = time.time() | |
| REQUEST_COUNT = {"total": 0, "generate": 0, "create": 0, "errors": 0} | |
| # High-quality image library for professional websites | |
| WEBSITE_IMAGES = [ | |
| "https://images.unsplash.com/photo-1497366754035-f200968a6e72?auto=format&fit=crop&q=80&w=1920&h=1080", | |
| "https://images.unsplash.com/photo-1497366811353-6870744d04b2?auto=format&fit=crop&q=80&w=1920&h=1080", | |
| "https://images.unsplash.com/photo-1497215728101-856f4ea42174?auto=format&fit=crop&q=80&w=1920&h=1080", | |
| "https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&q=80&w=1920&h=1080", | |
| "https://images.unsplash.com/photo-1441986300917-64674bd600d8?auto=format&fit=crop&q=80&w=1920&h=1080", | |
| "https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&q=80&w=1920&h=1080", | |
| "https://images.unsplash.com/photo-1504384308090-c894fdcc538d?auto=format&fit=crop&q=80&w=1920&h=1080", | |
| "https://images.unsplash.com/photo-1517245386807-bb43f82c33c4?auto=format&fit=crop&q=80&w=1920&h=1080", | |
| "https://images.unsplash.com/photo-1519389950473-47ba0277781c?auto=format&fit=crop&q=80&w=1920&h=1080", | |
| "https://images.unsplash.com/photo-1542744094-24638eff58bb?auto=format&fit=crop&q=80&w=1920&h=1080", | |
| "https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&q=80&w=1920&h=1080", | |
| "https://images.unsplash.com/photo-1522202176988-66273c2fd55f?auto=format&fit=crop&q=80&w=1920&h=1080", | |
| "https://images.unsplash.com/photo-1552664730-d307ca884978?auto=format&fit=crop&q=80&w=1920&h=1080", | |
| "https://images.unsplash.com/photo-1531482615713-2afd69097998?auto=format&fit=crop&q=80&w=1920&h=1080", | |
| "https://images.unsplash.com/photo-1498050108023-c5249f4df085?auto=format&fit=crop&q=80&w=1920&h=1080", | |
| "https://images.unsplash.com/photo-1516321318423-f06f85e504b3?auto=format&fit=crop&q=80&w=1920&h=1080" | |
| ] | |
| def get_random_images(count=10): | |
| """Get random images from the library""" | |
| return random.sample(WEBSITE_IMAGES, min(count, len(WEBSITE_IMAGES))) | |
| MOFH_API_URL = "https://panel.myownfreehost.net:2087/xml-api/" | |
| MOFH_API_USERNAME = "ZqHK1H5BBnrrywnsXXW2pY42QNqDxHru74HtwZofkn1LRawd4TDcKCPnIViBiPaJg1sUXUlKErn4EhXmqu0vx1STF263TxiTQuyKzYcErjSax5W4lBoLXqpE0aQqnLLKi8Pk7fncTbcfwqnKVRNOzLd34xdakdiHH1Z4gmDbetSmYauCId4Cj8SVizSBTuN6TMIrz2oMKAoXPypSocVOOUI0rSOIld34MY6yXtcHdSgh3Jd5J8mgM8SiW8BvHtE" | |
| MOFH_API_PASSWORD = "Uj39SyF5okKP73Ml6laLEay40MZEXSe81lStBEc9dQC2odK49Tw4K5l4BXI3DfnIVA5ZoIH0GFKvBRBO1cLIU1djjSBCABBkp2GO2oeH4BROw238dZwODu0kklR0uezHPfezHMCbcbjSKLRKJX5Ns7gZ9wgXxZbO3e8Rf4MoZnQVk5IUNLvKPWHDP43PsQnAFAYOhDNnKPBBg2CoHXsJ1Kq8C5wRuBd64GPyPhY7sbSCibAMUWKRkUSzRi7aq5i" | |
| # IONA AI - Multiple API Keys for Load Balancing (No Rate Limits) | |
| GROQ_API_KEYS = [ | |
| "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", # Replace with real keys | |
| "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", | |
| "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", | |
| "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" | |
| ] | |
| # Model Configuration - Use ONLY the most capable model | |
| AI_MODELS = { | |
| "groq": [ | |
| "llama-3.3-70b-versatile", # Most capable, reliable for complex generation | |
| ] | |
| } | |
| # Track API usage for rotation | |
| api_usage_counter = {key: 0 for key in GROQ_API_KEYS} | |
| def get_next_api_key(): | |
| """Round-robin API key selection for load balancing""" | |
| min_usage_key = min(api_usage_counter, key=api_usage_counter.get) | |
| api_usage_counter[min_usage_key] += 1 | |
| return min_usage_key | |
| def get_random_model(): | |
| """Select model (always use 70B for reliability)""" | |
| return AI_MODELS["groq"][0] # Always use llama-3.3-70b-versatile | |
| class AIWebsiteRequest(BaseModel): | |
| prompt: str | |
| business_type: Optional[str] = "general" | |
| color_scheme: Optional[str] = "modern" | |
| include_database: Optional[bool] = True | |
| php_only: Optional[bool] = False | |
| features: Optional[List[str]] = [] | |
| def validate_prompt(cls, v): | |
| if not v or len(v.strip()) < 10: | |
| raise ValueError('Prompt must be at least 10 characters') | |
| if len(v) > 10000: | |
| raise ValueError('Prompt too long (max 10000 characters)') | |
| return v.strip() | |
| class AccountRequest(BaseModel): | |
| username: str | |
| password: str | |
| email: str | |
| domain: str | |
| plan: str = "free" | |
| def validate_username(cls, v): | |
| if not v or len(v) < 3: | |
| raise ValueError('Username must be at least 3 characters') | |
| if len(v) > 20: | |
| raise ValueError('Username too long (max 20 characters)') | |
| return v.strip() | |
| def validate_email(cls, v): | |
| if '@' not in v or '.' not in v: | |
| raise ValueError('Invalid email format') | |
| return v.strip() | |
| def root(): | |
| """Uptime dashboard with external IP display""" | |
| import socket | |
| hostname = socket.gethostname() | |
| local_ip = socket.gethostbyname(hostname) | |
| # Get external IP | |
| try: | |
| external_ip = requests.get('https://api.ipify.org', timeout=5).text | |
| except: | |
| external_ip = "Unable to detect" | |
| # Calculate uptime | |
| uptime_seconds = int(time.time() - START_TIME) | |
| uptime_hours = uptime_seconds // 3600 | |
| uptime_minutes = (uptime_seconds % 3600) // 60 | |
| uptime_secs = uptime_seconds % 60 | |
| # Get system stats | |
| try: | |
| cpu_percent = psutil.cpu_percent(interval=1) | |
| memory = psutil.virtual_memory() | |
| memory_percent = memory.percent | |
| memory_used = memory.used / (1024**3) # GB | |
| memory_total = memory.total / (1024**3) # GB | |
| except: | |
| cpu_percent = 0 | |
| memory_percent = 0 | |
| memory_used = 0 | |
| memory_total = 0 | |
| # API key status | |
| api_key_usage = [] | |
| for key, count in api_usage_counter.items(): | |
| api_key_usage.append(f"{key[:20]}... → {count} requests") | |
| html_content = f""" | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>IONA AI - System Status</title> | |
| <style> | |
| * {{ | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| }} | |
| body {{ | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| min-height: 100vh; | |
| padding: 20px; | |
| color: #fff; | |
| }} | |
| .container {{ | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| }} | |
| .header {{ | |
| text-align: center; | |
| margin-bottom: 40px; | |
| animation: fadeIn 0.5s; | |
| }} | |
| .header h1 {{ | |
| font-size: 3rem; | |
| margin-bottom: 10px; | |
| text-shadow: 2px 2px 4px rgba(0,0,0,0.3); | |
| }} | |
| .header p {{ | |
| font-size: 1.2rem; | |
| opacity: 0.9; | |
| }} | |
| .status-badge {{ | |
| display: inline-block; | |
| background: #10b981; | |
| padding: 8px 20px; | |
| border-radius: 50px; | |
| font-weight: bold; | |
| margin-top: 10px; | |
| box-shadow: 0 4px 6px rgba(0,0,0,0.2); | |
| animation: pulse 2s infinite; | |
| }} | |
| @keyframes pulse {{ | |
| 0%, 100% {{ transform: scale(1); }} | |
| 50% {{ transform: scale(1.05); }} | |
| }} | |
| @keyframes fadeIn {{ | |
| from {{ opacity: 0; transform: translateY(-20px); }} | |
| to {{ opacity: 1; transform: translateY(0); }} | |
| }} | |
| .grid {{ | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); | |
| gap: 20px; | |
| margin-bottom: 30px; | |
| }} | |
| .card {{ | |
| background: rgba(255, 255, 255, 0.1); | |
| backdrop-filter: blur(10px); | |
| border-radius: 15px; | |
| padding: 25px; | |
| box-shadow: 0 8px 32px rgba(0,0,0,0.2); | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| animation: fadeIn 0.5s; | |
| transition: transform 0.3s; | |
| }} | |
| .card:hover {{ | |
| transform: translateY(-5px); | |
| }} | |
| .card h2 {{ | |
| font-size: 1.5rem; | |
| margin-bottom: 15px; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| }} | |
| .card-icon {{ | |
| font-size: 2rem; | |
| }} | |
| .stat {{ | |
| display: flex; | |
| justify-content: space-between; | |
| padding: 10px 0; | |
| border-bottom: 1px solid rgba(255, 255, 255, 0.1); | |
| }} | |
| .stat:last-child {{ | |
| border-bottom: none; | |
| }} | |
| .stat-label {{ | |
| opacity: 0.8; | |
| }} | |
| .stat-value {{ | |
| font-weight: bold; | |
| font-size: 1.1rem; | |
| }} | |
| .ip-box {{ | |
| background: rgba(0, 0, 0, 0.3); | |
| padding: 15px; | |
| border-radius: 10px; | |
| margin-top: 15px; | |
| font-family: 'Courier New', monospace; | |
| font-size: 1.1rem; | |
| text-align: center; | |
| border: 2px solid rgba(255, 255, 255, 0.3); | |
| }} | |
| .progress-bar {{ | |
| width: 100%; | |
| height: 20px; | |
| background: rgba(0, 0, 0, 0.3); | |
| border-radius: 10px; | |
| overflow: hidden; | |
| margin-top: 10px; | |
| }} | |
| .progress-fill {{ | |
| height: 100%; | |
| background: linear-gradient(90deg, #10b981, #3b82f6); | |
| transition: width 0.3s; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 0.85rem; | |
| font-weight: bold; | |
| }} | |
| .endpoint-list {{ | |
| list-style: none; | |
| margin-top: 15px; | |
| }} | |
| .endpoint-list li {{ | |
| padding: 10px; | |
| background: rgba(0, 0, 0, 0.2); | |
| margin-bottom: 8px; | |
| border-radius: 8px; | |
| border-left: 4px solid #10b981; | |
| }} | |
| .endpoint-method {{ | |
| display: inline-block; | |
| background: #3b82f6; | |
| padding: 3px 10px; | |
| border-radius: 5px; | |
| font-size: 0.85rem; | |
| font-weight: bold; | |
| margin-right: 10px; | |
| }} | |
| .footer {{ | |
| text-align: center; | |
| margin-top: 40px; | |
| opacity: 0.8; | |
| }} | |
| .refresh-btn {{ | |
| background: rgba(255, 255, 255, 0.2); | |
| border: 2px solid rgba(255, 255, 255, 0.5); | |
| color: white; | |
| padding: 12px 30px; | |
| border-radius: 50px; | |
| cursor: pointer; | |
| font-size: 1rem; | |
| font-weight: bold; | |
| transition: all 0.3s; | |
| margin-top: 20px; | |
| }} | |
| .refresh-btn:hover {{ | |
| background: rgba(255, 255, 255, 0.3); | |
| transform: scale(1.05); | |
| }} | |
| .api-key-list {{ | |
| font-size: 0.9rem; | |
| margin-top: 10px; | |
| }} | |
| .api-key-list div {{ | |
| padding: 5px; | |
| background: rgba(0, 0, 0, 0.2); | |
| margin-bottom: 5px; | |
| border-radius: 5px; | |
| font-family: 'Courier New', monospace; | |
| }} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <div class="header"> | |
| <h1>Celestine Hosting Backend</h1> | |
| <p>Intelligent Website Generator & MOFH API Proxy</p> | |
| <div class="status-badge">🟢 ONLINE</div> | |
| </div> | |
| <div class="grid"> | |
| <div class="card"> | |
| <h2><span class="card-icon">⏱️</span> Uptime</h2> | |
| <div class="stat"> | |
| <span class="stat-label">Running Since</span> | |
| <span class="stat-value">{datetime.fromtimestamp(START_TIME).strftime('%Y-%m-%d %H:%M:%S')}</span> | |
| </div> | |
| <div class="stat"> | |
| <span class="stat-label">Uptime</span> | |
| <span class="stat-value">{uptime_hours}h {uptime_minutes}m {uptime_secs}s</span> | |
| </div> | |
| <div class="stat"> | |
| <span class="stat-label">Total Requests</span> | |
| <span class="stat-value">{REQUEST_COUNT['total']}</span> | |
| </div> | |
| <div class="stat"> | |
| <span class="stat-label">Errors</span> | |
| <span class="stat-value">{REQUEST_COUNT['errors']}</span> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h2><span class="card-icon">🌐</span> Network Info</h2> | |
| <div class="stat"> | |
| <span class="stat-label">Hostname</span> | |
| <span class="stat-value">{hostname}</span> | |
| </div> | |
| <div class="stat"> | |
| <span class="stat-label">Local IP</span> | |
| <span class="stat-value">{local_ip}</span> | |
| </div> | |
| <div class="ip-box"> | |
| <div style="opacity: 0.7; font-size: 0.9rem; margin-bottom: 5px;">External IP Address</div> | |
| <div style="font-size: 1.3rem; font-weight: bold;">{external_ip}</div> | |
| </div> | |
| <div style="margin-top: 15px; padding: 10px; background: rgba(255, 193, 7, 0.2); border-radius: 8px; font-size: 0.85rem;"> | |
| ⚠️ Add this IP to MOFH API whitelist | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h2><span class="card-icon">💻</span> System Resources</h2> | |
| <div class="stat"> | |
| <span class="stat-label">CPU Usage</span> | |
| <span class="stat-value">{cpu_percent:.1f}%</span> | |
| </div> | |
| <div class="progress-bar"> | |
| <div class="progress-fill" style="width: {cpu_percent}%">{cpu_percent:.1f}%</div> | |
| </div> | |
| <div class="stat" style="margin-top: 15px;"> | |
| <span class="stat-label">Memory Usage</span> | |
| <span class="stat-value">{memory_percent:.1f}%</span> | |
| </div> | |
| <div class="progress-bar"> | |
| <div class="progress-fill" style="width: {memory_percent}%">{memory_percent:.1f}%</div> | |
| </div> | |
| <div class="stat" style="margin-top: 10px;"> | |
| <span class="stat-label">Memory</span> | |
| <span class="stat-value">{memory_used:.2f} GB / {memory_total:.2f} GB</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="grid"> | |
| <div class="card"> | |
| <h2><span class="card-icon">🤖</span> AI Configuration</h2> | |
| <div class="stat"> | |
| <span class="stat-label">Active Models</span> | |
| <span class="stat-value">{len(AI_MODELS['groq'])}</span> | |
| </div> | |
| <div class="stat"> | |
| <span class="stat-label">API Keys</span> | |
| <span class="stat-value">{len(GROQ_API_KEYS)}</span> | |
| </div> | |
| <div class="stat"> | |
| <span class="stat-label">Generations</span> | |
| <span class="stat-value">{REQUEST_COUNT['generate']}</span> | |
| </div> | |
| <div class="api-key-list"> | |
| {''.join([f'<div>{usage}</div>' for usage in api_key_usage])} | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h2><span class="card-icon">📡</span> API Endpoints</h2> | |
| <ul class="endpoint-list"> | |
| <li> | |
| <span class="endpoint-method">GET</span> | |
| <span>/</span> | |
| <div style="font-size: 0.85rem; opacity: 0.8; margin-top: 5px;">System status dashboard</div> | |
| </li> | |
| <li> | |
| <span class="endpoint-method">POST</span> | |
| <span>/generate-website</span> | |
| <div style="font-size: 0.85rem; opacity: 0.8; margin-top: 5px;">Generate website with IONA AI</div> | |
| </li> | |
| <li> | |
| <span class="endpoint-method">POST</span> | |
| <span>/create</span> | |
| <div style="font-size: 0.85rem; opacity: 0.8; margin-top: 5px;">Create MOFH hosting account</div> | |
| </li> | |
| <li> | |
| <span class="endpoint-method">GET</span> | |
| <span>/health</span> | |
| <div style="font-size: 0.85rem; opacity: 0.8; margin-top: 5px;">Health check endpoint</div> | |
| </li> | |
| <li> | |
| <span class="endpoint-method">GET</span> | |
| <span>/test</span> | |
| <div style="font-size: 0.85rem; opacity: 0.8; margin-top: 5px;">Test AI generation</div> | |
| </li> | |
| </ul> | |
| </div> | |
| <div class="card"> | |
| <h2><span class="card-icon">👨💻</span> Developer Info</h2> | |
| <div class="stat"> | |
| <span class="stat-label">Developer</span> | |
| <span class="stat-value">Pratyush Srivastava</span> | |
| </div> | |
| <div class="stat"> | |
| <span class="stat-label">GitHub</span> | |
| <span class="stat-value"><a href="https://github.com/pratyush" style="color: #fff; text-decoration: none;">@pratyush</a></span> | |
| </div> | |
| <div class="stat"> | |
| <span class="stat-label">Portfolio</span> | |
| <span class="stat-value"><a href="https://pratyush.dev" style="color: #fff; text-decoration: none;">pratyush.dev</a></span> | |
| </div> | |
| <div class="stat"> | |
| <span class="stat-label">Version</span> | |
| <span class="stat-value">2026.1.0</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div style="text-align: center;"> | |
| <button class="refresh-btn" onclick="location.reload()">🔄 Refresh Status</button> | |
| </div> | |
| <div class="footer"> | |
| <p>IONA AI - Intelligent Website Generator</p> | |
| <p style="font-size: 0.9rem; margin-top: 10px;">Powered by Groq AI • FastAPI • Python</p> | |
| </div> | |
| </div> | |
| <script> | |
| // Auto-refresh every 30 seconds | |
| setTimeout(() => location.reload(), 30000); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| return html_content | |
| def health_check(): | |
| """Health check endpoint for monitoring""" | |
| return { | |
| "status": "healthy", | |
| "uptime_seconds": int(time.time() - START_TIME), | |
| "timestamp": datetime.now().isoformat(), | |
| "requests": REQUEST_COUNT | |
| } | |
| def test_generation(): | |
| """Test AI generation with a simple prompt""" | |
| try: | |
| api_key = get_next_api_key() | |
| model = get_random_model() | |
| test_prompt = "Create a simple HTML page with a heading 'Hello World' and a paragraph." | |
| headers = { | |
| "Authorization": f"Bearer {api_key}", | |
| "Content-Type": "application/json" | |
| } | |
| payload = { | |
| "model": model, | |
| "messages": [ | |
| {"role": "system", "content": "You are a helpful assistant. Return only valid JSON."}, | |
| {"role": "user", "content": test_prompt} | |
| ], | |
| "temperature": 0.7, | |
| "max_tokens": 500 | |
| } | |
| response = requests.post( | |
| "https://api.groq.com/openai/v1/chat/completions", | |
| headers=headers, | |
| json=payload, | |
| timeout=30 | |
| ) | |
| if response.status_code == 200: | |
| result = response.json() | |
| return { | |
| "success": True, | |
| "model": model, | |
| "response_length": len(result['choices'][0]['message']['content']), | |
| "tokens_used": result.get('usage', {}), | |
| "message": "AI is working correctly!" | |
| } | |
| else: | |
| return { | |
| "success": False, | |
| "error": f"HTTP {response.status_code}", | |
| "message": "AI test failed" | |
| } | |
| except Exception as e: | |
| return { | |
| "success": False, | |
| "error": str(e), | |
| "message": "AI test failed with exception" | |
| } | |
| def generate_website(req: AIWebsiteRequest): | |
| """IONA AI - Generate complete website with HTML, CSS, JS, and MySQL""" | |
| REQUEST_COUNT["total"] += 1 | |
| REQUEST_COUNT["generate"] += 1 | |
| try: | |
| logger.info("=== IONA AI Generation Started ===") | |
| logger.info(f"Business Type: {req.business_type}") | |
| logger.info(f"Prompt Length: {len(req.prompt)} chars") | |
| logger.info(f"PHP Only Mode: {req.php_only}") | |
| api_key = get_next_api_key() | |
| model = get_random_model() | |
| logger.info(f"Model: {model}, API Key: {api_key[:20]}...") | |
| # Get random images for this website | |
| selected_images = get_random_images(15) | |
| images_list = '\n'.join([f"- {img}" for img in selected_images]) | |
| # Check if PHP-only mode | |
| if req.php_only: | |
| # PHP-only generation prompt | |
| system_prompt = f"""You are IONA AI, an expert PHP backend developer. | |
| 🎯 HOSTING ENVIRONMENT: VistaPanel (Free Hosting) | |
| - PHP Version: 7.4 or 8.0 | |
| - MySQL Database available | |
| - PDO and MySQLi supported | |
| 🎯 MISSION: Generate ONLY PHP BACKEND FILES (NO HTML/CSS/JS). | |
| 📦 REQUIRED OUTPUT FORMAT: | |
| Return ONLY a valid JSON object with these keys: | |
| {{ | |
| "contact_php": "Complete contact form handler with validation and email sending", | |
| "api_php": "RESTful API endpoints with JSON responses", | |
| "functions_php": "Helper functions for database, validation, sanitization", | |
| "config": "Database configuration with PDO connection", | |
| "sql": "MySQL schema with sample data", | |
| "readme": "Setup guide for VistaPanel" | |
| }} | |
| 🐘 PHP FILE REQUIREMENTS: | |
| 1. **contact_php** (Contact Form Handler): | |
| - Handle POST requests | |
| - Validate: name, email, phone, message | |
| - Sanitize all inputs (htmlspecialchars, filter_var) | |
| - Send email using mail() function | |
| - Store in database | |
| - Return JSON response | |
| - CSRF protection | |
| - Rate limiting (session-based) | |
| - Error handling | |
| 2. **api_php** (API Endpoints): | |
| - RESTful structure | |
| - GET /api.php?action=list (list items) | |
| - POST /api.php?action=create (create item) | |
| - PUT /api.php?action=update (update item) | |
| - DELETE /api.php?action=delete (delete item) | |
| - JSON responses | |
| - Authentication (if needed) | |
| - Input validation | |
| - Error handling | |
| 3. **functions_php** (Helper Functions): | |
| - Database query helpers (select, insert, update, delete) | |
| - Input validation functions | |
| - Sanitization functions | |
| - Email sending function | |
| - Authentication helpers | |
| - Session management | |
| - Error logging | |
| 4. **config** (Database Configuration): | |
| - PDO connection setup | |
| - Database credentials (localhost, username, password, dbname) | |
| - Error handling (try-catch) | |
| - Timezone settings | |
| - Error reporting settings | |
| - Session configuration | |
| 5. **sql** (MySQL Schema): | |
| - CREATE DATABASE statement | |
| - CREATE TABLE statements | |
| - Proper data types (VARCHAR, INT, TEXT, DATETIME) | |
| - Primary keys (AUTO_INCREMENT) | |
| - Foreign keys and indexes | |
| - Timestamps (created_at, updated_at) | |
| - Sample data (5-10 realistic entries) | |
| PHP SECURITY REQUIREMENTS: | |
| - Use prepared statements (PDO) for ALL database queries | |
| - Sanitize ALL user inputs | |
| - Validate email addresses with filter_var() | |
| - Use password_hash() for passwords | |
| - Implement CSRF tokens | |
| - Use htmlspecialchars() for output | |
| - Set proper error reporting | |
| - Use sessions securely | |
| PHP BEST PRACTICES: | |
| - PSR-12 coding standards | |
| - Proper indentation (4 spaces) | |
| - Clear variable names | |
| - Comments for complex logic | |
| - Separate concerns | |
| - Return JSON for AJAX requests | |
| - Use HTTP status codes | |
| - Error handling (try-catch) | |
| CODE FORMATTING: | |
| - Proper indentation (4 spaces per level) | |
| - Blank lines between functions | |
| - Comments for each function | |
| - Use \n for newlines in JSON strings | |
| IMPORTANT JSON RULES: | |
| - Return ONLY the JSON object | |
| - NO markdown code blocks | |
| - NO explanations | |
| - Escape quotes with backslash | |
| - Use \n for newlines | |
| - Do NOT return null values | |
| - Generate COMPLETE, FUNCTIONAL code | |
| CREATE PROFESSIONAL PHP BACKEND FOR VISTAPANEL! 🚀""" | |
| user_prompt = f"""Generate PHP backend files for: {req.prompt} | |
| Requirements: | |
| - Complete contact form handler | |
| - RESTful API endpoints | |
| - Helper functions | |
| - Database configuration | |
| - MySQL schema with sample data | |
| Return as JSON with contact_php, api_php, functions_php, config, sql, and readme keys.""" | |
| else: | |
| # Full website generation prompt | |
| system_prompt = f"""You are IONA AI, an elite full-stack web developer with 15+ years of experience. | |
| 🎯 HOSTING ENVIRONMENT: VistaPanel (Free Hosting) | |
| - PHP Version: 7.4 or 8.0 | |
| - MySQL Database available | |
| - File Manager: VistaPanel | |
| - No SSH access | |
| - Standard PHP functions available | |
| - PDO and MySQLi supported | |
| 🎯 MISSION: Generate COMPLETE, PRODUCTION-READY, MULTI-FILE websites with REAL CONTENT. | |
| 📦 REQUIRED OUTPUT FORMAT: | |
| Return ONLY a valid JSON object with these keys: | |
| {{ | |
| "html": "Complete HTML5 document with PROPER INDENTATION (2 spaces per level)", | |
| "css": "Complete CSS with PROPER FORMATTING and line breaks", | |
| "js": "Complete JavaScript with PROPER FORMATTING", | |
| "php": "PHP backend files (contact.php, api.php, etc.) with PROPER FORMATTING", | |
| "sql": "MySQL schema with sample data (if database needed)", | |
| "config": "PHP config file with database connection (if database needed)", | |
| "readme": "Setup and customization guide for VistaPanel" | |
| }} | |
| 🖼️ USE THESE REAL IMAGES (randomly selected for this website): | |
| {images_list} | |
| IMPORTANT IMAGE USAGE: | |
| - Use different images for hero, gallery, team, testimonials, blog posts | |
| - Add proper alt text describing each image | |
| - Use responsive image techniques (srcset if needed) | |
| - Images are high-quality 1920x1080, perfect for hero sections | |
| ✨ CODE FORMATTING RULES (CRITICAL): | |
| 1. HTML: Proper indentation with 2 spaces per level | |
| 2. CSS: One rule per line, organized by sections with comments | |
| 3. JavaScript: Proper function formatting with line breaks | |
| 4. PHP: PSR-12 coding standards, proper indentation | |
| 5. NO MINIFIED CODE - Make it readable and maintainable | |
| 6. Add blank lines between major sections | |
| 7. Use \n for newlines in JSON strings | |
| 8. Properly indent nested elements | |
| 🐘 PHP FILE GENERATION (IMPORTANT): | |
| When generating PHP files, create COMPLETE, FUNCTIONAL code: | |
| 1. **contact.php** (Contact Form Handler): | |
| - Validate all inputs (name, email, message) | |
| - Sanitize data (htmlspecialchars, filter_var) | |
| - Send email using mail() function | |
| - Store in database if requested | |
| - Return JSON response | |
| - Include CSRF protection | |
| - Rate limiting (session-based) | |
| 2. **config.php** (Database Configuration): | |
| - PDO connection setup | |
| - Error handling (try-catch) | |
| - Database credentials (localhost, username, password, dbname) | |
| - Timezone settings | |
| - Error reporting settings | |
| - Session configuration | |
| 3. **functions.php** (Helper Functions): | |
| - Database query helpers | |
| - Input validation functions | |
| - Sanitization functions | |
| - Email sending function | |
| - Authentication helpers (if needed) | |
| 4. **api.php** (API Endpoints): | |
| - RESTful API structure | |
| - JSON responses | |
| - Error handling | |
| - Input validation | |
| - CORS headers (if needed) | |
| PHP SECURITY REQUIREMENTS: | |
| - Use prepared statements (PDO) for all database queries | |
| - Sanitize ALL user inputs | |
| - Validate email addresses with filter_var() | |
| - Use password_hash() for passwords | |
| - Implement CSRF tokens | |
| - Use htmlspecialchars() for output | |
| - Set proper error reporting | |
| - Use sessions securely | |
| PHP BEST PRACTICES: | |
| - PSR-12 coding standards | |
| - Proper error handling (try-catch) | |
| - Clear variable names | |
| - Comments for complex logic | |
| - Separate concerns (config, functions, handlers) | |
| - Return JSON for AJAX requests | |
| - Use HTTP status codes | |
| Example HTML formatting: | |
| ``` | |
| <!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <title>Page</title>\n</head>\n<body>\n <header>\n <nav>\n <ul>\n <li><a href=\"#home\">Home</a></li>\n </ul>\n </nav>\n </header>\n</body>\n</html> | |
| ``` | |
| Example PHP formatting: | |
| ``` | |
| <?php\nsession_start();\n\nif ($_SERVER['REQUEST_METHOD'] === 'POST') {{\n $name = htmlspecialchars($_POST['name']);\n $email = filter_var($_POST['email'], FILTER_SANITIZE_EMAIL);\n \n if (empty($name) || empty($email)) {{\n echo json_encode(['success' => false, 'error' => 'Required fields missing']);\n exit;\n }}\n \n // Process form\n echo json_encode(['success' => true, 'message' => 'Form submitted']);\n}}\n?> | |
| ``` | |
| 📝 CONTENT REQUIREMENTS: | |
| 1. Write REAL, PROFESSIONAL content (not Lorem Ipsum) | |
| 2. Create compelling headlines and descriptions | |
| 3. Write realistic testimonials with names | |
| 4. Generate actual blog post content | |
| 5. Create detailed service/product descriptions | |
| 6. Write engaging About Us content | |
| 7. Include realistic contact information (use placeholders) | |
| 🎨 DESIGN EXCELLENCE: | |
| - Modern, professional, visually stunning | |
| - Consistent spacing (8px grid system) | |
| - Smooth animations (0.3s transitions) | |
| - Hover effects on interactive elements | |
| - Mobile-first responsive design | |
| - Glassmorphism, gradients, shadows | |
| - Professional color palette | |
| - Clean typography (2-3 fonts max) | |
| 🔧 TECHNICAL REQUIREMENTS: | |
| - Semantic HTML5 (header, nav, main, section, article, footer) | |
| - CSS Grid + Flexbox layouts | |
| - Vanilla JavaScript (ES6+) | |
| - Responsive breakpoints: 320px, 768px, 1024px, 1440px | |
| - Form validation with error messages | |
| - Smooth scroll behavior | |
| - Loading animations | |
| - Interactive elements (accordions, tabs, modals) | |
| 📱 MUST INCLUDE SECTIONS: | |
| 1. Hero section with CTA button | |
| 2. Navigation (sticky on scroll, mobile menu) | |
| 3. About/Services section | |
| 4. Features/Benefits cards | |
| 5. Gallery/Portfolio (if applicable) | |
| 6. Testimonials (if applicable) | |
| 7. Pricing tables (if applicable) | |
| 8. Team members (if applicable) | |
| 9. Blog posts grid (if applicable) | |
| 10. Contact form with validation | |
| 11. Footer with social links | |
| 12. Back-to-top button | |
| 🗄️ DATABASE (if requested): | |
| - Normalized MySQL schema | |
| - Proper indexes and foreign keys | |
| - Sample data (5-10 realistic entries) | |
| - PHP config with PDO connection | |
| - CREATE DATABASE statement | |
| - CREATE TABLE statements | |
| - INSERT sample data | |
| - Proper data types (VARCHAR, INT, TEXT, DATETIME) | |
| - Auto-increment primary keys | |
| - Timestamps (created_at, updated_at) | |
| ⚡ INTERACTIVE FEATURES: | |
| - Smooth scroll to sections | |
| - Fade-in animations on scroll | |
| - Form validation (real-time) | |
| - AJAX form submission (with PHP backend) | |
| - Image lightbox/modal | |
| - Mobile hamburger menu | |
| - Loading spinner | |
| - Success/error messages | |
| - Accordion/FAQ | |
| - Tabs for content | |
| - Carousel/slider (if needed) | |
| 🚀 PERFORMANCE: | |
| - Optimized CSS (no unused styles) | |
| - Efficient JavaScript | |
| - Lazy loading for images | |
| - Minimal HTTP requests | |
| - Fast animations | |
| 📊 SEO & ACCESSIBILITY: | |
| - Proper meta tags (title, description, keywords) | |
| - Open Graph tags | |
| - Semantic HTML structure | |
| - Alt text for all images | |
| - ARIA labels where needed | |
| - Keyboard navigation | |
| - Focus indicators | |
| - Sufficient color contrast | |
| 📋 VISTAPANEL SETUP INSTRUCTIONS (in README): | |
| 1. Upload files via File Manager | |
| 2. Create MySQL database in VistaPanel | |
| 3. Import SQL file using phpMyAdmin | |
| 4. Update config.php with database credentials | |
| 5. Set file permissions (755 for directories, 644 for files) | |
| 6. Test contact form | |
| 7. Customize content and images | |
| IMPORTANT JSON RULES: | |
| - Return ONLY the JSON object | |
| - NO markdown code blocks | |
| - NO explanations before or after | |
| - Escape quotes with backslash | |
| - Use \n for newlines | |
| - Ensure valid JSON syntax | |
| - Do NOT return null values | |
| - Generate COMPLETE, FUNCTIONAL code for ALL files | |
| CREATE PROFESSIONAL, PRODUCTION-READY CODE FOR VISTAPANEL! 🚀""" | |
| # Enhanced user prompt with detailed specifications | |
| features_text = ', '.join(req.features) if req.features else 'Standard features' | |
| color_guide = _get_color_scheme_guide(req.color_scheme) | |
| user_prompt = f"""🎨 PROJECT BRIEF: | |
| 📋 WEBSITE TYPE: {req.business_type.upper()} | |
| 💬 CLIENT DESCRIPTION: | |
| {req.prompt} | |
| 🎨 DESIGN SPECIFICATIONS: | |
| - Color Scheme: {req.color_scheme} | |
| - Style: Modern, professional, visually stunning | |
| - Layout: Clean, spacious, well-organized | |
| - Typography: Professional, readable, hierarchical | |
| ✨ REQUIRED FEATURES: | |
| {features_text} | |
| 🗄️ DATABASE: {'YES - Include full MySQL schema with sample data' if req.include_database else 'NO - Static website only'} | |
| 🎯 SPECIFIC REQUIREMENTS: | |
| 1. HERO SECTION: | |
| - Eye-catching headline | |
| - Compelling subheadline | |
| - Clear call-to-action button | |
| - Background: gradient or image | |
| - Smooth scroll-down indicator | |
| 2. NAVIGATION: | |
| - Sticky header on scroll | |
| - Smooth scroll to sections | |
| - Mobile hamburger menu | |
| - Active section highlighting | |
| 3. CONTENT SECTIONS: | |
| - About/Services section with icons | |
| - Features/Benefits with cards | |
| - Testimonials with star ratings (if requested) | |
| - Gallery with lightbox (if requested) | |
| - Pricing tables (if requested) | |
| - Team members (if requested) | |
| - Blog posts grid (if requested) | |
| - FAQ accordion (if applicable) | |
| 4. CONTACT SECTION: | |
| - Working contact form with validation | |
| - Email, phone, address display | |
| - Social media links | |
| - Google Maps embed (placeholder) | |
| 5. FOOTER: | |
| - Multi-column layout | |
| - Quick links | |
| - Social media icons | |
| - Copyright notice | |
| - Back-to-top button | |
| 6. INTERACTIVE ELEMENTS: | |
| - Smooth scroll animations (fade-in, slide-up) | |
| - Hover effects on cards and buttons | |
| - Loading states for forms | |
| - Success/error messages | |
| - Image lazy loading | |
| - Parallax effects (subtle) | |
| 7. FORMS (if applicable): | |
| - Real-time validation | |
| - Clear error messages | |
| - Success confirmation | |
| - Spam protection (honeypot) | |
| - Required field indicators | |
| 8. PERFORMANCE: | |
| - Optimized CSS (no unused styles) | |
| - Efficient JavaScript (no jQuery) | |
| - Fast loading animations | |
| - Minimal HTTP requests | |
| 9. SEO: | |
| - Proper meta tags (title, description, keywords) | |
| - Open Graph tags for social sharing | |
| - Structured data (JSON-LD) | |
| - Semantic HTML structure | |
| - Alt text for all images | |
| 10. ACCESSIBILITY: | |
| - ARIA labels where needed | |
| - Keyboard navigation support | |
| - Focus indicators | |
| - Sufficient color contrast | |
| - Screen reader friendly | |
| {f'''11. DATABASE STRUCTURE (if applicable): | |
| - Users table (id, name, email, password, created_at) | |
| - Content table (id, title, description, image, created_at) | |
| - Messages/Contacts table (id, name, email, message, created_at) | |
| - Categories/Tags (if blog) | |
| - Proper relationships and indexes | |
| - Sample data for testing''' if req.include_database else ''} | |
| 🎨 COLOR SCHEME GUIDANCE: | |
| {color_guide} | |
| 📱 RESPONSIVE BREAKPOINTS: | |
| - Mobile: 320px - 767px (single column, stacked layout) | |
| - Tablet: 768px - 1023px (2 columns where appropriate) | |
| - Desktop: 1024px+ (full layout, max-width 1400px) | |
| ⚡ MUST INCLUDE: | |
| - Favicon link (placeholder) | |
| - Google Fonts (1-2 professional fonts) | |
| - Font Awesome icons (CDN) | |
| - Smooth scroll behavior | |
| - Loading animation on page load | |
| - 404 error handling | |
| - Print stylesheet basics | |
| 🚫 AVOID: | |
| - jQuery or heavy frameworks | |
| - Inline styles (except critical CSS) | |
| - !important in CSS (unless necessary) | |
| - Console.log in production code | |
| - Hardcoded sensitive data | |
| - Broken links or placeholder text | |
| 📦 DELIVERABLES: | |
| Return ONLY valid JSON with these exact keys: | |
| - html: Complete HTML5 document | |
| - css: Complete stylesheet with comments | |
| - js: Complete JavaScript with error handling | |
| - sql: MySQL schema with sample data (if database requested) | |
| - config: PHP database config (if database requested) | |
| - readme: Detailed setup and customization guide | |
| CREATE A WEBSITE THAT WILL IMPRESS AND DELIGHT! 🚀✨""" | |
| logger.info(f"Prompt sizes - System: {len(system_prompt)}, User: {len(user_prompt)}") | |
| # Call Groq API | |
| headers = { | |
| "Authorization": f"Bearer {api_key}", | |
| "Content-Type": "application/json" | |
| } | |
| payload = { | |
| "model": model, | |
| "messages": [ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_prompt} | |
| ], | |
| "temperature": 0.7, | |
| "max_tokens": 8000, | |
| "top_p": 0.9 | |
| } | |
| logger.info("Calling Groq API...") | |
| response = requests.post( | |
| "https://api.groq.com/openai/v1/chat/completions", | |
| headers=headers, | |
| json=payload, | |
| timeout=60 | |
| ) | |
| logger.info(f"Groq Response: {response.status_code}") | |
| if response.status_code != 200: | |
| logger.error(f"Groq Error: {response.text}") | |
| raise HTTPException(status_code=response.status_code, detail=response.text) | |
| result = response.json() | |
| raw_content = result['choices'][0]['message']['content'] | |
| logger.info(f"Response length: {len(raw_content)} chars") | |
| # Try to extract JSON from markdown code blocks if present | |
| if '```json' in raw_content: | |
| import re | |
| json_match = re.search(r'```json\s*(.+?)\s*```', raw_content, re.DOTALL) | |
| if json_match: | |
| raw_content = json_match.group(1) | |
| logger.info("Extracted JSON from markdown block") | |
| elif '```' in raw_content: | |
| import re | |
| json_match = re.search(r'```\s*(.+?)\s*```', raw_content, re.DOTALL) | |
| if json_match: | |
| raw_content = json_match.group(1) | |
| logger.info("Extracted content from code block") | |
| logger.info(f"Preview: {raw_content[:200]}...") | |
| try: | |
| generated_content = json.loads(raw_content) | |
| # Validate that we have the expected structure | |
| if not isinstance(generated_content, dict): | |
| raise ValueError("Response is not a JSON object") | |
| # Check if we have the required files | |
| required_keys = ['html', 'css', 'js'] | |
| has_required = any(key in generated_content for key in required_keys) | |
| if not has_required: | |
| # Check if the response is double-encoded (JSON string containing JSON) | |
| if len(generated_content) == 1: | |
| first_key = list(generated_content.keys())[0] | |
| first_value = generated_content[first_key] | |
| if isinstance(first_value, str) and first_value.strip().startswith('{'): | |
| try: | |
| inner_json = json.loads(first_value) | |
| if isinstance(inner_json, dict) and any(k in inner_json for k in required_keys): | |
| generated_content = inner_json | |
| logger.info("Unwrapped double-encoded JSON") | |
| except: | |
| pass | |
| # Final validation | |
| if 'html' not in generated_content: | |
| logger.warning("No HTML in response, using fallback") | |
| raise ValueError("Missing HTML content") | |
| logger.info(f"Files: {list(generated_content.keys())}") | |
| except (json.JSONDecodeError, ValueError) as je: | |
| logger.error(f"JSON parse failed: {str(je)}") | |
| # Try to fix common JSON issues | |
| try: | |
| # Remove markdown code blocks if still present | |
| if '```' in raw_content: | |
| parts = raw_content.split('```') | |
| for part in parts: | |
| if '{' in part and '}' in part: | |
| raw_content = part.replace('json\n', '').replace('json', '').strip() | |
| break | |
| # Try parsing again | |
| generated_content = json.loads(raw_content) | |
| # Validate again | |
| if not isinstance(generated_content, dict) or 'html' not in generated_content: | |
| raise ValueError("Invalid structure after cleanup") | |
| logger.info("Fixed JSON after cleanup") | |
| logger.info(f"Files: {list(generated_content.keys())}") | |
| except Exception as cleanup_error: | |
| # Final fallback: create minimal structure only if we have HTML-like content | |
| logger.warning(f"Using fallback structure: {str(cleanup_error)}") | |
| if '<html' in raw_content.lower() or '<!doctype' in raw_content.lower(): | |
| # Extract HTML if present | |
| generated_content = { | |
| "html": raw_content, | |
| "css": "/* Generated by IONA AI */\nbody { font-family: Arial, sans-serif; margin: 0; padding: 20px; }", | |
| "js": "// Generated by IONA AI\nconsole.log('Website loaded');", | |
| "readme": "# Generated Website\n\nThis website was generated by IONA AI.\n\nNote: AI response had formatting issues, minimal CSS/JS provided." | |
| } | |
| else: | |
| # Cannot recover - throw error | |
| raise HTTPException( | |
| status_code=500, | |
| detail=f"AI returned invalid response format. Please try again. Error: {str(je)}" | |
| ) | |
| logger.info("=== Generation Complete ===") | |
| return { | |
| "success": True, | |
| "model_used": model, | |
| "files": generated_content, | |
| "tokens_used": result.get('usage', {}), | |
| "generation_time": result.get('created', time.time()) | |
| } | |
| except json.JSONDecodeError as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| logger.error(f"JSON Error: {str(e)}") | |
| raise HTTPException( | |
| status_code=500, | |
| detail=f"AI response parsing failed: {str(e)}. The AI may be overloaded, please try again." | |
| ) | |
| except Exception as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| logger.error(f"Error: {str(e)}") | |
| logger.exception("Traceback:") | |
| raise HTTPException(status_code=500, detail=f"AI Error: {str(e)}") | |
| def _get_color_scheme_guide(color_scheme: str) -> str: | |
| """Return detailed color guidance based on selected scheme""" | |
| schemes = { | |
| "modern": """Primary: #6366f1 (Indigo), Secondary: #8b5cf6 (Purple) | |
| Accent: #ec4899 (Pink), Background: #0f172a (Dark Blue) | |
| Text: #f1f5f9 (Light Gray), Use gradients and glassmorphism""", | |
| "dark": """Primary: #3b82f6 (Blue), Secondary: #10b981 (Green) | |
| Accent: #f59e0b (Amber), Background: #111827 (Very Dark) | |
| Text: #f9fafb (White), High contrast, neon accents""", | |
| "light": """Primary: #2563eb (Blue), Secondary: #7c3aed (Purple) | |
| Accent: #dc2626 (Red), Background: #ffffff (White) | |
| Text: #1f2937 (Dark Gray), Clean, minimal, lots of whitespace""", | |
| "vibrant": """Primary: #f43f5e (Rose), Secondary: #8b5cf6 (Purple) | |
| Accent: #f59e0b (Amber), Background: #1e293b (Dark Slate) | |
| Text: #f1f5f9 (Light), Bold colors, high energy""", | |
| "professional": """Primary: #1e40af (Navy), Secondary: #475569 (Slate) | |
| Accent: #0891b2 (Cyan), Background: #f8fafc (Off White) | |
| Text: #0f172a (Dark), Corporate, trustworthy""", | |
| "warm": """Primary: #ea580c (Orange), Secondary: #dc2626 (Red) | |
| Accent: #facc15 (Yellow), Background: #292524 (Warm Dark) | |
| Text: #fafaf9 (Warm White), Cozy, inviting""", | |
| "nature": """Primary: #16a34a (Green), Secondary: #65a30d (Lime) | |
| Accent: #0891b2 (Teal), Background: #1c1917 (Earth Dark) | |
| Text: #fafaf9 (Natural White), Organic, earthy""" | |
| } | |
| return schemes.get(color_scheme, schemes["modern"]) | |
| def create_account(req: AccountRequest): | |
| """Create MOFH hosting account""" | |
| REQUEST_COUNT["total"] += 1 | |
| REQUEST_COUNT["create"] += 1 | |
| try: | |
| # Use HTTP Basic Authentication (the correct method) | |
| auth_string = f"{MOFH_API_USERNAME}:{MOFH_API_PASSWORD}" | |
| auth_b64 = base64.b64encode(auth_string.encode('utf-8')).decode('utf-8') | |
| headers = { | |
| 'Authorization': f'Basic {auth_b64}', | |
| 'Content-Type': 'application/x-www-form-urlencoded' | |
| } | |
| # Data WITHOUT api_user/api_key (they're in the header) | |
| api_data = { | |
| 'username': req.username, | |
| 'password': req.password, | |
| 'contactemail': req.email, | |
| 'domain': req.domain, | |
| 'plan': req.plan | |
| } | |
| response = requests.post( | |
| f"{MOFH_API_URL}createacct.php", | |
| data=api_data, | |
| headers=headers, | |
| timeout=30, | |
| verify=False | |
| ) | |
| return { | |
| 'success': response.status_code == 200, | |
| 'http_code': response.status_code, | |
| 'response': response.text, | |
| 'debug': { | |
| 'api_url': f"{MOFH_API_URL}createacct.php", | |
| 'auth_method': 'Basic Auth (HTTP Header)', | |
| 'api_user_length': len(MOFH_API_USERNAME), | |
| 'api_key_length': len(MOFH_API_PASSWORD), | |
| 'sent_params': list(api_data.keys()) | |
| } | |
| } | |
| except Exception as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| class FTPDeployRequest(BaseModel): | |
| ftp_host: str | |
| ftp_user: str | |
| ftp_pass: str | |
| files: dict # {"index.html": "content", "style.css": "content", ...} | |
| def validate_host(cls, v): | |
| if not v or len(v) < 3: | |
| raise ValueError('Invalid FTP host') | |
| return v.strip() | |
| def deploy_via_ftp(req: FTPDeployRequest): | |
| """Deploy files to FTP server (HF Space has no firewall restrictions)""" | |
| REQUEST_COUNT["total"] += 1 | |
| try: | |
| import ftplib | |
| from io import BytesIO | |
| logger.info(f"[FTP-DEPLOY] Connecting to {req.ftp_host}") | |
| deployed_files = [] | |
| errors = [] | |
| # Try regular FTP first | |
| ftp = None | |
| try: | |
| ftp = ftplib.FTP(timeout=30) | |
| ftp.connect(req.ftp_host, 21) | |
| ftp.login(req.ftp_user, req.ftp_pass) | |
| logger.info("[FTP-DEPLOY] Connected via regular FTP") | |
| except Exception as e: | |
| logger.warning(f"[FTP-DEPLOY] Regular FTP failed: {e}") | |
| # Try FTP_TLS | |
| try: | |
| ftp = ftplib.FTP_TLS(timeout=30) | |
| ftp.connect(req.ftp_host, 21) | |
| ftp.login(req.ftp_user, req.ftp_pass) | |
| ftp.prot_p() # Enable encryption | |
| logger.info("[FTP-DEPLOY] Connected via FTP_TLS") | |
| except Exception as e2: | |
| logger.error(f"[FTP-DEPLOY] FTP_TLS also failed: {e2}") | |
| raise HTTPException(status_code=500, detail=f"FTP connection failed: {str(e2)}") | |
| # Set passive mode | |
| ftp.set_pasv(True) | |
| # Try to change to web directory | |
| web_dirs = ['htdocs', 'public_html', 'www', 'html'] | |
| current_dir = '/' | |
| for web_dir in web_dirs: | |
| try: | |
| ftp.cwd(web_dir) | |
| current_dir = ftp.pwd() | |
| logger.info(f"[FTP-DEPLOY] Changed to directory: {current_dir}") | |
| break | |
| except: | |
| continue | |
| logger.info(f"[FTP-DEPLOY] Working directory: {current_dir}") | |
| # Upload each file | |
| for filename, content in req.files.items(): | |
| try: | |
| logger.info(f"[FTP-DEPLOY] Uploading {filename} ({len(content)} bytes)") | |
| # Convert string to bytes | |
| file_bytes = BytesIO(content.encode('utf-8')) | |
| # Upload file | |
| ftp.storbinary(f'STOR {filename}', file_bytes) | |
| deployed_files.append(filename) | |
| logger.info(f"[FTP-DEPLOY] ✓ {filename} uploaded") | |
| except Exception as e: | |
| error_msg = f"{filename}: {str(e)}" | |
| errors.append(error_msg) | |
| logger.error(f"[FTP-DEPLOY] ✗ {error_msg}") | |
| # Close FTP connection | |
| try: | |
| ftp.quit() | |
| except: | |
| ftp.close() | |
| logger.info(f"[FTP-DEPLOY] Deployment complete. Success: {len(deployed_files)}, Errors: {len(errors)}") | |
| if not deployed_files: | |
| raise HTTPException(status_code=500, detail=f"No files deployed. Errors: {', '.join(errors)}") | |
| return { | |
| "success": True, | |
| "deployed_files": deployed_files, | |
| "errors": errors, | |
| "message": f"Successfully deployed {len(deployed_files)} file(s)" | |
| } | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| logger.error(f"[FTP-DEPLOY] Error: {str(e)}") | |
| raise HTTPException(status_code=500, detail=f"Deployment failed: {str(e)}") | |
| class GenerateAndDeployRequest(BaseModel): | |
| prompt: str | |
| business_type: Optional[str] = "general" | |
| color_scheme: Optional[str] = "modern" | |
| include_database: Optional[bool] = False | |
| features: Optional[List[str]] = [] | |
| ftp_host: str | |
| ftp_user: str | |
| ftp_pass: str | |
| def validate_prompt(cls, v): | |
| if not v or len(v.strip()) < 10: | |
| raise ValueError('Prompt must be at least 10 characters') | |
| if len(v) > 10000: | |
| raise ValueError('Prompt too long (max 10000 characters)') | |
| return v.strip() | |
| def generate_and_deploy(req: GenerateAndDeployRequest): | |
| """Generate website with AI and deploy to FTP in one call""" | |
| REQUEST_COUNT["total"] += 1 | |
| REQUEST_COUNT["generate"] += 1 | |
| logs = [] | |
| try: | |
| import ftplib | |
| from io import BytesIO | |
| logs.append({"time": time.time(), "type": "info", "message": "Starting AI generation..."}) | |
| logger.info("=== IONA AI Generation + Deployment Started ===") | |
| logger.info(f"Prompt: {req.prompt[:100]}...") | |
| logger.info(f"FTP Host: {req.ftp_host}, User: {req.ftp_user}") | |
| api_key = get_next_api_key() | |
| model = get_random_model() | |
| logs.append({"time": time.time(), "type": "info", "message": f"Using model: {model}"}) | |
| # Generate with AI - FORCE JSON MODE WITH DETAILED INSTRUCTIONS | |
| system_prompt = """You are a web development code generator. | |
| Generate a complete, functional website with HTML, CSS, and JavaScript. | |
| Return a JSON object with these keys: | |
| - html: A complete HTML5 document with proper structure | |
| - css: Complete CSS stylesheet with modern styling | |
| - js: JavaScript code for interactivity | |
| - readme: Brief setup instructions | |
| The website must be: | |
| - Fully functional and complete | |
| - Mobile responsive | |
| - Modern and professional | |
| - Include all requested features | |
| IMPORTANT CODE FORMATTING RULES: | |
| - Use proper indentation (2 spaces per level) | |
| - Add newlines between sections | |
| - Format code readably with line breaks | |
| - Do NOT put everything on one line | |
| - Use \n for newlines in JSON strings | |
| - Properly indent nested elements | |
| IMPORTANT: Do NOT return null values. Generate actual code for each field.""" | |
| user_prompt = f"""Generate a {req.business_type} website with this description: | |
| {req.prompt} | |
| Use {req.color_scheme} color scheme and include {', '.join(req.features) if req.features else 'standard'} features. | |
| Return as JSON with html, css, js, and readme keys.""" | |
| headers = { | |
| "Authorization": f"Bearer {api_key}", | |
| "Content-Type": "application/json" | |
| } | |
| payload = { | |
| "model": model, | |
| "messages": [ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_prompt} | |
| ], | |
| "temperature": 0.7, # Balanced for creativity and consistency | |
| "max_tokens": 32000, # Maximum tokens for complete generation | |
| "response_format": {"type": "json_object"} # Force JSON mode | |
| } | |
| logs.append({"time": time.time(), "type": "info", "message": "Calling Groq AI..."}) | |
| response = requests.post( | |
| "https://api.groq.com/openai/v1/chat/completions", | |
| headers=headers, | |
| json=payload, | |
| timeout=180 # 3 minutes for complex generation | |
| ) | |
| if response.status_code != 200: | |
| logs.append({"time": time.time(), "type": "error", "message": f"Groq API error: HTTP {response.status_code}"}) | |
| raise HTTPException(status_code=response.status_code, detail=response.text) | |
| result = response.json() | |
| raw_content = result['choices'][0]['message']['content'] | |
| logger.info(f"[GENERATE-DEPLOY] Raw response length: {len(raw_content)}") | |
| logger.info(f"[GENERATE-DEPLOY] Raw response preview: {raw_content[:200]}") | |
| logs.append({"time": time.time(), "type": "success", "message": "AI generation complete"}) | |
| logs.append({"time": time.time(), "type": "info", "message": "Parsing AI response..."}) | |
| # Parse JSON with better error handling | |
| generated_content = None | |
| # Try extracting from markdown code blocks | |
| if '```json' in raw_content: | |
| import re | |
| json_match = re.search(r'```json\s*(.+?)\s*```', raw_content, re.DOTALL) | |
| if json_match: | |
| raw_content = json_match.group(1) | |
| logger.info("[GENERATE-DEPLOY] Extracted from ```json block") | |
| elif '```' in raw_content: | |
| import re | |
| json_match = re.search(r'```\s*(.+?)\s*```', raw_content, re.DOTALL) | |
| if json_match: | |
| raw_content = json_match.group(1) | |
| logger.info("[GENERATE-DEPLOY] Extracted from ``` block") | |
| # Try parsing JSON | |
| try: | |
| generated_content = json.loads(raw_content) | |
| logger.info(f"[GENERATE-DEPLOY] Parsed JSON successfully, keys: {list(generated_content.keys())}") | |
| except json.JSONDecodeError as e: | |
| logger.error(f"[GENERATE-DEPLOY] JSON parse failed: {e}") | |
| logger.error(f"[GENERATE-DEPLOY] Content: {raw_content[:500]}") | |
| # Try to find JSON object in the response | |
| import re | |
| json_pattern = r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}' | |
| matches = re.findall(json_pattern, raw_content, re.DOTALL) | |
| for match in matches: | |
| try: | |
| generated_content = json.loads(match) | |
| if 'html' in generated_content: | |
| logger.info("[GENERATE-DEPLOY] Found valid JSON in response") | |
| break | |
| except: | |
| continue | |
| if not generated_content: | |
| logs.append({"time": time.time(), "type": "error", "message": "AI returned invalid JSON format"}) | |
| raise ValueError(f"AI response is not valid JSON. Error: {str(e)}. Response preview: {raw_content[:200]}") | |
| if 'html' not in generated_content: | |
| logs.append({"time": time.time(), "type": "error", "message": "No HTML in AI response"}) | |
| raise ValueError("No HTML in AI response") | |
| # Check if HTML is null or empty | |
| html_content = generated_content.get('html', '') | |
| if not html_content or html_content == 'null' or len(html_content.strip()) < 100: | |
| logs.append({"time": time.time(), "type": "error", "message": f"AI returned invalid HTML (length: {len(html_content)})"}) | |
| logs.append({"time": time.time(), "type": "error", "message": f"Model used: {model}"}) | |
| logs.append({"time": time.time(), "type": "error", "message": f"Response preview: {str(generated_content)[:200]}"}) | |
| raise ValueError(f"AI returned null or empty HTML content. Model: {model}. Please try again.") | |
| logs.append({"time": time.time(), "type": "success", "message": "AI response parsed successfully"}) | |
| logs.append({"time": time.time(), "type": "info", "message": f"Generated {len(generated_content.get('html', ''))} chars of HTML"}) | |
| logs.append({"time": time.time(), "type": "info", "message": "FTP blocked - returning files to PHP for deployment"}) | |
| # HF Space cannot connect to FTP (port 21 blocked) | |
| # Return generated files to PHP for deployment | |
| logger.info("[GENERATE-DEPLOY] FTP blocked by HF network - returning files") | |
| return { | |
| "success": True, | |
| "model_used": model, | |
| "deployed_files": [], | |
| "files": { | |
| 'html': generated_content.get('html', ''), | |
| 'css': generated_content.get('css', ''), | |
| 'js': generated_content.get('js', ''), | |
| 'php': generated_content.get('php', ''), | |
| 'sql': generated_content.get('sql', ''), | |
| 'config': generated_content.get('config', ''), | |
| 'readme': generated_content.get('readme', '') | |
| }, | |
| "ftp_blocked": True, | |
| "logs": logs, | |
| "message": "Website generated successfully. FTP blocked - files returned for PHP deployment." | |
| } | |
| except HTTPException: | |
| raise | |
| except json.JSONDecodeError as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| logger.error(f"[GENERATE-DEPLOY] JSON Error: {str(e)}") | |
| logs.append({"time": time.time(), "type": "error", "message": f"JSON parsing failed: {str(e)}"}) | |
| raise HTTPException(status_code=500, detail=f"AI returned invalid JSON format. Please try again. Error: {str(e)}") | |
| except Exception as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| logger.error(f"[GENERATE-DEPLOY] Error: {str(e)}") | |
| logs.append({"time": time.time(), "type": "error", "message": str(e)}) | |
| raise HTTPException(status_code=500, detail=f"Generation/Deployment failed: {str(e)}") | |
| # ============================================================================ | |
| # GITHUB DEPLOYMENT ENDPOINTS | |
| # ============================================================================ | |
| # Import GitHub deployment module | |
| github_deploy = None | |
| try: | |
| from github_deploy import GitHubDeployment | |
| # Initialize GitHub Deployment (will be configured via environment variables) | |
| GITHUB_APP_ID = os.getenv('GITHUB_APP_ID', '') | |
| GITHUB_CLIENT_ID = os.getenv('GITHUB_CLIENT_ID', '') | |
| GITHUB_CLIENT_SECRET = os.getenv('GITHUB_CLIENT_SECRET', '') | |
| GITHUB_PRIVATE_KEY = os.getenv('GITHUB_PRIVATE_KEY', '').replace('\\n', '\n') | |
| if GITHUB_APP_ID and GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET and GITHUB_PRIVATE_KEY: | |
| try: | |
| github_deploy = GitHubDeployment( | |
| GITHUB_APP_ID, | |
| GITHUB_CLIENT_ID, | |
| GITHUB_CLIENT_SECRET, | |
| GITHUB_PRIVATE_KEY | |
| ) | |
| logger.info("✅ GitHub Deployment initialized successfully") | |
| except Exception as e: | |
| logger.error(f"❌ GitHub Deployment initialization failed: {e}") | |
| else: | |
| logger.info("ℹ️ GitHub Deployment not configured (environment variables not set)") | |
| except ImportError as e: | |
| logger.error(f"❌ Failed to import github_deploy module: {e}") | |
| logger.error("❌ Make sure github_deploy.py is in the same directory as app.py") | |
| except Exception as e: | |
| logger.error(f"❌ Error loading GitHub deployment: {e}") | |
| class GitHubOAuthRequest(BaseModel): | |
| code: str | |
| class GitHubInstallationTokenRequest(BaseModel): | |
| installation_id: str | |
| class GitHubDeploymentStatusRequest(BaseModel): | |
| owner: str | |
| repo: str | |
| run_id: int | |
| installation_id: str | |
| class GitHubRepoRequest(BaseModel): | |
| access_token: str | |
| class GitHubSetupDeploymentRequest(BaseModel): | |
| owner: str | |
| repo: str | |
| access_token: str | |
| ftp_server: str | |
| ftp_username: str | |
| ftp_password: str | |
| branch: Optional[str] = 'main' | |
| class GitHubWorkflowRunsRequest(BaseModel): | |
| owner: str | |
| repo: str | |
| access_token: str | |
| limit: Optional[int] = 10 | |
| class GitHubWorkflowLogsRequest(BaseModel): | |
| owner: str | |
| repo: str | |
| run_id: int | |
| access_token: str | |
| class GitHubTriggerWorkflowRequest(BaseModel): | |
| owner: str | |
| repo: str | |
| access_token: str | |
| workflow_file: Optional[str] = 'celestine_deploy.yml' | |
| branch: Optional[str] = 'main' | |
| def github_status(): | |
| """Check if GitHub integration is configured""" | |
| return { | |
| "configured": github_deploy is not None, | |
| "app_id": GITHUB_APP_ID if GITHUB_APP_ID else None, | |
| "client_id": GITHUB_CLIENT_ID if GITHUB_CLIENT_ID else None, | |
| "message": "GitHub integration is ready" if github_deploy else "GitHub integration not configured" | |
| } | |
| def github_oauth_exchange(req: GitHubOAuthRequest): | |
| """Exchange OAuth code for access token AND get installation ID""" | |
| REQUEST_COUNT["total"] += 1 | |
| if not github_deploy: | |
| raise HTTPException(status_code=503, detail="GitHub integration not configured") | |
| try: | |
| # Get OAuth token | |
| result = github_deploy.exchange_code_for_token(req.code) | |
| access_token = result.get('access_token') | |
| # Get user's GitHub App installations | |
| headers = { | |
| 'Authorization': f'token {access_token}', | |
| 'Accept': 'application/vnd.github.v3+json' | |
| } | |
| logger.info("📦 Fetching user's GitHub App installations...") | |
| response = requests.get( | |
| 'https://api.github.com/user/installations', | |
| headers=headers, | |
| timeout=30 | |
| ) | |
| installation_id = None | |
| if response.status_code == 200: | |
| installations = response.json().get('installations', []) | |
| if installations: | |
| installation_id = str(installations[0]['id']) | |
| logger.info(f"✓ Found installation ID: {installation_id}") | |
| else: | |
| logger.warning("⚠ No installations found for user") | |
| else: | |
| logger.warning(f"⚠ Failed to get installations: {response.status_code}") | |
| return { | |
| "success": True, | |
| "access_token": access_token, # Only for initial repo listing | |
| "installation_id": installation_id, # Store this! Never expires! | |
| "token_type": result.get('token_type'), | |
| "scope": result.get('scope') | |
| } | |
| except Exception as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| logger.error(f"OAuth exchange failed: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| def get_installation_token(req: GitHubInstallationTokenRequest): | |
| """ | |
| Get fresh installation token (valid for 1 hour) | |
| This is the KEY to solving token expiration issues! | |
| """ | |
| REQUEST_COUNT["total"] += 1 | |
| if not github_deploy: | |
| raise HTTPException(status_code=503, detail="GitHub integration not configured") | |
| try: | |
| logger.info(f"🔐 Generating installation token for installation_id: {req.installation_id}") | |
| token = github_deploy.get_installation_access_token(req.installation_id) | |
| logger.info(f"✓ Installation token generated (length: {len(token)})") | |
| return { | |
| "success": True, | |
| "token": token, | |
| "expires_in": 3600, # 1 hour | |
| "message": "Fresh installation token generated" | |
| } | |
| except Exception as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| logger.error(f"Failed to get installation token: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| def get_deployment_status(req: GitHubDeploymentStatusRequest): | |
| """ | |
| Get deployment status using installation token (never expires!) | |
| This replaces the need for stored OAuth tokens | |
| """ | |
| REQUEST_COUNT["total"] += 1 | |
| if not github_deploy: | |
| raise HTTPException(status_code=503, detail="GitHub integration not configured") | |
| try: | |
| # Generate fresh installation token | |
| installation_token = github_deploy.get_installation_access_token(req.installation_id) | |
| # Get workflow run status | |
| headers = { | |
| 'Authorization': f'token {installation_token}', | |
| 'Accept': 'application/vnd.github.v3+json' | |
| } | |
| response = requests.get( | |
| f'https://api.github.com/repos/{req.owner}/{req.repo}/actions/runs/{req.run_id}', | |
| headers=headers, | |
| timeout=30 | |
| ) | |
| if response.status_code != 200: | |
| raise HTTPException(status_code=response.status_code, detail=response.text) | |
| data = response.json() | |
| return { | |
| "success": True, | |
| "status": data.get('status'), | |
| "conclusion": data.get('conclusion'), | |
| "created_at": data.get('created_at'), | |
| "updated_at": data.get('updated_at'), | |
| "html_url": data.get('html_url') | |
| } | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| logger.error(f"Failed to get deployment status: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| def github_get_repos(req: GitHubRepoRequest): | |
| """Get user's GitHub repositories""" | |
| REQUEST_COUNT["total"] += 1 | |
| if not github_deploy: | |
| raise HTTPException(status_code=503, detail="GitHub integration not configured") | |
| try: | |
| repos = github_deploy.get_user_repos(req.access_token) | |
| return { | |
| "success": True, | |
| "repositories": repos, | |
| "count": len(repos) | |
| } | |
| except Exception as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| logger.error(f"Failed to get repos: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| def github_detect_project(req: GitHubSetupDeploymentRequest): | |
| """Detect project type and build configuration""" | |
| REQUEST_COUNT["total"] += 1 | |
| if not github_deploy: | |
| raise HTTPException(status_code=503, detail="GitHub integration not configured") | |
| try: | |
| project_config = github_deploy.detect_project_type( | |
| req.owner, | |
| req.repo, | |
| req.access_token | |
| ) | |
| return { | |
| "success": True, | |
| "project_config": project_config | |
| } | |
| except Exception as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| logger.error(f"Project detection failed: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| def github_setup_deployment(req: GitHubSetupDeploymentRequest): | |
| """ | |
| Complete deployment setup: | |
| 1. Detect project type | |
| 2. Create FTP secrets | |
| 3. Inject workflow file | |
| """ | |
| REQUEST_COUNT["total"] += 1 | |
| if not github_deploy: | |
| raise HTTPException(status_code=503, detail="GitHub integration not configured") | |
| try: | |
| logger.info("=" * 80) | |
| logger.info("🚀 GITHUB DEPLOYMENT SETUP STARTED") | |
| logger.info("=" * 80) | |
| logger.info(f"📦 Repository: {req.owner}/{req.repo}") | |
| logger.info(f"🌿 Branch: {req.branch}") | |
| logger.info(f"🔐 Access Token: {'✓ Present' if req.access_token else '✗ Missing'} (length: {len(req.access_token) if req.access_token else 0})") | |
| logger.info(f"🌐 FTP Server: {req.ftp_server}") | |
| logger.info(f"👤 FTP Username: {req.ftp_username if req.ftp_username else '✗ EMPTY'}") | |
| logger.info(f"🔑 FTP Password: {'✓ Present' if req.ftp_password else '✗ EMPTY'} (length: {len(req.ftp_password) if req.ftp_password else 0})") | |
| logger.info("-" * 80) | |
| if not req.ftp_username: | |
| logger.error("❌ FTP Username is empty!") | |
| raise HTTPException(status_code=400, detail="FTP Username is required") | |
| if not req.ftp_password: | |
| logger.error("❌ FTP Password is empty!") | |
| raise HTTPException(status_code=400, detail="FTP Password is required") | |
| result = github_deploy.setup_deployment( | |
| req.owner, | |
| req.repo, | |
| req.access_token, | |
| req.ftp_server, | |
| req.ftp_username, | |
| req.ftp_password, | |
| req.branch | |
| ) | |
| if not result['success']: | |
| logger.error(f"❌ Deployment setup failed: {result.get('error', 'Unknown error')}") | |
| raise HTTPException(status_code=500, detail=result.get('error', 'Setup failed')) | |
| logger.info("=" * 80) | |
| logger.info("✅ GITHUB DEPLOYMENT SETUP COMPLETED SUCCESSFULLY") | |
| logger.info(f"📦 Project Type: {result.get('project_type')}") | |
| logger.info(f"🔨 Build Command: {result.get('build_command')}") | |
| logger.info(f"📂 Output Directory: {result.get('output_dir')}") | |
| logger.info(f"🔗 Workflow URL: {result.get('workflow_url')}") | |
| logger.info(f"🎬 Actions URL: {result.get('actions_url')}") | |
| logger.info("=" * 80) | |
| return result | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| logger.error("=" * 80) | |
| logger.error(f"❌ DEPLOYMENT SETUP EXCEPTION: {str(e)}") | |
| logger.error("=" * 80) | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| def github_workflow_runs(req: GitHubWorkflowRunsRequest): | |
| """Get workflow run history""" | |
| REQUEST_COUNT["total"] += 1 | |
| if not github_deploy: | |
| raise HTTPException(status_code=503, detail="GitHub integration not configured") | |
| try: | |
| runs = github_deploy.get_workflow_runs( | |
| req.owner, | |
| req.repo, | |
| req.access_token, | |
| req.limit | |
| ) | |
| return { | |
| "success": True, | |
| "runs": runs, | |
| "count": len(runs) | |
| } | |
| except Exception as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| logger.error(f"Failed to get workflow runs: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| def github_workflow_logs(req: GitHubWorkflowLogsRequest): | |
| """Get workflow run logs""" | |
| REQUEST_COUNT["total"] += 1 | |
| if not github_deploy: | |
| raise HTTPException(status_code=503, detail="GitHub integration not configured") | |
| try: | |
| logs = github_deploy.get_workflow_logs( | |
| req.owner, | |
| req.repo, | |
| req.run_id, | |
| req.access_token | |
| ) | |
| return { | |
| "success": True, | |
| "logs": logs | |
| } | |
| except Exception as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| logger.error(f"Failed to get logs: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| def github_trigger_workflow(req: GitHubTriggerWorkflowRequest): | |
| """Manually trigger a workflow""" | |
| REQUEST_COUNT["total"] += 1 | |
| if not github_deploy: | |
| raise HTTPException(status_code=503, detail="GitHub integration not configured") | |
| try: | |
| success = github_deploy.trigger_workflow( | |
| req.owner, | |
| req.repo, | |
| req.workflow_file, | |
| req.access_token, | |
| req.branch | |
| ) | |
| if not success: | |
| raise HTTPException(status_code=500, detail="Failed to trigger workflow") | |
| return { | |
| "success": True, | |
| "message": "Workflow triggered successfully" | |
| } | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| logger.error(f"Failed to trigger workflow: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| # ============================================================================ | |
| # GITHUB DEPLOYMENT STATUS TRACKING (WITH CACHING) | |
| # ============================================================================ | |
| # In-memory cache for deployment status (reduces GitHub API calls) | |
| deployment_status_cache = {} | |
| CACHE_TTL = 30 # seconds | |
| class GitHubDeploymentStatusRequest(BaseModel): | |
| access_token: str | |
| owner: str | |
| repo: str | |
| branch: str = "main" | |
| class GitHubLatestCommitRequest(BaseModel): | |
| access_token: str | |
| owner: str | |
| repo: str | |
| branch: str = "main" | |
| class GitHubTriggerDeployRequest(BaseModel): | |
| access_token: str | |
| owner: str | |
| repo: str | |
| branch: str = "main" | |
| class GitHubCreateDeploymentRequest(BaseModel): | |
| access_token: str | |
| owner: str | |
| repo: str | |
| ref: str = "main" | |
| environment: str = "production" | |
| description: str = "Deploying via CELESTINE" | |
| class GitHubUpdateDeploymentStatusRequest(BaseModel): | |
| access_token: str | |
| owner: str | |
| repo: str | |
| deployment_id: int | |
| state: str # pending, success, failure, error | |
| environment_url: Optional[str] = "" | |
| description: Optional[str] = "" | |
| def get_deployment_status(req: GitHubDeploymentStatusRequest): | |
| """ | |
| Get real-time deployment status from GitHub Actions | |
| Returns: status, conclusion, commit info, workflow URL | |
| Caches results for 30 seconds to reduce API calls | |
| """ | |
| REQUEST_COUNT["total"] += 1 | |
| try: | |
| cache_key = f"{req.owner}/{req.repo}/{req.branch}" | |
| now = time.time() | |
| # Check cache first | |
| if cache_key in deployment_status_cache: | |
| cached_data, cached_time = deployment_status_cache[cache_key] | |
| if now - cached_time < CACHE_TTL: | |
| logger.info(f"✓ Cache hit for {cache_key} (age: {int(now - cached_time)}s)") | |
| cached_data['cached'] = True | |
| cached_data['cache_age'] = int(now - cached_time) | |
| return cached_data | |
| logger.info(f"Fetching deployment status for {req.owner}/{req.repo} (branch: {req.branch})") | |
| headers = { | |
| 'Authorization': f'token {req.access_token}', | |
| 'Accept': 'application/vnd.github.v3+json', | |
| 'User-Agent': 'CELESTINE-Hosting' | |
| } | |
| # Get latest workflow runs for the branch | |
| response = requests.get( | |
| f'https://api.github.com/repos/{req.owner}/{req.repo}/actions/runs', | |
| headers=headers, | |
| params={ | |
| 'branch': req.branch, | |
| 'per_page': 5, | |
| 'status': 'completed,in_progress,queued' | |
| }, | |
| timeout=15 | |
| ) | |
| if response.status_code != 200: | |
| logger.error(f"GitHub API error: {response.status_code} - {response.text}") | |
| raise HTTPException(status_code=response.status_code, detail=f"GitHub API error: {response.text}") | |
| data = response.json() | |
| workflow_runs = data.get('workflow_runs', []) | |
| if not workflow_runs: | |
| result = { | |
| 'success': True, | |
| 'status': 'unknown', | |
| 'conclusion': None, | |
| 'commit_sha': '', | |
| 'commit_msg': '', | |
| 'workflow_url': '', | |
| 'run_id': None, | |
| 'cached': False, | |
| 'message': 'No workflow runs found' | |
| } | |
| deployment_status_cache[cache_key] = (result, now) | |
| return result | |
| # Get the most recent run | |
| latest_run = workflow_runs[0] | |
| # Map GitHub status to our status | |
| gh_status = latest_run.get('status', 'unknown') | |
| gh_conclusion = latest_run.get('conclusion') | |
| # Determine final status | |
| if gh_status == 'completed': | |
| if gh_conclusion == 'success': | |
| status = 'success' | |
| elif gh_conclusion in ['failure', 'cancelled', 'timed_out']: | |
| status = 'failure' | |
| else: | |
| status = 'unknown' | |
| elif gh_status == 'in_progress': | |
| status = 'in_progress' | |
| elif gh_status == 'queued': | |
| status = 'queued' | |
| else: | |
| status = 'unknown' | |
| result = { | |
| 'success': True, | |
| 'status': status, | |
| 'conclusion': gh_conclusion, | |
| 'commit_sha': latest_run.get('head_sha', ''), | |
| 'commit_msg': latest_run.get('head_commit', {}).get('message', ''), | |
| 'workflow_url': latest_run.get('html_url', ''), | |
| 'run_id': latest_run.get('id'), | |
| 'created_at': latest_run.get('created_at'), | |
| 'updated_at': latest_run.get('updated_at'), | |
| 'cached': False, | |
| 'cache_age': 0 | |
| } | |
| # Cache the result | |
| deployment_status_cache[cache_key] = (result, now) | |
| logger.info(f"✓ Cached deployment status for {cache_key}: {status}") | |
| return result | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| logger.error(f"Failed to get deployment status: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| def get_latest_commit(req: GitHubLatestCommitRequest): | |
| """ | |
| Get latest commit on a branch | |
| Used for auto-deploy detection | |
| Caches results for 60 seconds | |
| """ | |
| REQUEST_COUNT["total"] += 1 | |
| try: | |
| cache_key = f"commit_{req.owner}/{req.repo}/{req.branch}" | |
| now = time.time() | |
| # Check cache (longer TTL for commits) | |
| if cache_key in deployment_status_cache: | |
| cached_data, cached_time = deployment_status_cache[cache_key] | |
| if now - cached_time < 60: # 60 second cache for commits | |
| logger.info(f"✓ Cache hit for commit {cache_key}") | |
| cached_data['cached'] = True | |
| return cached_data | |
| logger.info(f"Fetching latest commit for {req.owner}/{req.repo} (branch: {req.branch})") | |
| headers = { | |
| 'Authorization': f'token {req.access_token}', | |
| 'Accept': 'application/vnd.github.v3+json', | |
| 'User-Agent': 'CELESTINE-Hosting' | |
| } | |
| # Get latest commit | |
| response = requests.get( | |
| f'https://api.github.com/repos/{req.owner}/{req.repo}/commits/{req.branch}', | |
| headers=headers, | |
| timeout=15 | |
| ) | |
| if response.status_code != 200: | |
| logger.error(f"GitHub API error: {response.status_code}") | |
| raise HTTPException(status_code=response.status_code, detail="Failed to fetch commit") | |
| commit_data = response.json() | |
| result = { | |
| 'success': True, | |
| 'sha': commit_data.get('sha', ''), | |
| 'message': commit_data.get('commit', {}).get('message', ''), | |
| 'author': commit_data.get('commit', {}).get('author', {}).get('name', ''), | |
| 'date': commit_data.get('commit', {}).get('author', {}).get('date', ''), | |
| 'cached': False | |
| } | |
| # Cache the result | |
| deployment_status_cache[cache_key] = (result, now) | |
| logger.info(f"✓ Cached commit info for {cache_key}: {result['sha'][:7]}") | |
| return result | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| logger.error(f"Failed to get latest commit: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| def trigger_deploy(req: GitHubTriggerDeployRequest): | |
| """ | |
| Trigger a deployment by dispatching the workflow | |
| Clears cache to force fresh status check | |
| """ | |
| REQUEST_COUNT["total"] += 1 | |
| try: | |
| logger.info(f"Triggering deployment for {req.owner}/{req.repo} (branch: {req.branch})") | |
| headers = { | |
| 'Authorization': f'token {req.access_token}', | |
| 'Accept': 'application/vnd.github.v3+json', | |
| 'User-Agent': 'CELESTINE-Hosting', | |
| 'Content-Type': 'application/json' | |
| } | |
| # Trigger workflow dispatch | |
| response = requests.post( | |
| f'https://api.github.com/repos/{req.owner}/{req.repo}/actions/workflows/deploy.yml/dispatches', | |
| headers=headers, | |
| json={'ref': req.branch}, | |
| timeout=15 | |
| ) | |
| # 204 = success (no content) | |
| if response.status_code == 204: | |
| # Clear cache to force fresh status check | |
| cache_key = f"{req.owner}/{req.repo}/{req.branch}" | |
| if cache_key in deployment_status_cache: | |
| del deployment_status_cache[cache_key] | |
| logger.info(f"✓ Cleared cache for {cache_key}") | |
| logger.info(f"✓ Deployment triggered successfully for {req.owner}/{req.repo}") | |
| return { | |
| 'success': True, | |
| 'message': 'Deployment triggered successfully' | |
| } | |
| else: | |
| logger.error(f"Failed to trigger deployment: {response.status_code} - {response.text}") | |
| return { | |
| 'success': False, | |
| 'error': f"GitHub API returned {response.status_code}", | |
| 'details': response.text | |
| } | |
| except Exception as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| logger.error(f"Failed to trigger deployment: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| def create_github_deployment(req: GitHubCreateDeploymentRequest): | |
| """ | |
| Create a GitHub deployment (for status tracking on repo page) | |
| This fixes the yellow dots issue | |
| """ | |
| REQUEST_COUNT["total"] += 1 | |
| try: | |
| logger.info(f"Creating GitHub deployment for {req.owner}/{req.repo}") | |
| headers = { | |
| 'Authorization': f'token {req.access_token}', | |
| 'Accept': 'application/vnd.github.v3+json', | |
| 'User-Agent': 'CELESTINE-Hosting', | |
| 'Content-Type': 'application/json' | |
| } | |
| # Create deployment | |
| response = requests.post( | |
| f'https://api.github.com/repos/{req.owner}/{req.repo}/deployments', | |
| headers=headers, | |
| json={ | |
| 'ref': req.ref, | |
| 'environment': req.environment, | |
| 'description': req.description, | |
| 'auto_merge': False, | |
| 'required_contexts': [], | |
| 'production_environment': True | |
| }, | |
| timeout=15 | |
| ) | |
| if response.status_code in [200, 201]: | |
| deployment_data = response.json() | |
| logger.info(f"✓ GitHub deployment created: {deployment_data.get('id')}") | |
| return { | |
| 'success': True, | |
| 'deployment_id': deployment_data.get('id'), | |
| 'sha': deployment_data.get('sha') | |
| } | |
| else: | |
| logger.error(f"Failed to create deployment: {response.status_code} - {response.text}") | |
| return { | |
| 'success': False, | |
| 'error': f"GitHub API returned {response.status_code}", | |
| 'details': response.text | |
| } | |
| except Exception as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| logger.error(f"Failed to create deployment: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| def update_github_deployment_status(req: GitHubUpdateDeploymentStatusRequest): | |
| """ | |
| Update GitHub deployment status (success/failure) | |
| This updates the dots on the GitHub repo page | |
| """ | |
| REQUEST_COUNT["total"] += 1 | |
| try: | |
| logger.info(f"Updating deployment status for {req.owner}/{req.repo} (deployment: {req.deployment_id}, state: {req.state})") | |
| headers = { | |
| 'Authorization': f'token {req.access_token}', | |
| 'Accept': 'application/vnd.github.v3+json', | |
| 'User-Agent': 'CELESTINE-Hosting', | |
| 'Content-Type': 'application/json' | |
| } | |
| # Update deployment status | |
| response = requests.post( | |
| f'https://api.github.com/repos/{req.owner}/{req.repo}/deployments/{req.deployment_id}/statuses', | |
| headers=headers, | |
| json={ | |
| 'state': req.state, | |
| 'environment_url': req.environment_url, | |
| 'description': req.description, | |
| 'auto_inactive': False | |
| }, | |
| timeout=15 | |
| ) | |
| if response.status_code in [200, 201]: | |
| logger.info(f"✓ Deployment status updated to {req.state}") | |
| return { | |
| 'success': True, | |
| 'message': f'Deployment status updated to {req.state}' | |
| } | |
| else: | |
| logger.error(f"Failed to update deployment status: {response.status_code} - {response.text}") | |
| return { | |
| 'success': False, | |
| 'error': f"GitHub API returned {response.status_code}", | |
| 'details': response.text | |
| } | |
| except Exception as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| logger.error(f"Failed to update deployment status: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| # Cache cleanup task (runs every 5 minutes) | |
| async def startup_cache_cleanup(): | |
| """Clean up old cache entries periodically""" | |
| import asyncio | |
| async def cleanup_task(): | |
| while True: | |
| await asyncio.sleep(300) # 5 minutes | |
| now = time.time() | |
| expired_keys = [] | |
| for key, (data, cached_time) in deployment_status_cache.items(): | |
| if now - cached_time > 300: # 5 minutes | |
| expired_keys.append(key) | |
| for key in expired_keys: | |
| del deployment_status_cache[key] | |
| if expired_keys: | |
| logger.info(f"🧹 Cleaned up {len(expired_keys)} expired cache entries") | |
| asyncio.create_task(cleanup_task()) | |
| # ============================================================================ | |
| # GITHUB WEBHOOK ENDPOINT (Fixes 404 errors) | |
| # ============================================================================ | |
| import hmac | |
| import hashlib | |
| def verify_github_webhook_signature(payload_body: bytes, secret_token: str, signature_header: str) -> bool: | |
| """Verify that the webhook payload was sent from GitHub.""" | |
| if not signature_header: | |
| return False | |
| hash_object = hmac.new(secret_token.encode('utf-8'), msg=payload_body, digestmod=hashlib.sha256) | |
| expected_signature = "sha256=" + hash_object.hexdigest() | |
| return hmac.compare_digest(expected_signature, signature_header) | |
| async def github_webhook(request: Request): | |
| """ | |
| Handle GitHub webhook events | |
| Used for real-time deployment status updates | |
| """ | |
| REQUEST_COUNT["total"] += 1 | |
| try: | |
| # 1. Verify Webhook Signature | |
| github_secret = os.getenv('WEBHOOK_SECRET', 'savage') | |
| signature_header = request.headers.get('X-Hub-Signature-256', '') | |
| # Read raw body for signature verification | |
| payload_body = await request.body() | |
| if not verify_github_webhook_signature(payload_body, github_secret, signature_header): | |
| logger.warning("Invalid GitHub webhook signature") | |
| return {"success": False, "error": "Invalid signature"} | |
| event_type = request.headers.get('X-GitHub-Event', 'unknown') | |
| # Handle ping and installation events immediately (required for GitHub Apps) | |
| if event_type == 'ping': | |
| logger.info("Received ping event from GitHub App") | |
| return {"success": True, "message": "pong"} | |
| if event_type == 'installation' or event_type == 'installation_repositories': | |
| logger.info(f"Received {event_type} event from GitHub App") | |
| return {"success": True, "message": f"{event_type} event received"} | |
| payload = await request.json() | |
| logger.info("=" * 80) | |
| logger.info("📨 GitHub Webhook Received") | |
| logger.info("=" * 80) | |
| logger.info(f"Event Type: {event_type}") | |
| repo_full_name = "unknown" | |
| if 'repository' in payload: | |
| repo_full_name = payload['repository']['full_name'] | |
| logger.info(f"Repository: {repo_full_name}") | |
| # Clear cache for this repo to force fresh status check | |
| cache_keys_to_clear = [k for k in deployment_status_cache.keys() if repo_full_name in k] | |
| for key in cache_keys_to_clear: | |
| del deployment_status_cache[key] | |
| logger.info(f"✓ Cleared cache for {key}") | |
| # Handle workflow_run events | |
| if event_type == 'workflow_run': | |
| workflow_run = payload.get('workflow_run', {}) | |
| action = payload.get('action', '') | |
| repo_owner = payload['repository']['owner']['login'] | |
| repo_name = payload['repository']['name'] | |
| run_id = workflow_run.get('id') | |
| status = workflow_run.get('status') | |
| conclusion = workflow_run.get('conclusion') | |
| html_url = workflow_run.get('html_url') | |
| logger.info(f"Workflow Run: {action}") | |
| logger.info(f"Run ID: {run_id}") | |
| logger.info(f"Status: {status}") | |
| logger.info(f"Conclusion: {conclusion}") | |
| # Update database | |
| if run_id: | |
| update_deployment_status( | |
| repo_owner=repo_owner, | |
| repo_name=repo_name, | |
| workflow_run_id=run_id, | |
| status=status, | |
| conclusion=conclusion, | |
| workflow_url=html_url | |
| ) | |
| # Handle deployment_status events | |
| elif event_type == 'deployment_status': | |
| deployment = payload.get('deployment', {}) | |
| deployment_status = payload.get('deployment_status', {}) | |
| repo_owner = payload['repository']['owner']['login'] | |
| repo_name = payload['repository']['name'] | |
| state = deployment_status.get('state') | |
| logger.info(f"Deployment Status: {state}") | |
| # Map to our status | |
| status_map = { | |
| 'pending': 'pending', | |
| 'queued': 'queued', | |
| 'in_progress': 'in_progress', | |
| 'success': 'completed', | |
| 'failure': 'failed', | |
| 'error': 'failed' | |
| } | |
| mapped_status = status_map.get(state, 'pending') | |
| # This could fall back to other updates if needed | |
| return { | |
| "success": True, | |
| "message": "Webhook processed", | |
| "event": event_type, | |
| "repo": repo_full_name | |
| } | |
| except Exception as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| logger.error(f"Webhook processing error: {e}") | |
| return { | |
| "success": False, | |
| "error": str(e) | |
| } | |
| # ============================================================================ | |
| # GITHUB ACTIONS LOGS ENDPOINT (Real-time workflow logs) | |
| # ============================================================================ | |
| class GitHubWorkflowLogsRequest(BaseModel): | |
| access_token: str | |
| owner: str | |
| repo: str | |
| run_id: Optional[int] = None | |
| branch: str = "main" | |
| def get_workflow_logs(req: GitHubWorkflowLogsRequest): | |
| """ | |
| Get real-time GitHub Actions workflow logs | |
| Returns actual logs from the running/completed workflow | |
| """ | |
| REQUEST_COUNT["total"] += 1 | |
| try: | |
| logger.info(f"Fetching workflow logs for {req.owner}/{req.repo}") | |
| headers = { | |
| 'Authorization': f'token {req.access_token}', | |
| 'Accept': 'application/vnd.github.v3+json', | |
| 'User-Agent': 'CELESTINE-Hosting' | |
| } | |
| # If run_id not provided, get the latest run | |
| if not req.run_id: | |
| response = requests.get( | |
| f'https://api.github.com/repos/{req.owner}/{req.repo}/actions/runs', | |
| headers=headers, | |
| params={'branch': req.branch, 'per_page': 1}, | |
| timeout=15 | |
| ) | |
| if response.status_code != 200: | |
| raise HTTPException(status_code=response.status_code, detail="Failed to fetch runs") | |
| runs = response.json().get('workflow_runs', []) | |
| if not runs: | |
| return { | |
| 'success': True, | |
| 'logs': [], | |
| 'jobs': [], | |
| 'message': 'No workflow runs found' | |
| } | |
| req.run_id = runs[0]['id'] | |
| # Get jobs for this run | |
| jobs_response = requests.get( | |
| f'https://api.github.com/repos/{req.owner}/{req.repo}/actions/runs/{req.run_id}/jobs', | |
| headers=headers, | |
| timeout=15 | |
| ) | |
| if jobs_response.status_code != 200: | |
| raise HTTPException(status_code=jobs_response.status_code, detail="Failed to fetch jobs") | |
| jobs_data = jobs_response.json() | |
| jobs = jobs_data.get('jobs', []) | |
| # Extract steps from all jobs | |
| all_steps = [] | |
| for job in jobs: | |
| job_name = job.get('name', 'Unknown Job') | |
| steps = job.get('steps', []) | |
| for step in steps: | |
| step_name = step.get('name', 'Unknown Step') | |
| status = step.get('status', 'pending') | |
| conclusion = step.get('conclusion') | |
| started_at = step.get('started_at', '') | |
| completed_at = step.get('completed_at', '') | |
| # Map status to our format | |
| if status == 'completed': | |
| if conclusion == 'success': | |
| step_status = 'completed' | |
| else: | |
| step_status = 'failed' | |
| elif status == 'in_progress': | |
| step_status = 'in-progress' | |
| else: | |
| step_status = 'pending' | |
| all_steps.append({ | |
| 'job': job_name, | |
| 'name': step_name, | |
| 'status': step_status, | |
| 'conclusion': conclusion, | |
| 'started_at': started_at, | |
| 'completed_at': completed_at, | |
| 'number': step.get('number', 0) | |
| }) | |
| # Try to get logs (may not be available for in-progress runs) | |
| logs_text = [] | |
| try: | |
| logs_response = requests.get( | |
| f'https://api.github.com/repos/{req.owner}/{req.repo}/actions/runs/{req.run_id}/logs', | |
| headers=headers, | |
| timeout=15, | |
| allow_redirects=True | |
| ) | |
| if logs_response.status_code == 200: | |
| # Parse logs (they come as a zip file, but we'll get the text) | |
| logs_text = logs_response.text.split('\n')[:100] # First 100 lines | |
| except: | |
| pass | |
| return { | |
| 'success': True, | |
| 'run_id': req.run_id, | |
| 'jobs': jobs, | |
| 'steps': all_steps, | |
| 'logs': logs_text, | |
| 'total_steps': len(all_steps) | |
| } | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| logger.error(f"Failed to get workflow logs: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| def get_workflow_steps(req: GitHubWorkflowLogsRequest): | |
| """ | |
| Get workflow steps with real-time status | |
| Optimized for deployment viewer UI | |
| """ | |
| REQUEST_COUNT["total"] += 1 | |
| try: | |
| logger.info(f"Fetching workflow steps for {req.owner}/{req.repo}") | |
| headers = { | |
| 'Authorization': f'token {req.access_token}', | |
| 'Accept': 'application/vnd.github.v3+json', | |
| 'User-Agent': 'CELESTINE-Hosting' | |
| } | |
| # Get latest run if run_id not provided | |
| if not req.run_id: | |
| response = requests.get( | |
| f'https://api.github.com/repos/{req.owner}/{req.repo}/actions/runs', | |
| headers=headers, | |
| params={'branch': req.branch, 'per_page': 1}, | |
| timeout=15 | |
| ) | |
| if response.status_code != 200: | |
| return {'success': False, 'error': 'Failed to fetch runs'} | |
| runs = response.json().get('workflow_runs', []) | |
| if not runs: | |
| return {'success': True, 'steps': [], 'logs': []} | |
| req.run_id = runs[0]['id'] | |
| # Get jobs | |
| jobs_response = requests.get( | |
| f'https://api.github.com/repos/{req.owner}/{req.repo}/actions/runs/{req.run_id}/jobs', | |
| headers=headers, | |
| timeout=15 | |
| ) | |
| if jobs_response.status_code != 200: | |
| return {'success': False, 'error': 'Failed to fetch jobs'} | |
| jobs = jobs_response.json().get('jobs', []) | |
| # Build simplified steps list for UI | |
| steps = [] | |
| logs = [] | |
| for job in jobs: | |
| for step in job.get('steps', []): | |
| step_name = step.get('name', '') | |
| status = step.get('status', 'pending') | |
| conclusion = step.get('conclusion') | |
| # Map to UI status | |
| if status == 'completed': | |
| ui_status = 'completed' if conclusion == 'success' else 'failed' | |
| elif status == 'in_progress': | |
| ui_status = 'in-progress' | |
| else: | |
| ui_status = 'pending' | |
| steps.append({ | |
| 'id': step.get('number', len(steps) + 1), | |
| 'title': step_name, | |
| 'status': ui_status | |
| }) | |
| # Add log entry | |
| if status != 'pending': | |
| timestamp = step.get('started_at', '') | |
| if timestamp: | |
| time_str = timestamp.split('T')[1][:8] if 'T' in timestamp else '' | |
| if ui_status == 'completed': | |
| logs.append({ | |
| 'time': time_str, | |
| 'text': f"✓ {step_name}", | |
| 'color': 'text-emerald-400' | |
| }) | |
| elif ui_status == 'failed': | |
| logs.append({ | |
| 'time': time_str, | |
| 'text': f"✗ {step_name} failed", | |
| 'color': 'text-red-400' | |
| }) | |
| elif ui_status == 'in-progress': | |
| logs.append({ | |
| 'time': time_str, | |
| 'text': f"⟳ {step_name}...", | |
| 'color': 'text-indigo-400' | |
| }) | |
| return { | |
| 'success': True, | |
| 'run_id': req.run_id, | |
| 'steps': steps, | |
| 'logs': logs | |
| } | |
| except Exception as e: | |
| REQUEST_COUNT["errors"] += 1 | |
| logger.error(f"Failed to get workflow steps: {e}") | |
| return { | |
| 'success': False, | |
| 'error': str(e), | |
| 'steps': [], | |
| 'logs': [] | |
| } | |