feat: add marketplace metrics, privacy features, and service registry endpoints
- Add Prometheus metrics for marketplace API throughput and error rates with new dashboard panels - Implement confidential transaction models with encryption support and access control - Add key management system with registration, rotation, and audit logging - Create services and registry routers for service discovery and management - Integrate ZK proof generation for privacy-preserving receipts - Add metrics instru
This commit is contained in:
@@ -7,7 +7,7 @@ from fastapi import FastAPI
|
||||
from ..database import close_engine, create_engine
|
||||
from ..redis_cache import close_redis, create_redis
|
||||
from ..settings import settings
|
||||
from .routers import health_router, match_router, metrics_router
|
||||
from .routers import health_router, match_router, metrics_router, services, ui, validation
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -25,6 +25,9 @@ app = FastAPI(**settings.asgi_kwargs(), lifespan=lifespan)
|
||||
app.include_router(match_router, prefix="/v1")
|
||||
app.include_router(health_router)
|
||||
app.include_router(metrics_router)
|
||||
app.include_router(services, prefix="/v1")
|
||||
app.include_router(ui)
|
||||
app.include_router(validation, prefix="/v1")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
|
||||
302
apps/pool-hub/src/poolhub/app/routers/services.py
Normal file
302
apps/pool-hub/src/poolhub/app/routers/services.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
Service configuration router for pool hub
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..deps import get_db, get_miner_id
|
||||
from ..models import Miner, ServiceConfig, ServiceType
|
||||
from ..schemas import ServiceConfigCreate, ServiceConfigUpdate, ServiceConfigResponse
|
||||
|
||||
router = APIRouter(prefix="/services", tags=["services"])
|
||||
|
||||
|
||||
@router.get("/", response_model=List[ServiceConfigResponse])
|
||||
async def list_service_configs(
|
||||
db: Session = Depends(get_db),
|
||||
miner_id: str = Depends(get_miner_id)
|
||||
) -> List[ServiceConfigResponse]:
|
||||
"""List all service configurations for the miner"""
|
||||
stmt = select(ServiceConfig).where(ServiceConfig.miner_id == miner_id)
|
||||
configs = db.execute(stmt).scalars().all()
|
||||
|
||||
return [ServiceConfigResponse.from_orm(config) for config in configs]
|
||||
|
||||
|
||||
@router.get("/{service_type}", response_model=ServiceConfigResponse)
|
||||
async def get_service_config(
|
||||
service_type: str,
|
||||
db: Session = Depends(get_db),
|
||||
miner_id: str = Depends(get_miner_id)
|
||||
) -> ServiceConfigResponse:
|
||||
"""Get configuration for a specific service"""
|
||||
stmt = select(ServiceConfig).where(
|
||||
ServiceConfig.miner_id == miner_id,
|
||||
ServiceConfig.service_type == service_type
|
||||
)
|
||||
config = db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
# Return default config
|
||||
return ServiceConfigResponse(
|
||||
service_type=service_type,
|
||||
enabled=False,
|
||||
config={},
|
||||
pricing={},
|
||||
capabilities=[],
|
||||
max_concurrent=1
|
||||
)
|
||||
|
||||
return ServiceConfigResponse.from_orm(config)
|
||||
|
||||
|
||||
@router.post("/{service_type}", response_model=ServiceConfigResponse)
|
||||
async def create_or_update_service_config(
|
||||
service_type: str,
|
||||
config_data: ServiceConfigCreate,
|
||||
db: Session = Depends(get_db),
|
||||
miner_id: str = Depends(get_miner_id)
|
||||
) -> ServiceConfigResponse:
|
||||
"""Create or update service configuration"""
|
||||
# Validate service type
|
||||
if service_type not in [s.value for s in ServiceType]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid service type: {service_type}"
|
||||
)
|
||||
|
||||
# Check if config exists
|
||||
stmt = select(ServiceConfig).where(
|
||||
ServiceConfig.miner_id == miner_id,
|
||||
ServiceConfig.service_type == service_type
|
||||
)
|
||||
existing = db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
# Update existing
|
||||
existing.enabled = config_data.enabled
|
||||
existing.config = config_data.config
|
||||
existing.pricing = config_data.pricing
|
||||
existing.capabilities = config_data.capabilities
|
||||
existing.max_concurrent = config_data.max_concurrent
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
config = existing
|
||||
else:
|
||||
# Create new
|
||||
config = ServiceConfig(
|
||||
miner_id=miner_id,
|
||||
service_type=service_type,
|
||||
enabled=config_data.enabled,
|
||||
config=config_data.config,
|
||||
pricing=config_data.pricing,
|
||||
capabilities=config_data.capabilities,
|
||||
max_concurrent=config_data.max_concurrent
|
||||
)
|
||||
db.add(config)
|
||||
db.commit()
|
||||
db.refresh(config)
|
||||
|
||||
return ServiceConfigResponse.from_orm(config)
|
||||
|
||||
|
||||
@router.patch("/{service_type}", response_model=ServiceConfigResponse)
|
||||
async def patch_service_config(
|
||||
service_type: str,
|
||||
config_data: ServiceConfigUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
miner_id: str = Depends(get_miner_id)
|
||||
) -> ServiceConfigResponse:
|
||||
"""Partially update service configuration"""
|
||||
stmt = select(ServiceConfig).where(
|
||||
ServiceConfig.miner_id == miner_id,
|
||||
ServiceConfig.service_type == service_type
|
||||
)
|
||||
config = db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Service configuration not found"
|
||||
)
|
||||
|
||||
# Update only provided fields
|
||||
if config_data.enabled is not None:
|
||||
config.enabled = config_data.enabled
|
||||
if config_data.config is not None:
|
||||
config.config = config_data.config
|
||||
if config_data.pricing is not None:
|
||||
config.pricing = config_data.pricing
|
||||
if config_data.capabilities is not None:
|
||||
config.capabilities = config_data.capabilities
|
||||
if config_data.max_concurrent is not None:
|
||||
config.max_concurrent = config_data.max_concurrent
|
||||
|
||||
db.commit()
|
||||
db.refresh(config)
|
||||
|
||||
return ServiceConfigResponse.from_orm(config)
|
||||
|
||||
|
||||
@router.delete("/{service_type}")
|
||||
async def delete_service_config(
|
||||
service_type: str,
|
||||
db: Session = Depends(get_db),
|
||||
miner_id: str = Depends(get_miner_id)
|
||||
) -> Dict[str, Any]:
|
||||
"""Delete service configuration"""
|
||||
stmt = select(ServiceConfig).where(
|
||||
ServiceConfig.miner_id == miner_id,
|
||||
ServiceConfig.service_type == service_type
|
||||
)
|
||||
config = db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Service configuration not found"
|
||||
)
|
||||
|
||||
db.delete(config)
|
||||
db.commit()
|
||||
|
||||
return {"message": f"Service configuration for {service_type} deleted"}
|
||||
|
||||
|
||||
@router.get("/templates/{service_type}")
|
||||
async def get_service_template(service_type: str) -> Dict[str, Any]:
|
||||
"""Get default configuration template for a service"""
|
||||
templates = {
|
||||
"whisper": {
|
||||
"config": {
|
||||
"models": ["tiny", "base", "small", "medium", "large"],
|
||||
"default_model": "base",
|
||||
"max_file_size_mb": 500,
|
||||
"supported_formats": ["mp3", "wav", "m4a", "flac"]
|
||||
},
|
||||
"pricing": {
|
||||
"per_minute": 0.001,
|
||||
"min_charge": 0.01
|
||||
},
|
||||
"capabilities": ["transcribe", "translate"],
|
||||
"max_concurrent": 2
|
||||
},
|
||||
"stable_diffusion": {
|
||||
"config": {
|
||||
"models": ["stable-diffusion-1.5", "stable-diffusion-2.1", "sdxl"],
|
||||
"default_model": "stable-diffusion-1.5",
|
||||
"max_resolution": "1024x1024",
|
||||
"max_images_per_request": 4
|
||||
},
|
||||
"pricing": {
|
||||
"per_image": 0.01,
|
||||
"per_step": 0.001
|
||||
},
|
||||
"capabilities": ["txt2img", "img2img"],
|
||||
"max_concurrent": 1
|
||||
},
|
||||
"llm_inference": {
|
||||
"config": {
|
||||
"models": ["llama-7b", "llama-13b", "mistral-7b", "mixtral-8x7b"],
|
||||
"default_model": "llama-7b",
|
||||
"max_tokens": 4096,
|
||||
"context_length": 4096
|
||||
},
|
||||
"pricing": {
|
||||
"per_1k_tokens": 0.001,
|
||||
"min_charge": 0.01
|
||||
},
|
||||
"capabilities": ["generate", "stream"],
|
||||
"max_concurrent": 2
|
||||
},
|
||||
"ffmpeg": {
|
||||
"config": {
|
||||
"supported_codecs": ["h264", "h265", "vp9"],
|
||||
"max_resolution": "4K",
|
||||
"max_file_size_gb": 10,
|
||||
"gpu_acceleration": True
|
||||
},
|
||||
"pricing": {
|
||||
"per_minute": 0.005,
|
||||
"per_gb": 0.01
|
||||
},
|
||||
"capabilities": ["transcode", "resize", "compress"],
|
||||
"max_concurrent": 1
|
||||
},
|
||||
"blender": {
|
||||
"config": {
|
||||
"engines": ["cycles", "eevee"],
|
||||
"default_engine": "cycles",
|
||||
"max_samples": 4096,
|
||||
"max_resolution": "4K"
|
||||
},
|
||||
"pricing": {
|
||||
"per_frame": 0.01,
|
||||
"per_hour": 0.5
|
||||
},
|
||||
"capabilities": ["render", "animation"],
|
||||
"max_concurrent": 1
|
||||
}
|
||||
}
|
||||
|
||||
if service_type not in templates:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unknown service type: {service_type}"
|
||||
)
|
||||
|
||||
return templates[service_type]
|
||||
|
||||
|
||||
@router.post("/validate/{service_type}")
|
||||
async def validate_service_config(
|
||||
service_type: str,
|
||||
config_data: Dict[str, Any],
|
||||
db: Session = Depends(get_db),
|
||||
miner_id: str = Depends(get_miner_id)
|
||||
) -> Dict[str, Any]:
|
||||
"""Validate service configuration against miner capabilities"""
|
||||
# Get miner info
|
||||
stmt = select(Miner).where(Miner.miner_id == miner_id)
|
||||
miner = db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
if not miner:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Miner not found"
|
||||
)
|
||||
|
||||
# Validate based on service type
|
||||
validation_result = {
|
||||
"valid": True,
|
||||
"warnings": [],
|
||||
"errors": []
|
||||
}
|
||||
|
||||
if service_type == "stable_diffusion":
|
||||
# Check VRAM requirements
|
||||
max_resolution = config_data.get("config", {}).get("max_resolution", "1024x1024")
|
||||
if "4K" in max_resolution and miner.gpu_vram_gb < 16:
|
||||
validation_result["warnings"].append("4K resolution requires at least 16GB VRAM")
|
||||
|
||||
if miner.gpu_vram_gb < 8:
|
||||
validation_result["errors"].append("Stable Diffusion requires at least 8GB VRAM")
|
||||
validation_result["valid"] = False
|
||||
|
||||
elif service_type == "llm_inference":
|
||||
# Check model size vs VRAM
|
||||
models = config_data.get("config", {}).get("models", [])
|
||||
for model in models:
|
||||
if "70b" in model.lower() and miner.gpu_vram_gb < 64:
|
||||
validation_result["warnings"].append(f"{model} requires 64GB VRAM")
|
||||
|
||||
elif service_type == "blender":
|
||||
# Check if GPU is supported
|
||||
engine = config_data.get("config", {}).get("default_engine", "cycles")
|
||||
if engine == "cycles" and "nvidia" not in miner.tags.get("gpu", "").lower():
|
||||
validation_result["warnings"].append("Cycles engine works best with NVIDIA GPUs")
|
||||
|
||||
return validation_result
|
||||
20
apps/pool-hub/src/poolhub/app/routers/ui.py
Normal file
20
apps/pool-hub/src/poolhub/app/routers/ui.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
UI router for serving static HTML pages
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
import os
|
||||
|
||||
router = APIRouter(tags=["ui"])
|
||||
|
||||
# Get templates directory
|
||||
templates_dir = os.path.join(os.path.dirname(__file__), "..", "templates")
|
||||
templates = Jinja2Templates(directory=templates_dir)
|
||||
|
||||
|
||||
@router.get("/services", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def services_ui(request: Request):
|
||||
"""Serve the service configuration UI"""
|
||||
return templates.TemplateResponse("services.html", {"request": request})
|
||||
181
apps/pool-hub/src/poolhub/app/routers/validation.py
Normal file
181
apps/pool-hub/src/poolhub/app/routers/validation.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Validation router for service configuration validation
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..deps import get_miner_from_token
|
||||
from ..models import Miner
|
||||
from ..services.validation import HardwareValidator, ValidationResult
|
||||
|
||||
router = APIRouter(tags=["validation"])
|
||||
validator = HardwareValidator()
|
||||
|
||||
|
||||
@router.post("/validation/service/{service_id}")
|
||||
async def validate_service(
|
||||
service_id: str,
|
||||
config: Dict[str, Any],
|
||||
miner: Miner = Depends(get_miner_from_token)
|
||||
) -> Dict[str, Any]:
|
||||
"""Validate if miner can run a specific service with given configuration"""
|
||||
|
||||
result = await validator.validate_service_for_miner(miner, service_id, config)
|
||||
|
||||
return {
|
||||
"valid": result.valid,
|
||||
"errors": result.errors,
|
||||
"warnings": result.warnings,
|
||||
"score": result.score,
|
||||
"missing_requirements": result.missing_requirements,
|
||||
"performance_impact": result.performance_impact
|
||||
}
|
||||
|
||||
|
||||
@router.get("/validation/compatible-services")
|
||||
async def get_compatible_services(
|
||||
miner: Miner = Depends(get_miner_from_token)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get list of services compatible with miner hardware, sorted by compatibility score"""
|
||||
|
||||
compatible = await validator.get_compatible_services(miner)
|
||||
|
||||
return [
|
||||
{
|
||||
"service_id": service_id,
|
||||
"compatibility_score": score,
|
||||
"grade": _get_grade_from_score(score)
|
||||
}
|
||||
for service_id, score in compatible
|
||||
]
|
||||
|
||||
|
||||
@router.post("/validation/batch")
|
||||
async def validate_multiple_services(
|
||||
validations: List[Dict[str, Any]],
|
||||
miner: Miner = Depends(get_miner_from_token)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Validate multiple service configurations in batch"""
|
||||
|
||||
results = []
|
||||
|
||||
for validation in validations:
|
||||
service_id = validation.get("service_id")
|
||||
config = validation.get("config", {})
|
||||
|
||||
if not service_id:
|
||||
results.append({
|
||||
"service_id": service_id,
|
||||
"valid": False,
|
||||
"errors": ["Missing service_id"]
|
||||
})
|
||||
continue
|
||||
|
||||
result = await validator.validate_service_for_miner(miner, service_id, config)
|
||||
|
||||
results.append({
|
||||
"service_id": service_id,
|
||||
"valid": result.valid,
|
||||
"errors": result.errors,
|
||||
"warnings": result.warnings,
|
||||
"score": result.score,
|
||||
"performance_impact": result.performance_impact
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/validation/hardware-profile")
|
||||
async def get_hardware_profile(
|
||||
miner: Miner = Depends(get_miner_from_token)
|
||||
) -> Dict[str, Any]:
|
||||
"""Get miner's hardware profile with capabilities assessment"""
|
||||
|
||||
# Get compatible services to assess capabilities
|
||||
compatible = await validator.get_compatible_services(miner)
|
||||
|
||||
# Analyze hardware capabilities
|
||||
profile = {
|
||||
"miner_id": miner.id,
|
||||
"hardware": {
|
||||
"gpu": {
|
||||
"name": miner.gpu_name,
|
||||
"vram_gb": miner.gpu_vram_gb,
|
||||
"available": miner.gpu_name is not None
|
||||
},
|
||||
"cpu": {
|
||||
"cores": miner.cpu_cores
|
||||
},
|
||||
"ram": {
|
||||
"gb": miner.ram_gb
|
||||
},
|
||||
"capabilities": miner.capabilities,
|
||||
"tags": miner.tags
|
||||
},
|
||||
"assessment": {
|
||||
"total_services": len(compatible),
|
||||
"highly_compatible": len([s for s in compatible if s[1] >= 80]),
|
||||
"moderately_compatible": len([s for s in compatible if 50 <= s[1] < 80]),
|
||||
"barely_compatible": len([s for s in compatible if s[1] < 50]),
|
||||
"best_categories": _get_best_categories(compatible)
|
||||
},
|
||||
"recommendations": _generate_recommendations(miner, compatible)
|
||||
}
|
||||
|
||||
return profile
|
||||
|
||||
|
||||
def _get_grade_from_score(score: int) -> str:
|
||||
"""Convert compatibility score to letter grade"""
|
||||
if score >= 90:
|
||||
return "A+"
|
||||
elif score >= 80:
|
||||
return "A"
|
||||
elif score >= 70:
|
||||
return "B"
|
||||
elif score >= 60:
|
||||
return "C"
|
||||
elif score >= 50:
|
||||
return "D"
|
||||
else:
|
||||
return "F"
|
||||
|
||||
|
||||
def _get_best_categories(compatible: List[tuple]) -> List[str]:
|
||||
"""Get the categories with highest compatibility"""
|
||||
# This would need category info from registry
|
||||
# For now, return placeholder
|
||||
return ["AI/ML", "Media Processing"]
|
||||
|
||||
|
||||
def _generate_recommendations(miner: Miner, compatible: List[tuple]) -> List[str]:
|
||||
"""Generate hardware upgrade recommendations"""
|
||||
recommendations = []
|
||||
|
||||
# Check VRAM
|
||||
if miner.gpu_vram_gb < 8:
|
||||
recommendations.append("Upgrade GPU to at least 8GB VRAM for better AI/ML performance")
|
||||
elif miner.gpu_vram_gb < 16:
|
||||
recommendations.append("Consider upgrading to 16GB+ VRAM for optimal performance")
|
||||
|
||||
# Check CPU
|
||||
if miner.cpu_cores < 8:
|
||||
recommendations.append("More CPU cores would improve parallel processing")
|
||||
|
||||
# Check RAM
|
||||
if miner.ram_gb < 16:
|
||||
recommendations.append("Upgrade to 16GB+ RAM for better multitasking")
|
||||
|
||||
# Check capabilities
|
||||
if "cuda" not in [c.lower() for c in miner.capabilities]:
|
||||
recommendations.append("CUDA support would enable more GPU services")
|
||||
|
||||
# Based on compatible services
|
||||
if len(compatible) < 10:
|
||||
recommendations.append("Hardware upgrade recommended to access more services")
|
||||
elif len(compatible) > 20:
|
||||
recommendations.append("Your hardware is well-suited for a wide range of services")
|
||||
|
||||
return recommendations
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -10,6 +11,7 @@ class MatchRequestPayload(BaseModel):
|
||||
requirements: Dict[str, Any] = Field(default_factory=dict)
|
||||
hints: Dict[str, Any] = Field(default_factory=dict)
|
||||
top_k: int = Field(default=1, ge=1, le=50)
|
||||
redis_error: Optional[str] = None
|
||||
|
||||
|
||||
class MatchCandidate(BaseModel):
|
||||
@@ -38,3 +40,37 @@ class HealthResponse(BaseModel):
|
||||
|
||||
class MetricsResponse(BaseModel):
|
||||
detail: str = "Prometheus metrics output"
|
||||
|
||||
|
||||
# Service Configuration Schemas
|
||||
class ServiceConfigBase(BaseModel):
|
||||
"""Base service configuration"""
|
||||
enabled: bool = Field(False, description="Whether service is enabled")
|
||||
config: Dict[str, Any] = Field(default_factory=dict, description="Service-specific configuration")
|
||||
pricing: Dict[str, Any] = Field(default_factory=dict, description="Pricing configuration")
|
||||
capabilities: List[str] = Field(default_factory=list, description="Service capabilities")
|
||||
max_concurrent: int = Field(1, ge=1, le=10, description="Maximum concurrent jobs")
|
||||
|
||||
|
||||
class ServiceConfigCreate(ServiceConfigBase):
|
||||
"""Service configuration creation request"""
|
||||
pass
|
||||
|
||||
|
||||
class ServiceConfigUpdate(BaseModel):
|
||||
"""Service configuration update request"""
|
||||
enabled: Optional[bool] = Field(None, description="Whether service is enabled")
|
||||
config: Optional[Dict[str, Any]] = Field(None, description="Service-specific configuration")
|
||||
pricing: Optional[Dict[str, Any]] = Field(None, description="Pricing configuration")
|
||||
capabilities: Optional[List[str]] = Field(None, description="Service capabilities")
|
||||
max_concurrent: Optional[int] = Field(None, ge=1, le=10, description="Maximum concurrent jobs")
|
||||
|
||||
|
||||
class ServiceConfigResponse(ServiceConfigBase):
|
||||
"""Service configuration response"""
|
||||
service_type: str = Field(..., description="Service type")
|
||||
created_at: datetime = Field(..., description="Creation time")
|
||||
updated_at: datetime = Field(..., description="Last update time")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
990
apps/pool-hub/src/poolhub/app/templates/services.html
Normal file
990
apps/pool-hub/src/poolhub/app/templates/services.html
Normal file
@@ -0,0 +1,990 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Service Configuration - AITBC Pool Hub</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f5f7fa;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
header {
|
||||
background: white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
#categoryFilter {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#categoryFilter:focus {
|
||||
outline: none;
|
||||
border-color: #4caf50;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #2c3e50;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 16px;
|
||||
background: #e8f5e9;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #4caf50;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.services-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.service-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.service-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.service-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.service-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #f0f2f5;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: .4s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .toggle-slider {
|
||||
background-color: #4caf50;
|
||||
}
|
||||
|
||||
input:checked + .toggle-slider:before {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.service-description {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="number"]:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: #4caf50;
|
||||
}
|
||||
|
||||
.price-input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.price-input-group input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.price-unit {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.capabilities-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.capability-tag {
|
||||
padding: 4px 12px;
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f0f2f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #e0e2e5;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.notification {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
padding: 16px 24px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
display: none;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.notification.success {
|
||||
border-left: 4px solid #4caf50;
|
||||
}
|
||||
|
||||
.notification.error {
|
||||
border-left: 4px solid #f44336;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #4caf50;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-content">
|
||||
<h1>Service Configuration</h1>
|
||||
<div class="header-controls">
|
||||
<select id="categoryFilter" onchange="filterByCategory()">
|
||||
<option value="">All Categories</option>
|
||||
<option value="ai_ml">AI/ML</option>
|
||||
<option value="media_processing">Media Processing</option>
|
||||
<option value="scientific_computing">Scientific Computing</option>
|
||||
<option value="data_analytics">Data Analytics</option>
|
||||
<option value="gaming_entertainment">Gaming & Entertainment</option>
|
||||
<option value="development_tools">Development Tools</option>
|
||||
</select>
|
||||
<div class="status-indicator">
|
||||
<div class="status-dot"></div>
|
||||
<span>Connected</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<div class="loading" id="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading service configurations...</p>
|
||||
</div>
|
||||
|
||||
<div class="services-grid" id="servicesGrid">
|
||||
<!-- Service cards will be dynamically inserted here -->
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="notification" id="notification"></div>
|
||||
|
||||
<script>
|
||||
const API_BASE = '/v1';
|
||||
let SERVICES = [];
|
||||
let serviceConfigs = {};
|
||||
|
||||
// Initialize the app
|
||||
async function init() {
|
||||
showLoading(true);
|
||||
try {
|
||||
await loadServicesFromRegistry();
|
||||
await loadServiceConfigs();
|
||||
renderServices();
|
||||
} catch (error) {
|
||||
showNotification('Failed to load configurations', 'error');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Load services from registry
|
||||
async function loadServicesFromRegistry() {
|
||||
const response = await fetch(`${API_BASE}/registry/services`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch service registry');
|
||||
}
|
||||
|
||||
const registry = await response.json();
|
||||
SERVICES = registry.map(service => ({
|
||||
type: service.id,
|
||||
name: service.name,
|
||||
description: service.description,
|
||||
icon: service.icon || '⚙️',
|
||||
category: service.category,
|
||||
defaultConfig: extractDefaultConfig(service),
|
||||
defaultPricing: extractDefaultPricing(service),
|
||||
capabilities: service.capabilities || []
|
||||
}));
|
||||
}
|
||||
|
||||
// Extract default configuration from service definition
|
||||
function extractDefaultConfig(service) {
|
||||
const config = {};
|
||||
service.input_parameters.forEach(param => {
|
||||
if (param.default !== undefined) {
|
||||
config[param.name] = param.default;
|
||||
} else if (param.type === 'array' && param.options) {
|
||||
config[param.name] = param.options;
|
||||
}
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
// Extract default pricing from service definition
|
||||
function extractDefaultPricing(service) {
|
||||
if (!service.pricing || service.pricing.length === 0) {
|
||||
return { per_unit: 0.01, min_charge: 0.01 };
|
||||
}
|
||||
|
||||
const pricing = {};
|
||||
service.pricing.forEach(tier => {
|
||||
pricing[tier.name] = tier.unit_price;
|
||||
if (tier.min_charge) {
|
||||
pricing.min_charge = tier.min_charge;
|
||||
}
|
||||
});
|
||||
|
||||
return pricing;
|
||||
}
|
||||
|
||||
// Load existing service configurations
|
||||
async function loadServiceConfigs() {
|
||||
const response = await fetch(`${API_BASE}/services/`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch service configs');
|
||||
}
|
||||
|
||||
const configs = await response.json();
|
||||
configs.forEach(config => {
|
||||
serviceConfigs[config.service_type] = config;
|
||||
});
|
||||
}
|
||||
|
||||
// Render service cards
|
||||
function renderServices() {
|
||||
const grid = document.getElementById('servicesGrid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
const categoryFilter = document.getElementById('categoryFilter').value;
|
||||
const filteredServices = categoryFilter
|
||||
? SERVICES.filter(s => s.category === categoryFilter)
|
||||
: SERVICES;
|
||||
|
||||
filteredServices.forEach(service => {
|
||||
const config = serviceConfigs[service.type] || {
|
||||
service_type: service.type,
|
||||
enabled: false,
|
||||
config: service.defaultConfig,
|
||||
pricing: service.defaultPricing,
|
||||
capabilities: service.capabilities,
|
||||
max_concurrent: 1
|
||||
};
|
||||
|
||||
const card = createServiceCard(service, config);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
// Filter services by category
|
||||
function filterByCategory() {
|
||||
renderServices();
|
||||
}
|
||||
|
||||
// Create a service card element
|
||||
function createServiceCard(service, config) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'service-card';
|
||||
card.setAttribute('data-service', service.type);
|
||||
card.innerHTML = `
|
||||
<div class="service-header">
|
||||
<div class="service-icon">${service.icon}</div>
|
||||
<h3 class="service-title">${service.name}</h3>
|
||||
<div class="compatibility-score" id="score-${service.type}" style="display: none;">
|
||||
Score: <span>0</span>/100
|
||||
</div>
|
||||
</div>
|
||||
<p class="service-description">${service.description}</p>
|
||||
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" ${config.enabled ? 'checked' : ''}
|
||||
onchange="toggleService('${service.type}', this.checked)">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
|
||||
<div class="config-section" id="config-${service.type}"
|
||||
style="display: ${config.enabled ? 'block' : 'none'}">
|
||||
<div class="section-title">
|
||||
⚙️ Configuration
|
||||
</div>
|
||||
|
||||
${renderConfigFields(service.type, config.config)}
|
||||
|
||||
<div class="section-title" style="margin-top: 20px;">
|
||||
💰 Pricing
|
||||
</div>
|
||||
|
||||
${renderPricingFields(service.type, config.pricing)}
|
||||
|
||||
<div class="section-title" style="margin-top: 20px;">
|
||||
⚡ Capacity
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Max Concurrent Jobs</label>
|
||||
<input type="number" min="1" max="10" value="${config.max_concurrent}"
|
||||
onchange="updateConfig('${service.type}', 'max_concurrent', parseInt(this.value)); validateOnChange('${service.type}')">
|
||||
</div>
|
||||
|
||||
<div class="section-title" style="margin-top: 20px;">
|
||||
🎯 Capabilities
|
||||
</div>
|
||||
|
||||
<div class="capabilities-list">
|
||||
${config.capabilities.map(cap =>
|
||||
`<span class="capability-tag">${cap}</span>`
|
||||
).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-secondary" onclick="resetConfig('${service.type}')">
|
||||
Reset to Default
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="saveConfig('${service.type}')">
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Validate on load if enabled
|
||||
if (config.enabled) {
|
||||
setTimeout(() => validateOnChange(service.type), 100);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// Render configuration fields based on service type
|
||||
function renderConfigFields(serviceType, config) {
|
||||
switch (serviceType) {
|
||||
case 'whisper':
|
||||
return `
|
||||
<div class="form-group">
|
||||
<label>Available Models</label>
|
||||
<input type="text" value="${config.models ? config.models.join(', ') : ''}"
|
||||
placeholder="tiny, base, small, medium, large"
|
||||
onchange="updateConfigField('${serviceType}', 'models', this.value.split(',').map(s => s.trim()))">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Max File Size (MB)</label>
|
||||
<input type="number" value="${config.max_file_size_mb || 500}"
|
||||
onchange="updateConfigField('${serviceType}', 'max_file_size_mb', parseInt(this.value))">
|
||||
</div>
|
||||
`;
|
||||
|
||||
case 'stable_diffusion':
|
||||
return `
|
||||
<div class="form-group">
|
||||
<label>Available Models</label>
|
||||
<input type="text" value="${config.models ? config.models.join(', ') : ''}"
|
||||
placeholder="stable-diffusion-1.5, stable-diffusion-2.1, sdxl"
|
||||
onchange="updateConfigField('${serviceType}', 'models', this.value.split(',').map(s => s.trim()))">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Max Resolution</label>
|
||||
<select onchange="updateConfigField('${serviceType}', 'max_resolution', this.value)">
|
||||
<option value="512x512" ${config.max_resolution === '512x512' ? 'selected' : ''}>512x512</option>
|
||||
<option value="768x768" ${config.max_resolution === '768x768' ? 'selected' : ''}>768x768</option>
|
||||
<option value="1024x1024" ${config.max_resolution === '1024x1024' ? 'selected' : ''}>1024x1024</option>
|
||||
<option value="4K" ${config.max_resolution === '4K' ? 'selected' : ''}>4K</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Max Images per Request</label>
|
||||
<input type="number" min="1" max="10" value="${config.max_images_per_request || 4}"
|
||||
onchange="updateConfigField('${serviceType}', 'max_images_per_request', parseInt(this.value))">
|
||||
</div>
|
||||
`;
|
||||
|
||||
case 'llm_inference':
|
||||
return `
|
||||
<div class="form-group">
|
||||
<label>Available Models</label>
|
||||
<input type="text" value="${config.models ? config.models.join(', ') : ''}"
|
||||
placeholder="llama-7b, llama-13b, mistral-7b, mixtral-8x7b"
|
||||
onchange="updateConfigField('${serviceType}', 'models', this.value.split(',').map(s => s.trim()))">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Max Tokens</label>
|
||||
<input type="number" value="${config.max_tokens || 4096}"
|
||||
onchange="updateConfigField('${serviceType}', 'max_tokens', parseInt(this.value))">
|
||||
</div>
|
||||
`;
|
||||
|
||||
case 'ffmpeg':
|
||||
return `
|
||||
<div class="form-group">
|
||||
<label>Supported Codecs</label>
|
||||
<input type="text" value="${config.supported_codecs ? config.supported_codecs.join(', ') : ''}"
|
||||
placeholder="h264, h265, vp9"
|
||||
onchange="updateConfigField('${serviceType}', 'supported_codecs', this.value.split(',').map(s => s.trim()))">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Max Resolution</label>
|
||||
<select onchange="updateConfigField('${serviceType}', 'max_resolution', this.value)">
|
||||
<option value="1080p" ${config.max_resolution === '1080p' ? 'selected' : ''}>1080p</option>
|
||||
<option value="4K" ${config.max_resolution === '4K' ? 'selected' : ''}>4K</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Max File Size (GB)</label>
|
||||
<input type="number" value="${config.max_file_size_gb || 10}"
|
||||
onchange="updateConfigField('${serviceType}', 'max_file_size_gb', parseInt(this.value))">
|
||||
</div>
|
||||
`;
|
||||
|
||||
case 'blender':
|
||||
return `
|
||||
<div class="form-group">
|
||||
<label>Render Engines</label>
|
||||
<input type="text" value="${config.engines ? config.engines.join(', ') : ''}"
|
||||
placeholder="cycles, eevee"
|
||||
onchange="updateConfigField('${serviceType}', 'engines', this.value.split(',').map(s => s.trim()))">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Default Engine</label>
|
||||
<select onchange="updateConfigField('${serviceType}', 'default_engine', this.value)">
|
||||
<option value="cycles" ${config.default_engine === 'cycles' ? 'selected' : ''}>Cycles</option>
|
||||
<option value="eevee" ${config.default_engine === 'eevee' ? 'selected' : ''}>Eevee</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Max Samples</label>
|
||||
<input type="number" value="${config.max_samples || 4096}"
|
||||
onchange="updateConfigField('${serviceType}', 'max_samples', parseInt(this.value))">
|
||||
</div>
|
||||
`;
|
||||
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Render pricing fields based on service type
|
||||
function renderPricingFields(serviceType, pricing) {
|
||||
switch (serviceType) {
|
||||
case 'whisper':
|
||||
case 'llm_inference':
|
||||
return `
|
||||
<div class="form-group">
|
||||
<label>Price per 1k tokens/minutes</label>
|
||||
<div class="price-input-group">
|
||||
<input type="number" step="0.001" min="0" value="${pricing.per_1k_tokens || pricing.per_minute || 0.001}"
|
||||
onchange="updatePricingField('${serviceType}', '${pricing.per_1k_tokens ? 'per_1k_tokens' : 'per_minute'}', parseFloat(this.value))">
|
||||
<span class="price-unit">AITBC</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Minimum Charge</label>
|
||||
<div class="price-input-group">
|
||||
<input type="number" step="0.01" min="0" value="${pricing.min_charge || 0.01}"
|
||||
onchange="updatePricingField('${serviceType}', 'min_charge', parseFloat(this.value))">
|
||||
<span class="price-unit">AITBC</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
case 'stable_diffusion':
|
||||
return `
|
||||
<div class="form-group">
|
||||
<label>Price per Image</label>
|
||||
<div class="price-input-group">
|
||||
<input type="number" step="0.001" min="0" value="${pricing.per_image || 0.01}"
|
||||
onchange="updatePricingField('${serviceType}', 'per_image', parseFloat(this.value))">
|
||||
<span class="price-unit">AITBC</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Price per Step</label>
|
||||
<div class="price-input-group">
|
||||
<input type="number" step="0.001" min="0" value="${pricing.per_step || 0.001}"
|
||||
onchange="updatePricingField('${serviceType}', 'per_step', parseFloat(this.value))">
|
||||
<span class="price-unit">AITBC</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
case 'ffmpeg':
|
||||
return `
|
||||
<div class="form-group">
|
||||
<label>Price per Minute</label>
|
||||
<div class="price-input-group">
|
||||
<input type="number" step="0.001" min="0" value="${pricing.per_minute || 0.005}"
|
||||
onchange="updatePricingField('${serviceType}', 'per_minute', parseFloat(this.value))">
|
||||
<span class="price-unit">AITBC</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Price per GB</label>
|
||||
<div class="price-input-group">
|
||||
<input type="number" step="0.01" min="0" value="${pricing.per_gb || 0.01}"
|
||||
onchange="updatePricingField('${serviceType}', 'per_gb', parseFloat(this.value))">
|
||||
<span class="price-unit">AITBC</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
case 'blender':
|
||||
return `
|
||||
<div class="form-group">
|
||||
<label>Price per Frame</label>
|
||||
<div class="price-input-group">
|
||||
<input type="number" step="0.001" min="0" value="${pricing.per_frame || 0.01}"
|
||||
onchange="updatePricingField('${serviceType}', 'per_frame', parseFloat(this.value))">
|
||||
<span class="price-unit">AITBC</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Price per Hour</label>
|
||||
<div class="price-input-group">
|
||||
<input type="number" step="0.01" min="0" value="${pricing.per_hour || 0.5}"
|
||||
onchange="updatePricingField('${serviceType}', 'per_hour', parseFloat(this.value))">
|
||||
<span class="price-unit">AITBC</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle service enabled/disabled
|
||||
function toggleService(serviceType, enabled) {
|
||||
if (!serviceConfigs[serviceType]) {
|
||||
const service = SERVICES.find(s => s.type === serviceType);
|
||||
serviceConfigs[serviceType] = {
|
||||
service_type: serviceType,
|
||||
enabled: enabled,
|
||||
config: service.defaultConfig,
|
||||
pricing: service.defaultPricing,
|
||||
capabilities: service.capabilities,
|
||||
max_concurrent: 1
|
||||
};
|
||||
} else {
|
||||
serviceConfigs[serviceType].enabled = enabled;
|
||||
}
|
||||
|
||||
// Show/hide configuration section
|
||||
const configSection = document.getElementById(`config-${serviceType}`);
|
||||
configSection.style.display = enabled ? 'block' : 'none';
|
||||
|
||||
// Validate when enabling
|
||||
if (enabled) {
|
||||
setTimeout(() => validateOnChange(serviceType), 100);
|
||||
} else {
|
||||
// Clear validation feedback when disabling
|
||||
const card = document.querySelector(`[data-service="${serviceType}"]`);
|
||||
const feedback = card.querySelector('.validation-feedback');
|
||||
if (feedback) feedback.remove();
|
||||
card.style.borderColor = '#e0e0e0';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate configuration on change
|
||||
async function validateOnChange(serviceType) {
|
||||
const config = serviceConfigs[serviceType];
|
||||
if (!config || !config.enabled) return;
|
||||
|
||||
const validationResult = await validateService(serviceType, config);
|
||||
showValidationFeedback(serviceType, validationResult);
|
||||
|
||||
// Update score display
|
||||
const scoreElement = document.querySelector(`#score-${serviceType} span`);
|
||||
if (scoreElement) {
|
||||
scoreElement.textContent = validationResult.score;
|
||||
document.getElementById(`score-${serviceType}`).style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Update configuration field
|
||||
function updateConfigField(serviceType, field, value) {
|
||||
if (!serviceConfigs[serviceType]) return;
|
||||
if (!serviceConfigs[serviceType].config) {
|
||||
serviceConfigs[serviceType].config = {};
|
||||
}
|
||||
serviceConfigs[serviceType].config[field] = value;
|
||||
}
|
||||
|
||||
// Update pricing field
|
||||
function updatePricingField(serviceType, field, value) {
|
||||
if (!serviceConfigs[serviceType]) return;
|
||||
if (!serviceConfigs[serviceType].pricing) {
|
||||
serviceConfigs[serviceType].pricing = {};
|
||||
}
|
||||
serviceConfigs[serviceType].pricing[field] = value;
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
function updateConfig(serviceType, field, value) {
|
||||
if (!serviceConfigs[serviceType]) return;
|
||||
serviceConfigs[serviceType][field] = value;
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
async function saveConfig(serviceType) {
|
||||
const config = serviceConfigs[serviceType];
|
||||
if (!config) return;
|
||||
|
||||
// Validate before saving
|
||||
const validationResult = await validateService(serviceType, config);
|
||||
if (!validationResult.valid) {
|
||||
showNotification(`Cannot save: ${validationResult.errors.join(', ')}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/services/${serviceType}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save configuration');
|
||||
}
|
||||
|
||||
showNotification('Configuration saved successfully', 'success');
|
||||
} catch (error) {
|
||||
showNotification('Failed to save configuration: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate service configuration
|
||||
async function validateService(serviceType, config) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/validation/service/${serviceType}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(config.config || {})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Validation failed');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { valid: false, errors: [error.message] };
|
||||
}
|
||||
}
|
||||
|
||||
// Show validation feedback in UI
|
||||
function showValidationFeedback(serviceType, validationResult) {
|
||||
const card = document.querySelector(`[data-service="${serviceType}"]`);
|
||||
if (!card) return;
|
||||
|
||||
// Remove existing feedback
|
||||
const existing = card.querySelector('.validation-feedback');
|
||||
if (existing) existing.remove();
|
||||
|
||||
// Create feedback element
|
||||
const feedback = document.createElement('div');
|
||||
feedback.className = 'validation-feedback';
|
||||
|
||||
if (!validationResult.valid) {
|
||||
feedback.innerHTML = `
|
||||
<div class="validation-errors">
|
||||
<strong>❌ Configuration Issues:</strong>
|
||||
<ul>
|
||||
${validationResult.errors.map(e => `<li>${e}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
feedback.style.color = '#f44336';
|
||||
} else if (validationResult.warnings.length > 0) {
|
||||
feedback.innerHTML = `
|
||||
<div class="validation-warnings">
|
||||
<strong>⚠️ Warnings:</strong>
|
||||
<ul>
|
||||
${validationResult.warnings.map(w => `<li>${w}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
feedback.style.color = '#ff9800';
|
||||
} else {
|
||||
feedback.innerHTML = `
|
||||
<div class="validation-success">
|
||||
✅ Configuration is valid (Score: ${validationResult.score}/100)
|
||||
</div>
|
||||
`;
|
||||
feedback.style.color = '#4caf50';
|
||||
}
|
||||
|
||||
// Insert after the toggle switch
|
||||
const toggle = card.querySelector('.toggle-switch');
|
||||
toggle.parentNode.insertBefore(feedback, toggle.nextSibling.nextSibling);
|
||||
|
||||
// Update card border based on validation
|
||||
if (validationResult.valid) {
|
||||
card.style.borderColor = '#4caf50';
|
||||
} else {
|
||||
card.style.borderColor = '#f44336';
|
||||
}
|
||||
}
|
||||
|
||||
// Reset configuration to defaults
|
||||
function resetConfig(serviceType) {
|
||||
const service = SERVICES.find(s => s.type === serviceType);
|
||||
serviceConfigs[serviceType] = {
|
||||
service_type: serviceType,
|
||||
enabled: false,
|
||||
config: service.defaultConfig,
|
||||
pricing: service.defaultPricing,
|
||||
capabilities: service.capabilities,
|
||||
max_concurrent: 1
|
||||
};
|
||||
renderServices();
|
||||
}
|
||||
|
||||
// Get auth headers
|
||||
function getAuthHeaders() {
|
||||
// Get API key from localStorage or other secure storage
|
||||
const apiKey = localStorage.getItem('poolhub_api_key') || '';
|
||||
return {
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
};
|
||||
}
|
||||
|
||||
// Show notification
|
||||
function showNotification(message, type = 'success') {
|
||||
const notification = document.getElementById('notification');
|
||||
notification.textContent = message;
|
||||
notification.className = `notification ${type}`;
|
||||
notification.style.display = 'block';
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.display = 'none';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Show/hide loading
|
||||
function showLoading(show) {
|
||||
document.getElementById('loading').style.display = show ? 'block' : 'none';
|
||||
document.getElementById('servicesGrid').style.display = show ? 'none' : 'grid';
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user