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]] = []
@validator('prompt')
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"
@validator('username')
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()
@validator('email')
def validate_email(cls, v):
if '@' not in v or '.' not in v:
raise ValueError('Invalid email format')
return v.strip()
@app.get("/", response_class=HTMLResponse)
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"""
IONA AI - System Status
โฑ๏ธ Uptime
Running Since
{datetime.fromtimestamp(START_TIME).strftime('%Y-%m-%d %H:%M:%S')}
Uptime
{uptime_hours}h {uptime_minutes}m {uptime_secs}s
Total Requests
{REQUEST_COUNT['total']}
Errors
{REQUEST_COUNT['errors']}
๐ Network Info
Hostname
{hostname}
Local IP
{local_ip}
External IP Address
{external_ip}
โ ๏ธ Add this IP to MOFH API whitelist
๐ป System Resources
CPU Usage
{cpu_percent:.1f}%
Memory Usage
{memory_percent:.1f}%
Memory
{memory_used:.2f} GB / {memory_total:.2f} GB
๐ค AI Configuration
Active Models
{len(AI_MODELS['groq'])}
API Keys
{len(GROQ_API_KEYS)}
Generations
{REQUEST_COUNT['generate']}
{''.join([f'
{usage}
' for usage in api_key_usage])}
๐จโ๐ป Developer Info
Developer
Pratyush Srivastava
Version
2026.1.0
"""
return html_content
@app.get("/health")
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
}
@app.get("/test")
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"
}
@app.post("/generate-website")
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:
```
\n\n\n \n Page\n\n\n \n\n
```
Example PHP formatting:
```
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 ' 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"])
@app.post("/create")
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", ...}
@validator('ftp_host')
def validate_host(cls, v):
if not v or len(v) < 3:
raise ValueError('Invalid FTP host')
return v.strip()
@app.post("/deploy-ftp")
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
@validator('prompt')
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()
@app.post("/generate-and-deploy")
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'
@app.get("/github/status")
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"
}
@app.post("/github/oauth/exchange")
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))
@app.post("/github/installation-token")
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))
@app.post("/github/deployment-status")
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))
@app.post("/github/repos")
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))
@app.post("/github/detect-project")
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))
@app.post("/github/setup-deployment")
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))
@app.post("/github/workflow-runs")
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))
@app.post("/github/workflow-logs")
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))
@app.post("/github/trigger-workflow")
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] = ""
@app.post("/github/deployment-status")
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))
@app.post("/github/latest-commit")
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))
@app.post("/github/trigger-deploy")
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))
@app.post("/github/create-deployment")
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))
@app.post("/github/update-deployment-status")
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)
@app.on_event("startup")
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)
@app.post("/github/webhook")
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"
@app.post("/github/workflow-logs")
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))
@app.post("/github/workflow-steps")
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': []
}