From 61d0a00d41032c0629a361cdff37e58bb24c5791 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 16 Jan 2026 18:58:33 +0000
Subject: [PATCH 01/11] feat: Add GitHub docker-compose import functionality
- Add backend API endpoints for scanning GitHub repos and detecting
docker-compose files
- Implement GitHub URL parsing supporting various URL formats
- Add compose file parsing and service/env extraction
- Create ImportFromGitHubModal component with multi-step wizard:
- URL input with branch selection
- Compose file detection and selection
- Service selection from detected services
- Shadow header configuration interface
- Environment variables configuration with secret handling
- Add import button to Services page header
- Support importing services with custom shadow headers for routing
- Save imported service configuration to settings store
---
ushadow/backend/main.py | 2 +
ushadow/backend/src/models/github_import.py | 206 +++++
ushadow/backend/src/routers/github_import.py | 612 ++++++++++++++
.../src/components/ImportFromGitHubModal.tsx | 762 ++++++++++++++++++
ushadow/frontend/src/pages/ServicesPage.tsx | 25 +-
ushadow/frontend/src/services/api.ts | 142 ++++
6 files changed, 1748 insertions(+), 1 deletion(-)
create mode 100644 ushadow/backend/src/models/github_import.py
create mode 100644 ushadow/backend/src/routers/github_import.py
create mode 100644 ushadow/frontend/src/components/ImportFromGitHubModal.tsx
diff --git a/ushadow/backend/main.py b/ushadow/backend/main.py
index bbab13e7..118b4912 100644
--- a/ushadow/backend/main.py
+++ b/ushadow/backend/main.py
@@ -22,6 +22,7 @@
from src.routers import health, wizard, chronicle, auth, feature_flags
from src.routers import services, deployments, providers, instances, chat
from src.routers import kubernetes, tailscale, unodes, docker
+from src.routers import github_import
from src.routers import settings as settings_api
from src.middleware import setup_middleware
from src.services.unode_manager import init_unode_manager, get_unode_manager
@@ -180,6 +181,7 @@ def send_telemetry():
app.include_router(chat.router, prefix="/api/chat", tags=["chat"])
app.include_router(deployments.router, tags=["deployments"])
app.include_router(tailscale.router, tags=["tailscale"])
+app.include_router(github_import.router, prefix="/api/github-import", tags=["github-import"])
# Setup MCP server for LLM tool access
setup_mcp_server(app)
diff --git a/ushadow/backend/src/models/github_import.py b/ushadow/backend/src/models/github_import.py
new file mode 100644
index 00000000..7b69eda5
--- /dev/null
+++ b/ushadow/backend/src/models/github_import.py
@@ -0,0 +1,206 @@
+"""
+Models for GitHub Docker Compose Import functionality.
+
+Supports importing docker-compose files from GitHub repositories
+and configuring shadow headers and environment variables.
+"""
+
+from typing import Dict, List, Optional, Any
+from pydantic import BaseModel, Field, field_validator
+import re
+
+
+class GitHubUrlInfo(BaseModel):
+ """Parsed GitHub URL information."""
+ owner: str
+ repo: str
+ branch: str = "main"
+ path: str = ""
+
+ @property
+ def api_url(self) -> str:
+ """Get GitHub API URL for contents."""
+ if self.path:
+ return f"https://api.github.com/repos/{self.owner}/{self.repo}/contents/{self.path}?ref={self.branch}"
+ return f"https://api.github.com/repos/{self.owner}/{self.repo}/contents?ref={self.branch}"
+
+ @property
+ def raw_url(self) -> str:
+ """Get raw GitHub URL for file content."""
+ if self.path:
+ return f"https://raw.githubusercontent.com/{self.owner}/{self.repo}/{self.branch}/{self.path}"
+ return f"https://raw.githubusercontent.com/{self.owner}/{self.repo}/{self.branch}"
+
+
+class GitHubImportRequest(BaseModel):
+ """Request to import from a GitHub URL."""
+ github_url: str = Field(..., description="GitHub repository or file URL")
+ branch: Optional[str] = Field(None, description="Branch to use (defaults to main/master)")
+ compose_path: Optional[str] = Field(None, description="Path to docker-compose file if not auto-detected")
+
+ @field_validator('github_url')
+ @classmethod
+ def validate_github_url(cls, v: str) -> str:
+ """Validate that the URL is a GitHub URL."""
+ if not v:
+ raise ValueError("GitHub URL is required")
+ if not ('github.com' in v or 'raw.githubusercontent.com' in v):
+ raise ValueError("URL must be a GitHub URL")
+ return v
+
+
+class DetectedComposeFile(BaseModel):
+ """A docker-compose file detected in the repository."""
+ path: str
+ name: str
+ download_url: str
+ size: int = 0
+
+
+class ComposeEnvVarInfo(BaseModel):
+ """Environment variable extracted from compose file."""
+ name: str
+ has_default: bool = False
+ default_value: Optional[str] = None
+ is_required: bool = True
+ description: Optional[str] = None
+
+
+class ComposeServiceInfo(BaseModel):
+ """Service information extracted from compose file."""
+ name: str
+ image: Optional[str] = None
+ ports: List[Dict[str, Any]] = Field(default_factory=list)
+ environment: List[ComposeEnvVarInfo] = Field(default_factory=list)
+ depends_on: List[str] = Field(default_factory=list)
+ volumes: List[str] = Field(default_factory=list)
+ networks: List[str] = Field(default_factory=list)
+ command: Optional[str] = None
+ healthcheck: Optional[Dict[str, Any]] = None
+
+
+class ShadowHeaderConfig(BaseModel):
+ """Configuration for shadow header routing."""
+ enabled: bool = True
+ header_name: str = Field(default="X-Shadow-Service", description="Header name for service identification")
+ header_value: Optional[str] = Field(None, description="Header value (defaults to service name)")
+ route_path: Optional[str] = Field(None, description="Tailscale Serve route path (e.g., /myservice)")
+
+
+class EnvVarConfigItem(BaseModel):
+ """Configuration for a single environment variable."""
+ name: str
+ source: str = "literal" # "literal", "setting", "default"
+ value: Optional[str] = None
+ setting_path: Optional[str] = None
+ is_secret: bool = False
+
+
+class ImportedServiceConfig(BaseModel):
+ """Full configuration for an imported service."""
+ service_name: str
+ display_name: Optional[str] = None
+ description: Optional[str] = None
+ github_url: str
+ compose_path: str
+ shadow_header: ShadowHeaderConfig = Field(default_factory=ShadowHeaderConfig)
+ env_vars: List[EnvVarConfigItem] = Field(default_factory=list)
+ enabled: bool = True
+
+
+class GitHubScanResponse(BaseModel):
+ """Response from scanning a GitHub repository."""
+ success: bool
+ github_info: Optional[GitHubUrlInfo] = None
+ compose_files: List[DetectedComposeFile] = Field(default_factory=list)
+ message: Optional[str] = None
+ error: Optional[str] = None
+
+
+class ComposeParseResponse(BaseModel):
+ """Response from parsing a docker-compose file."""
+ success: bool
+ compose_path: str
+ services: List[ComposeServiceInfo] = Field(default_factory=list)
+ networks: List[str] = Field(default_factory=list)
+ volumes: List[str] = Field(default_factory=list)
+ message: Optional[str] = None
+ error: Optional[str] = None
+
+
+class ImportServiceRequest(BaseModel):
+ """Request to import and register a service from GitHub."""
+ github_url: str
+ compose_path: str
+ service_name: str
+ config: ImportedServiceConfig
+
+
+class ImportServiceResponse(BaseModel):
+ """Response from importing a service."""
+ success: bool
+ service_id: Optional[str] = None
+ service_name: Optional[str] = None
+ message: str
+ compose_file_path: Optional[str] = None
+
+
+def parse_github_url(url: str) -> GitHubUrlInfo:
+ """
+ Parse a GitHub URL into its components.
+
+ Supports:
+ - https://github.com/owner/repo
+ - https://github.com/owner/repo/tree/branch
+ - https://github.com/owner/repo/tree/branch/path/to/dir
+ - https://github.com/owner/repo/blob/branch/path/to/file
+ - https://raw.githubusercontent.com/owner/repo/branch/path/to/file
+ """
+ url = url.strip()
+
+ # Handle raw.githubusercontent.com URLs
+ raw_match = re.match(
+ r'https?://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)(?:/(.+))?',
+ url
+ )
+ if raw_match:
+ owner, repo, branch, path = raw_match.groups()
+ return GitHubUrlInfo(
+ owner=owner,
+ repo=repo,
+ branch=branch,
+ path=path or ""
+ )
+
+ # Handle github.com URLs
+ github_match = re.match(
+ r'https?://github\.com/([^/]+)/([^/]+)(?:/(tree|blob)/([^/]+)(?:/(.+))?)?(?:\.git)?',
+ url
+ )
+ if github_match:
+ owner, repo, url_type, branch, path = github_match.groups()
+ # Remove .git suffix if present
+ repo = repo.replace('.git', '')
+ return GitHubUrlInfo(
+ owner=owner,
+ repo=repo,
+ branch=branch or "main",
+ path=path or ""
+ )
+
+ # Simple github.com/owner/repo pattern
+ simple_match = re.match(
+ r'https?://github\.com/([^/]+)/([^/]+)/?$',
+ url
+ )
+ if simple_match:
+ owner, repo = simple_match.groups()
+ repo = repo.replace('.git', '')
+ return GitHubUrlInfo(
+ owner=owner,
+ repo=repo,
+ branch="main",
+ path=""
+ )
+
+ raise ValueError(f"Could not parse GitHub URL: {url}")
diff --git a/ushadow/backend/src/routers/github_import.py b/ushadow/backend/src/routers/github_import.py
new file mode 100644
index 00000000..c4c53ab7
--- /dev/null
+++ b/ushadow/backend/src/routers/github_import.py
@@ -0,0 +1,612 @@
+"""
+GitHub Docker Compose Import Router.
+
+Provides endpoints for:
+- Scanning GitHub repositories for docker-compose files
+- Parsing compose files and extracting service/env info
+- Importing and registering services with shadow header configuration
+"""
+
+import logging
+import os
+import re
+from pathlib import Path
+from typing import List, Dict, Any, Optional
+
+import httpx
+from fastapi import APIRouter, HTTPException, Depends
+from ruamel.yaml import YAML
+
+from src.models.github_import import (
+ GitHubImportRequest,
+ GitHubScanResponse,
+ GitHubUrlInfo,
+ DetectedComposeFile,
+ ComposeParseResponse,
+ ComposeServiceInfo,
+ ComposeEnvVarInfo,
+ ImportServiceRequest,
+ ImportServiceResponse,
+ ImportedServiceConfig,
+ ShadowHeaderConfig,
+ EnvVarConfigItem,
+ parse_github_url,
+)
+from src.services.auth import get_current_user
+from src.models.user import User
+from src.config.yaml_parser import ComposeParser
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+
+# Common docker-compose file patterns to look for
+COMPOSE_FILE_PATTERNS = [
+ 'docker-compose.yml',
+ 'docker-compose.yaml',
+ 'compose.yml',
+ 'compose.yaml',
+ 'docker-compose.dev.yml',
+ 'docker-compose.dev.yaml',
+ 'docker-compose.prod.yml',
+ 'docker-compose.prod.yaml',
+ 'docker-compose.override.yml',
+ 'docker-compose.override.yaml',
+]
+
+
+async def fetch_github_contents(url: str, token: Optional[str] = None) -> Dict[str, Any]:
+ """Fetch contents from GitHub API."""
+ headers = {
+ 'Accept': 'application/vnd.github.v3+json',
+ 'User-Agent': 'Ushadow-Import'
+ }
+ if token:
+ headers['Authorization'] = f'token {token}'
+
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.get(url, headers=headers)
+ if response.status_code == 404:
+ raise HTTPException(status_code=404, detail="Repository or path not found")
+ if response.status_code == 403:
+ raise HTTPException(status_code=403, detail="GitHub API rate limit exceeded or access denied")
+ response.raise_for_status()
+ return response.json()
+
+
+async def fetch_raw_content(url: str, token: Optional[str] = None) -> str:
+ """Fetch raw file content from GitHub."""
+ headers = {'User-Agent': 'Ushadow-Import'}
+ if token:
+ headers['Authorization'] = f'token {token}'
+
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.get(url, headers=headers)
+ response.raise_for_status()
+ return response.text
+
+
+def detect_compose_files(contents: List[Dict[str, Any]]) -> List[DetectedComposeFile]:
+ """Detect docker-compose files from GitHub directory listing."""
+ compose_files = []
+
+ for item in contents:
+ if item.get('type') != 'file':
+ continue
+
+ name = item.get('name', '').lower()
+
+ # Check if file matches any compose pattern
+ for pattern in COMPOSE_FILE_PATTERNS:
+ if name == pattern.lower() or name.endswith('-compose.yml') or name.endswith('-compose.yaml'):
+ compose_files.append(DetectedComposeFile(
+ path=item.get('path', ''),
+ name=item.get('name', ''),
+ download_url=item.get('download_url', ''),
+ size=item.get('size', 0)
+ ))
+ break
+
+ return compose_files
+
+
+def parse_env_var_line(line: str) -> Optional[ComposeEnvVarInfo]:
+ """Parse an environment variable line from compose format."""
+ if not line or not isinstance(line, str):
+ return None
+
+ line = line.strip()
+ if not line:
+ return None
+
+ # Pattern for ${VAR:-default}
+ env_pattern = re.compile(r'\$\{([^:}]+)(?::-([^}]*))?\}')
+
+ # Check for KEY=VALUE format
+ if '=' in line:
+ key, value = line.split('=', 1)
+ key = key.strip()
+ value = value.strip()
+
+ if not value:
+ return ComposeEnvVarInfo(name=key, has_default=False, is_required=True)
+
+ # Check for ${VAR:-default} pattern
+ match = env_pattern.search(value)
+ if match:
+ var_name, default = match.groups()
+ has_default = default is not None and default != ""
+ return ComposeEnvVarInfo(
+ name=key,
+ has_default=has_default,
+ default_value=default if has_default else None,
+ is_required=not has_default
+ )
+
+ # Plain value - has a hardcoded default
+ return ComposeEnvVarInfo(
+ name=key,
+ has_default=True,
+ default_value=value,
+ is_required=False
+ )
+
+ # Bare variable name - required
+ return ComposeEnvVarInfo(name=line, has_default=False, is_required=True)
+
+
+def parse_compose_content(content: str) -> Dict[str, Any]:
+ """Parse docker-compose YAML content."""
+ yaml = YAML()
+ yaml.preserve_quotes = True
+ from io import StringIO
+ return yaml.load(StringIO(content)) or {}
+
+
+def extract_services_from_compose(data: Dict[str, Any]) -> List[ComposeServiceInfo]:
+ """Extract service information from parsed compose data."""
+ services = []
+ services_data = data.get('services', {})
+
+ for name, service_data in services_data.items():
+ # Parse environment variables
+ env_vars = []
+ environment = service_data.get('environment', [])
+
+ if isinstance(environment, list):
+ for item in environment:
+ env_info = parse_env_var_line(str(item) if item else "")
+ if env_info:
+ env_vars.append(env_info)
+ elif isinstance(environment, dict):
+ for key, value in environment.items():
+ if value is None:
+ env_vars.append(ComposeEnvVarInfo(name=key, has_default=False, is_required=True))
+ else:
+ env_info = parse_env_var_line(f"{key}={value}")
+ if env_info:
+ env_vars.append(env_info)
+
+ # Parse ports
+ ports = []
+ for port in service_data.get('ports', []):
+ if isinstance(port, str):
+ port_str = port.replace('/tcp', '').replace('/udp', '')
+ if ':' in port_str:
+ host, container = port_str.rsplit(':', 1)
+ ports.append({'host': host, 'container': container})
+ else:
+ ports.append({'container': port_str})
+ elif isinstance(port, dict):
+ ports.append({
+ 'host': str(port.get('published', '')),
+ 'container': str(port.get('target', ''))
+ })
+
+ # Parse depends_on
+ depends_on = service_data.get('depends_on', [])
+ if isinstance(depends_on, dict):
+ depends_on = list(depends_on.keys())
+
+ # Parse volumes
+ volumes = service_data.get('volumes', [])
+ if isinstance(volumes, list):
+ volumes = [str(v) for v in volumes]
+
+ # Parse networks
+ networks = service_data.get('networks', [])
+ if isinstance(networks, dict):
+ networks = list(networks.keys())
+
+ services.append(ComposeServiceInfo(
+ name=name,
+ image=service_data.get('image'),
+ ports=ports,
+ environment=env_vars,
+ depends_on=depends_on,
+ volumes=volumes,
+ networks=networks,
+ command=service_data.get('command'),
+ healthcheck=service_data.get('healthcheck')
+ ))
+
+ return services
+
+
+@router.post("/scan", response_model=GitHubScanResponse)
+async def scan_github_repo(
+ request: GitHubImportRequest,
+ current_user: User = Depends(get_current_user)
+) -> GitHubScanResponse:
+ """
+ Scan a GitHub repository for docker-compose files.
+
+ Accepts a GitHub URL (repository, directory, or specific file) and returns
+ a list of detected docker-compose files.
+ """
+ try:
+ github_info = parse_github_url(request.github_url)
+ logger.info(f"Scanning GitHub repo: {github_info.owner}/{github_info.repo}")
+
+ # If a specific compose path is provided, verify it exists
+ if request.compose_path:
+ github_info.path = request.compose_path
+
+ # Fetch directory contents
+ contents = await fetch_github_contents(github_info.api_url)
+
+ # Handle single file case
+ if isinstance(contents, dict) and contents.get('type') == 'file':
+ return GitHubScanResponse(
+ success=True,
+ github_info=github_info,
+ compose_files=[DetectedComposeFile(
+ path=contents.get('path', ''),
+ name=contents.get('name', ''),
+ download_url=contents.get('download_url', ''),
+ size=contents.get('size', 0)
+ )],
+ message="Found specified compose file"
+ )
+
+ # Detect compose files in directory
+ compose_files = detect_compose_files(contents if isinstance(contents, list) else [])
+
+ if not compose_files:
+ return GitHubScanResponse(
+ success=True,
+ github_info=github_info,
+ compose_files=[],
+ message="No docker-compose files found in the specified location"
+ )
+
+ return GitHubScanResponse(
+ success=True,
+ github_info=github_info,
+ compose_files=compose_files,
+ message=f"Found {len(compose_files)} docker-compose file(s)"
+ )
+
+ except ValueError as e:
+ return GitHubScanResponse(
+ success=False,
+ error=str(e)
+ )
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error scanning GitHub repo: {e}")
+ return GitHubScanResponse(
+ success=False,
+ error=str(e)
+ )
+
+
+@router.post("/parse", response_model=ComposeParseResponse)
+async def parse_compose_file(
+ github_url: str,
+ compose_path: str,
+ current_user: User = Depends(get_current_user)
+) -> ComposeParseResponse:
+ """
+ Parse a docker-compose file and extract service/environment information.
+
+ Returns structured information about services, including environment variables
+ that need to be configured.
+ """
+ try:
+ github_info = parse_github_url(github_url)
+ github_info.path = compose_path
+
+ # Build raw URL for file content
+ raw_url = f"https://raw.githubusercontent.com/{github_info.owner}/{github_info.repo}/{github_info.branch}/{compose_path}"
+
+ # Fetch compose file content
+ content = await fetch_raw_content(raw_url)
+
+ # Parse the compose file
+ data = parse_compose_content(content)
+
+ # Extract services
+ services = extract_services_from_compose(data)
+
+ # Extract networks
+ networks = list(data.get('networks', {}).keys())
+
+ # Extract volumes
+ volumes = list(data.get('volumes', {}).keys())
+
+ return ComposeParseResponse(
+ success=True,
+ compose_path=compose_path,
+ services=services,
+ networks=networks,
+ volumes=volumes,
+ message=f"Successfully parsed {len(services)} service(s)"
+ )
+
+ except Exception as e:
+ logger.error(f"Error parsing compose file: {e}")
+ return ComposeParseResponse(
+ success=False,
+ compose_path=compose_path,
+ error=str(e)
+ )
+
+
+@router.post("/register", response_model=ImportServiceResponse)
+async def register_imported_service(
+ request: ImportServiceRequest,
+ current_user: User = Depends(get_current_user)
+) -> ImportServiceResponse:
+ """
+ Register an imported service from GitHub.
+
+ Downloads the compose file, saves it locally, and registers the service
+ with the configured shadow header and environment variables.
+ """
+ try:
+ github_info = parse_github_url(request.github_url)
+ github_info.path = request.compose_path
+ config = request.config
+
+ # Build raw URL and fetch compose content
+ raw_url = f"https://raw.githubusercontent.com/{github_info.owner}/{github_info.repo}/{github_info.branch}/{request.compose_path}"
+ content = await fetch_raw_content(raw_url)
+
+ # Parse to validate
+ data = parse_compose_content(content)
+ if not data.get('services'):
+ return ImportServiceResponse(
+ success=False,
+ message="No services found in compose file"
+ )
+
+ # Add x-ushadow metadata to the compose content
+ yaml = YAML()
+ yaml.preserve_quotes = True
+ from io import StringIO
+ compose_data = yaml.load(StringIO(content)) or {}
+
+ # Build x-ushadow section
+ x_ushadow = compose_data.get('x-ushadow', {})
+
+ # Add metadata for the service
+ service_meta = {
+ 'display_name': config.display_name or request.service_name,
+ 'description': config.description or f"Imported from {github_info.owner}/{github_info.repo}",
+ 'github_source': {
+ 'url': request.github_url,
+ 'owner': github_info.owner,
+ 'repo': github_info.repo,
+ 'branch': github_info.branch,
+ 'path': request.compose_path
+ }
+ }
+
+ # Add shadow header config
+ if config.shadow_header.enabled:
+ service_meta['shadow_header'] = {
+ 'enabled': True,
+ 'header_name': config.shadow_header.header_name,
+ 'header_value': config.shadow_header.header_value or request.service_name
+ }
+ if config.shadow_header.route_path:
+ service_meta['route_path'] = config.shadow_header.route_path
+
+ x_ushadow[request.service_name] = service_meta
+ compose_data['x-ushadow'] = x_ushadow
+
+ # Ensure compose directory exists
+ compose_dir = Path('/compose')
+ if not compose_dir.exists():
+ compose_dir = Path('compose')
+ compose_dir.mkdir(parents=True, exist_ok=True)
+
+ # Generate filename
+ safe_name = re.sub(r'[^a-zA-Z0-9_-]', '-', request.service_name)
+ compose_filename = f"{safe_name}-compose.yaml"
+ compose_path = compose_dir / compose_filename
+
+ # Write the compose file with modifications
+ output = StringIO()
+ yaml.dump(compose_data, output)
+ compose_content = output.getvalue()
+
+ # Add environment variable overrides if configured
+ env_file_content = []
+ for env_config in config.env_vars:
+ if env_config.source == "literal" and env_config.value:
+ env_file_content.append(f"{env_config.name}={env_config.value}")
+
+ # Write compose file
+ with open(compose_path, 'w') as f:
+ f.write(compose_content)
+
+ # Write env file if we have overrides
+ if env_file_content:
+ env_file_path = compose_dir / f"{safe_name}.env"
+ with open(env_file_path, 'w') as f:
+ f.write('\n'.join(env_file_content) + '\n')
+
+ # Save service configuration to settings
+ from src.config.omegaconf_settings import get_settings_store
+ settings = get_settings_store()
+
+ service_config_key = f"imported_services.{safe_name}"
+ await settings.update({
+ service_config_key: {
+ 'github_url': request.github_url,
+ 'compose_path': request.compose_path,
+ 'compose_file': str(compose_path),
+ 'service_name': request.service_name,
+ 'display_name': config.display_name,
+ 'description': config.description,
+ 'shadow_header': config.shadow_header.model_dump(),
+ 'env_vars': [ev.model_dump() for ev in config.env_vars],
+ 'enabled': config.enabled
+ }
+ })
+
+ # Refresh compose registry to pick up new service
+ from src.services.compose_registry import get_compose_registry
+ registry = get_compose_registry()
+ registry.refresh()
+
+ logger.info(f"Imported service '{request.service_name}' from GitHub: {request.github_url}")
+
+ return ImportServiceResponse(
+ success=True,
+ service_id=f"{compose_filename.replace('.yaml', '')}:{request.service_name}",
+ service_name=request.service_name,
+ message=f"Successfully imported service '{request.service_name}'",
+ compose_file_path=str(compose_path)
+ )
+
+ except Exception as e:
+ logger.error(f"Error registering imported service: {e}")
+ return ImportServiceResponse(
+ success=False,
+ message=f"Failed to import service: {str(e)}"
+ )
+
+
+@router.get("/imported")
+async def list_imported_services(
+ current_user: User = Depends(get_current_user)
+) -> List[Dict[str, Any]]:
+ """List all imported services from GitHub."""
+ try:
+ from src.config.omegaconf_settings import get_settings_store
+ settings = get_settings_store()
+
+ imported = settings.get("imported_services", {})
+ return [
+ {
+ 'id': key,
+ **value
+ }
+ for key, value in imported.items()
+ ] if isinstance(imported, dict) else []
+
+ except Exception as e:
+ logger.error(f"Error listing imported services: {e}")
+ return []
+
+
+@router.delete("/imported/{service_id}")
+async def delete_imported_service(
+ service_id: str,
+ current_user: User = Depends(get_current_user)
+) -> Dict[str, Any]:
+ """Delete an imported service."""
+ try:
+ from src.config.omegaconf_settings import get_settings_store
+ settings = get_settings_store()
+
+ imported = settings.get("imported_services", {})
+ if service_id not in imported:
+ raise HTTPException(status_code=404, detail=f"Imported service '{service_id}' not found")
+
+ service_config = imported[service_id]
+ compose_file = service_config.get('compose_file')
+
+ # Remove compose file if it exists
+ if compose_file and os.path.exists(compose_file):
+ os.remove(compose_file)
+ logger.info(f"Removed compose file: {compose_file}")
+
+ # Remove env file if it exists
+ env_file = compose_file.replace('-compose.yaml', '.env') if compose_file else None
+ if env_file and os.path.exists(env_file):
+ os.remove(env_file)
+
+ # Remove from settings
+ del imported[service_id]
+ await settings.update({"imported_services": imported})
+
+ # Refresh compose registry
+ from src.services.compose_registry import get_compose_registry
+ registry = get_compose_registry()
+ registry.refresh()
+
+ return {
+ "success": True,
+ "message": f"Deleted imported service '{service_id}'"
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error deleting imported service: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.put("/imported/{service_id}/config")
+async def update_imported_service_config(
+ service_id: str,
+ config: ImportedServiceConfig,
+ current_user: User = Depends(get_current_user)
+) -> Dict[str, Any]:
+ """Update configuration for an imported service."""
+ try:
+ from src.config.omegaconf_settings import get_settings_store
+ settings = get_settings_store()
+
+ imported = settings.get("imported_services", {})
+ if service_id not in imported:
+ raise HTTPException(status_code=404, detail=f"Imported service '{service_id}' not found")
+
+ # Update configuration
+ imported[service_id].update({
+ 'display_name': config.display_name,
+ 'description': config.description,
+ 'shadow_header': config.shadow_header.model_dump(),
+ 'env_vars': [ev.model_dump() for ev in config.env_vars],
+ 'enabled': config.enabled
+ })
+
+ await settings.update({"imported_services": imported})
+
+ # Update env file with new values
+ compose_file = imported[service_id].get('compose_file')
+ if compose_file:
+ env_file_content = []
+ for env_config in config.env_vars:
+ if env_config.source == "literal" and env_config.value:
+ env_file_content.append(f"{env_config.name}={env_config.value}")
+
+ if env_file_content:
+ env_file_path = compose_file.replace('-compose.yaml', '.env')
+ with open(env_file_path, 'w') as f:
+ f.write('\n'.join(env_file_content) + '\n')
+
+ return {
+ "success": True,
+ "message": f"Updated configuration for '{service_id}'"
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error updating imported service config: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
diff --git a/ushadow/frontend/src/components/ImportFromGitHubModal.tsx b/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
new file mode 100644
index 00000000..6f2124ef
--- /dev/null
+++ b/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
@@ -0,0 +1,762 @@
+import { useState, useEffect } from 'react'
+import {
+ X,
+ Github,
+ Loader2,
+ FileCode,
+ ChevronRight,
+ ChevronLeft,
+ Settings,
+ Key,
+ Check,
+ AlertCircle,
+ Server,
+ Eye,
+ EyeOff,
+ RefreshCw,
+} from 'lucide-react'
+import {
+ githubImportApi,
+ DetectedComposeFile,
+ ComposeServiceInfo,
+ ComposeEnvVarInfo,
+ ShadowHeaderConfig,
+ EnvVarConfigItem,
+} from '../services/api'
+
+interface ImportFromGitHubModalProps {
+ isOpen: boolean
+ onClose: () => void
+ onServiceImported: () => void
+}
+
+type WizardStep = 'url' | 'compose' | 'service' | 'config' | 'complete'
+
+export default function ImportFromGitHubModal({
+ isOpen,
+ onClose,
+ onServiceImported,
+}: ImportFromGitHubModalProps) {
+ // Wizard state
+ const [step, setStep] = useState('url')
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ // URL step
+ const [githubUrl, setGithubUrl] = useState('')
+ const [branch, setBranch] = useState('')
+
+ // Compose file selection
+ const [composeFiles, setComposeFiles] = useState([])
+ const [selectedComposeFile, setSelectedComposeFile] = useState(null)
+
+ // Service selection
+ const [services, setServices] = useState([])
+ const [selectedService, setSelectedService] = useState(null)
+
+ // Configuration
+ const [serviceName, setServiceName] = useState('')
+ const [displayName, setDisplayName] = useState('')
+ const [description, setDescription] = useState('')
+ const [shadowHeader, setShadowHeader] = useState({
+ enabled: true,
+ header_name: 'X-Shadow-Service',
+ header_value: '',
+ route_path: '',
+ })
+ const [envVars, setEnvVars] = useState([])
+ const [showSecrets, setShowSecrets] = useState>({})
+
+ // Reset state when modal opens/closes
+ useEffect(() => {
+ if (!isOpen) {
+ setStep('url')
+ setGithubUrl('')
+ setBranch('')
+ setComposeFiles([])
+ setSelectedComposeFile(null)
+ setServices([])
+ setSelectedService(null)
+ setServiceName('')
+ setDisplayName('')
+ setDescription('')
+ setShadowHeader({
+ enabled: true,
+ header_name: 'X-Shadow-Service',
+ header_value: '',
+ route_path: '',
+ })
+ setEnvVars([])
+ setError(null)
+ }
+ }, [isOpen])
+
+ // Scan GitHub repository
+ const handleScanRepo = async () => {
+ if (!githubUrl.trim()) {
+ setError('Please enter a GitHub URL')
+ return
+ }
+
+ setLoading(true)
+ setError(null)
+
+ try {
+ const response = await githubImportApi.scan(githubUrl, branch || undefined)
+ const data = response.data
+
+ if (!data.success) {
+ setError(data.error || 'Failed to scan repository')
+ return
+ }
+
+ if (data.compose_files.length === 0) {
+ setError('No docker-compose files found in the repository')
+ return
+ }
+
+ setComposeFiles(data.compose_files)
+ setStep('compose')
+ } catch (err: any) {
+ setError(err.response?.data?.detail || 'Failed to scan repository')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // Parse selected compose file
+ const handleSelectComposeFile = async (file: DetectedComposeFile) => {
+ setSelectedComposeFile(file)
+ setLoading(true)
+ setError(null)
+
+ try {
+ const response = await githubImportApi.parse(githubUrl, file.path)
+ const data = response.data
+
+ if (!data.success) {
+ setError(data.error || 'Failed to parse compose file')
+ return
+ }
+
+ if (data.services.length === 0) {
+ setError('No services found in compose file')
+ return
+ }
+
+ setServices(data.services)
+ setStep('service')
+ } catch (err: any) {
+ setError(err.response?.data?.detail || 'Failed to parse compose file')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // Select service and prepare configuration
+ const handleSelectService = (service: ComposeServiceInfo) => {
+ setSelectedService(service)
+ setServiceName(service.name)
+ setDisplayName(service.name.charAt(0).toUpperCase() + service.name.slice(1).replace(/-/g, ' '))
+ setShadowHeader({
+ enabled: true,
+ header_name: 'X-Shadow-Service',
+ header_value: service.name,
+ route_path: `/${service.name}`,
+ })
+
+ // Initialize env vars from service
+ const envConfig: EnvVarConfigItem[] = service.environment.map((env) => ({
+ name: env.name,
+ source: env.has_default ? 'default' : 'literal',
+ value: env.default_value || '',
+ is_secret: env.name.toLowerCase().includes('key') ||
+ env.name.toLowerCase().includes('secret') ||
+ env.name.toLowerCase().includes('password') ||
+ env.name.toLowerCase().includes('token'),
+ }))
+ setEnvVars(envConfig)
+ setStep('config')
+ }
+
+ // Update environment variable
+ const updateEnvVar = (index: number, updates: Partial) => {
+ setEnvVars((prev) =>
+ prev.map((ev, i) => (i === index ? { ...ev, ...updates } : ev))
+ )
+ }
+
+ // Import the service
+ const handleImport = async () => {
+ if (!selectedComposeFile || !selectedService) {
+ setError('Please select a compose file and service')
+ return
+ }
+
+ setLoading(true)
+ setError(null)
+
+ try {
+ const response = await githubImportApi.register({
+ github_url: githubUrl,
+ compose_path: selectedComposeFile.path,
+ service_name: serviceName,
+ config: {
+ service_name: serviceName,
+ display_name: displayName,
+ description,
+ github_url: githubUrl,
+ compose_path: selectedComposeFile.path,
+ shadow_header: shadowHeader,
+ env_vars: envVars,
+ enabled: true,
+ },
+ })
+
+ const data = response.data
+
+ if (!data.success) {
+ setError(data.message || 'Failed to import service')
+ return
+ }
+
+ setStep('complete')
+ } catch (err: any) {
+ setError(err.response?.data?.detail || 'Failed to import service')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // Handle completion
+ const handleComplete = () => {
+ onServiceImported()
+ onClose()
+ }
+
+ if (!isOpen) return null
+
+ const renderStepIndicator = () => {
+ const steps: { key: WizardStep; label: string }[] = [
+ { key: 'url', label: 'URL' },
+ { key: 'compose', label: 'Compose' },
+ { key: 'service', label: 'Service' },
+ { key: 'config', label: 'Configure' },
+ { key: 'complete', label: 'Done' },
+ ]
+
+ const currentIndex = steps.findIndex((s) => s.key === step)
+
+ return (
+
+ {steps.map((s, index) => (
+
+
+ {index < currentIndex ? : index + 1}
+
+ {index < steps.length - 1 && (
+
+ )}
+
+ ))}
+
+ )
+ }
+
+ const renderUrlStep = () => (
+
+
+
+ GitHub Repository URL
+
+
setGithubUrl(e.target.value)}
+ placeholder="https://github.com/owner/repo"
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
+ />
+
+ Supports repository URLs, specific branches, or direct paths to compose files
+
+
+
+
+
+ Branch (optional)
+
+ setBranch(e.target.value)}
+ placeholder="main"
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
+ />
+
+
+ )
+
+ const renderComposeStep = () => (
+
+
+ Found {composeFiles.length} docker-compose file(s). Select one to continue:
+
+
+ {composeFiles.map((file) => (
+
handleSelectComposeFile(file)}
+ className={`w-full p-3 rounded-lg border-2 text-left transition-all flex items-center gap-3 ${
+ selectedComposeFile?.path === file.path
+ ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
+ : 'border-gray-200 dark:border-gray-700 hover:border-primary-300'
+ }`}
+ >
+
+
+
+ {file.name}
+
+
+ {file.path}
+
+
+
+
+ ))}
+
+
+ )
+
+ const renderServiceStep = () => (
+
+
+ Found {services.length} service(s) in the compose file. Select one to import:
+
+
+ {services.map((service) => (
+
handleSelectService(service)}
+ className={`w-full p-3 rounded-lg border-2 text-left transition-all ${
+ selectedService?.name === service.name
+ ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
+ : 'border-gray-200 dark:border-gray-700 hover:border-primary-300'
+ }`}
+ >
+
+
+
+
+ {service.name}
+
+ {service.image && (
+
+ Image: {service.image}
+
+ )}
+ {service.ports.length > 0 && (
+
+ Ports: {service.ports.map((p) => p.container).join(', ')}
+
+ )}
+ {service.environment.filter((e) => e.is_required).length > 0 && (
+
+ {service.environment.filter((e) => e.is_required).length} required env vars
+
+ )}
+
+
+
+
+ ))}
+
+
+ )
+
+ const renderConfigStep = () => (
+
+ {/* Basic Info */}
+
+
+
+ Basic Information
+
+
+
+
+ Description
+
+
+
+
+ {/* Shadow Header Configuration */}
+
+
+
+ Shadow Header Configuration
+
+
+
+ setShadowHeader({ ...shadowHeader, enabled: e.target.checked })
+ }
+ className="w-4 h-4 text-primary-600 rounded border-gray-300 focus:ring-primary-500"
+ />
+
+ Enable shadow header routing
+
+
+ {shadowHeader.enabled && (
+
+ )}
+
+
+ {/* Environment Variables */}
+ {envVars.length > 0 && (
+
+
+
+ Environment Variables ({envVars.length})
+
+
+ {envVars.map((env, index) => {
+ const originalEnv = selectedService?.environment.find(
+ (e) => e.name === env.name
+ )
+ return (
+
+
+
+ {env.name}
+
+
+ {originalEnv?.is_required && (
+ Required
+ )}
+ {env.is_secret && (
+ Secret
+ )}
+
+
+
+
+ updateEnvVar(index, {
+ source: e.target.value as 'literal' | 'setting' | 'default',
+ })
+ }
+ className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
+ >
+ Set Value
+ Use Default
+ From Settings
+
+ {env.source === 'literal' && (
+
+ updateEnvVar(index, { value: e.target.value })}
+ placeholder={originalEnv?.default_value || 'Enter value'}
+ className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
+ />
+ {env.is_secret && (
+
+ setShowSecrets((prev) => ({
+ ...prev,
+ [env.name]: !prev[env.name],
+ }))
+ }
+ className="p-1 text-gray-400 hover:text-gray-600"
+ >
+ {showSecrets[env.name] ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ )}
+ {env.source === 'default' && originalEnv?.default_value && (
+
+ Default: {originalEnv.default_value}
+
+ )}
+ {env.source === 'setting' && (
+
updateEnvVar(index, { setting_path: e.target.value })}
+ placeholder="api_keys.my_key"
+ className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
+ />
+ )}
+
+
+ )
+ })}
+
+
+ )}
+
+ )
+
+ const renderCompleteStep = () => (
+
+
+
+
+
+ Service Imported Successfully
+
+
+ The service "{displayName || serviceName}" has been imported and is ready to use.
+
+
+ You can now start the service from the Services page.
+
+
+ )
+
+ const renderCurrentStep = () => {
+ switch (step) {
+ case 'url':
+ return renderUrlStep()
+ case 'compose':
+ return renderComposeStep()
+ case 'service':
+ return renderServiceStep()
+ case 'config':
+ return renderConfigStep()
+ case 'complete':
+ return renderCompleteStep()
+ default:
+ return null
+ }
+ }
+
+ const canGoBack = step !== 'url' && step !== 'complete'
+ const canGoNext = step !== 'complete'
+
+ const handleBack = () => {
+ switch (step) {
+ case 'compose':
+ setStep('url')
+ break
+ case 'service':
+ setStep('compose')
+ break
+ case 'config':
+ setStep('service')
+ break
+ }
+ }
+
+ const handleNext = () => {
+ switch (step) {
+ case 'url':
+ handleScanRepo()
+ break
+ case 'config':
+ handleImport()
+ break
+ }
+ }
+
+ const getNextButtonLabel = () => {
+ switch (step) {
+ case 'url':
+ return 'Scan Repository'
+ case 'config':
+ return 'Import Service'
+ default:
+ return 'Next'
+ }
+ }
+
+ return (
+
+
e.stopPropagation()}
+ >
+ {/* Header */}
+
+
+
+
+ Import from GitHub
+
+
+
+
+
+
+
+ {/* Step indicator */}
+
+ {renderStepIndicator()}
+
+
+ {/* Content */}
+
+ {error && (
+
+ )}
+ {renderCurrentStep()}
+
+
+ {/* Footer */}
+
+
+ {canGoBack && (
+
+
+ Back
+
+ )}
+
+
+
+ {step === 'complete' ? 'Close' : 'Cancel'}
+
+ {canGoNext && step !== 'compose' && step !== 'service' && (
+
+ {loading ? (
+ <>
+
+ Loading...
+ >
+ ) : step === 'complete' ? (
+ <>
+
+ Done
+ >
+ ) : (
+ <>
+ {getNextButtonLabel()}
+
+ >
+ )}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/ushadow/frontend/src/pages/ServicesPage.tsx b/ushadow/frontend/src/pages/ServicesPage.tsx
index f526892b..0c0320a8 100644
--- a/ushadow/frontend/src/pages/ServicesPage.tsx
+++ b/ushadow/frontend/src/pages/ServicesPage.tsx
@@ -18,7 +18,8 @@ import {
Plus,
Package,
Trash2,
- BookOpen
+ BookOpen,
+ Github
} from 'lucide-react'
import {
settingsApi,
@@ -33,6 +34,7 @@ import {
import ConfirmDialog from '../components/ConfirmDialog'
import Modal from '../components/Modal'
import { PortConflictDialog } from '../components/services'
+import ImportFromGitHubModal from '../components/ImportFromGitHubModal'
export default function ServicesPage() {
// Compose services state
@@ -96,6 +98,9 @@ export default function ServicesPage() {
const [catalogLoading, setCatalogLoading] = useState(false)
const [installingService, setInstallingService] = useState(null)
+ // GitHub import modal state
+ const [showGitHubImport, setShowGitHubImport] = useState(false)
+
// Load initial data
useEffect(() => {
loadData()
@@ -624,6 +629,14 @@ export default function ServicesPage() {
+ setShowGitHubImport(true)}
+ data-testid="import-github-button"
+ className="btn-secondary flex items-center gap-2"
+ >
+
+ Import from GitHub
+
+
+ {/* GitHub Import Modal */}
+ setShowGitHubImport(false)}
+ onServiceImported={() => {
+ setShowGitHubImport(false)
+ loadData()
+ }}
+ />
)
}
diff --git a/ushadow/frontend/src/services/api.ts b/ushadow/frontend/src/services/api.ts
index 3b3b1553..3e28a348 100644
--- a/ushadow/frontend/src/services/api.ts
+++ b/ushadow/frontend/src/services/api.ts
@@ -1444,3 +1444,145 @@ export const integrationApi = {
disableAutoSync: (instanceId: string) =>
api.post<{ success: boolean; message: string }>(`/api/instances/${instanceId}/sync/disable`),
}
+
+// =============================================================================
+// GitHub Import API - Import docker-compose from GitHub repositories
+// =============================================================================
+
+export interface GitHubUrlInfo {
+ owner: string
+ repo: string
+ branch: string
+ path: string
+}
+
+export interface DetectedComposeFile {
+ path: string
+ name: string
+ download_url: string
+ size: number
+}
+
+export interface GitHubScanResponse {
+ success: boolean
+ github_info?: GitHubUrlInfo
+ compose_files: DetectedComposeFile[]
+ message?: string
+ error?: string
+}
+
+export interface ComposeEnvVarInfo {
+ name: string
+ has_default: boolean
+ default_value?: string
+ is_required: boolean
+ description?: string
+}
+
+export interface ComposeServiceInfo {
+ name: string
+ image?: string
+ ports: Array<{ host?: string; container?: string }>
+ environment: ComposeEnvVarInfo[]
+ depends_on: string[]
+ volumes: string[]
+ networks: string[]
+ command?: string
+ healthcheck?: Record
+}
+
+export interface ComposeParseResponse {
+ success: boolean
+ compose_path: string
+ services: ComposeServiceInfo[]
+ networks: string[]
+ volumes: string[]
+ message?: string
+ error?: string
+}
+
+export interface ShadowHeaderConfig {
+ enabled: boolean
+ header_name: string
+ header_value?: string
+ route_path?: string
+}
+
+export interface EnvVarConfigItem {
+ name: string
+ source: 'literal' | 'setting' | 'default'
+ value?: string
+ setting_path?: string
+ is_secret: boolean
+}
+
+export interface ImportedServiceConfig {
+ service_name: string
+ display_name?: string
+ description?: string
+ github_url: string
+ compose_path: string
+ shadow_header: ShadowHeaderConfig
+ env_vars: EnvVarConfigItem[]
+ enabled: boolean
+}
+
+export interface ImportServiceRequest {
+ github_url: string
+ compose_path: string
+ service_name: string
+ config: ImportedServiceConfig
+}
+
+export interface ImportServiceResponse {
+ success: boolean
+ service_id?: string
+ service_name?: string
+ message: string
+ compose_file_path?: string
+}
+
+export interface ImportedService {
+ id: string
+ github_url: string
+ compose_path: string
+ compose_file: string
+ service_name: string
+ display_name?: string
+ description?: string
+ shadow_header: ShadowHeaderConfig
+ env_vars: EnvVarConfigItem[]
+ enabled: boolean
+}
+
+export const githubImportApi = {
+ /** Scan a GitHub repository for docker-compose files */
+ scan: (github_url: string, branch?: string, compose_path?: string) =>
+ api.post('/api/github-import/scan', {
+ github_url,
+ branch,
+ compose_path
+ }),
+
+ /** Parse a docker-compose file and extract service/env information */
+ parse: (github_url: string, compose_path: string) =>
+ api.post('/api/github-import/parse', null, {
+ params: { github_url, compose_path }
+ }),
+
+ /** Register an imported service */
+ register: (request: ImportServiceRequest) =>
+ api.post('/api/github-import/register', request),
+
+ /** List all imported services */
+ listImported: () =>
+ api.get('/api/github-import/imported'),
+
+ /** Delete an imported service */
+ deleteImported: (serviceId: string) =>
+ api.delete<{ success: boolean; message: string }>(`/api/github-import/imported/${serviceId}`),
+
+ /** Update configuration for an imported service */
+ updateConfig: (serviceId: string, config: ImportedServiceConfig) =>
+ api.put<{ success: boolean; message: string }>(`/api/github-import/imported/${serviceId}/config`, config),
+}
From c4d31c374e46e421376d3d09a6bb6850f01149e6 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 16 Jan 2026 22:18:53 +0000
Subject: [PATCH 02/11] feat: Add Agent Zero docker-compose configuration
Add compose file for Agent Zero AI agent framework with:
- Web UI on configurable port (default 50080)
- Support for multiple LLM providers (OpenAI, Anthropic, Google, Groq, etc.)
- Ollama support for local models
- Persistent data volume for memory, knowledge, and settings
- Health check and auto-restart
- Ushadow metadata for service discovery
---
compose/agent-zero-compose.yaml | 77 +++++++++++++++++++++++++++++++++
1 file changed, 77 insertions(+)
create mode 100644 compose/agent-zero-compose.yaml
diff --git a/compose/agent-zero-compose.yaml b/compose/agent-zero-compose.yaml
new file mode 100644
index 00000000..ddea5718
--- /dev/null
+++ b/compose/agent-zero-compose.yaml
@@ -0,0 +1,77 @@
+# Agent Zero - Autonomous AI Agent Framework
+# https://github.com/agent0ai/agent-zero
+#
+# Agent Zero is an AI framework for personal assistants with features like:
+# - Multi-agent cooperation
+# - Computer tool usage
+# - Memory and knowledge retrieval
+# - Custom instruments (tools)
+
+# =============================================================================
+# USHADOW METADATA (ignored by Docker, read by ushadow backend)
+# =============================================================================
+x-ushadow:
+ agent-zero:
+ display_name: "Agent Zero"
+ description: "Autonomous AI agent framework with memory, knowledge, and tool usage capabilities"
+ requires: [llm]
+ optional: []
+ route_path: /agent-zero
+
+services:
+ agent-zero:
+ image: agent0ai/agent-zero:latest
+ container_name: ${COMPOSE_PROJECT_NAME:-ushadow}-agent-zero
+ ports:
+ - "${AGENT_ZERO_PORT:-50080}:80"
+ environment:
+ # LLM Configuration - Agent Zero supports multiple providers
+ # OpenAI
+ - OPENAI_API_KEY=${OPENAI_API_KEY:-}
+
+ # Anthropic
+ - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
+
+ # Google
+ - GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
+
+ # Groq
+ - GROQ_API_KEY=${GROQ_API_KEY:-}
+
+ # OpenRouter
+ - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
+
+ # Ollama (for local models)
+ - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
+
+ # Perplexity (for web search)
+ - PERPLEXITY_API_KEY=${PERPLEXITY_API_KEY:-}
+
+ volumes:
+ # Main data directory - stores memory, knowledge, instruments, prompts, settings
+ - agent_zero_data:/a0
+
+ networks:
+ - infra-network
+
+ # Enable access to host network for Ollama and other local services
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:80/"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 30s
+
+ restart: unless-stopped
+
+volumes:
+ agent_zero_data:
+ name: ${COMPOSE_PROJECT_NAME:-ushadow}_agent_zero_data
+
+networks:
+ infra-network:
+ external: true
+ name: ${COMPOSE_PROJECT_NAME:-ushadow}_infra-network
From e80835114aec29353c86c7950c8bc109e9f36aba Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 17 Jan 2026 13:47:22 +0000
Subject: [PATCH 03/11] feat: Add Docker Hub import support alongside GitHub
Extend the import functionality to support both GitHub and Docker Hub:
Backend:
- Add DockerHubImageInfo model with URL parsing
- Add Docker Hub API integration for image info and tags
- Add unified scan endpoint that auto-detects source type
- Add Docker Hub register endpoint that generates compose files
- Support parsing Docker Hub URLs and direct image references
Frontend:
- Update ImportFromGitHubModal to support both sources
- Add source type selector (GitHub/Docker Hub tabs)
- Auto-detect source type from URL input
- Add port and volume configuration for Docker Hub imports
- Add tag selection from available Docker Hub tags
- Streamlined wizard flow for Docker Hub (2 steps vs 4)
Users can now import services from:
- GitHub repositories with docker-compose files
- Docker Hub URLs (e.g., https://hub.docker.com/r/fishaudio/fish-speech)
- Direct image references (e.g., fishaudio/fish-speech)
---
ushadow/backend/src/models/github_import.py | 210 +++++-
ushadow/backend/src/routers/github_import.py | 486 +++++++++++++-
.../src/components/ImportFromGitHubModal.tsx | 621 +++++++++++++-----
ushadow/frontend/src/services/api.ts | 106 ++-
4 files changed, 1261 insertions(+), 162 deletions(-)
diff --git a/ushadow/backend/src/models/github_import.py b/ushadow/backend/src/models/github_import.py
index 7b69eda5..c80b6a83 100644
--- a/ushadow/backend/src/models/github_import.py
+++ b/ushadow/backend/src/models/github_import.py
@@ -1,15 +1,50 @@
"""
-Models for GitHub Docker Compose Import functionality.
+Models for Docker Import functionality.
-Supports importing docker-compose files from GitHub repositories
-and configuring shadow headers and environment variables.
+Supports importing docker-compose files from:
+- GitHub repositories
+- Docker Hub images
+
+And configuring shadow headers and environment variables.
"""
-from typing import Dict, List, Optional, Any
+from typing import Dict, List, Optional, Any, Literal
from pydantic import BaseModel, Field, field_validator
import re
+# =============================================================================
+# Import Source Types
+# =============================================================================
+
+ImportSourceType = Literal["github", "dockerhub"]
+
+
+class DockerHubImageInfo(BaseModel):
+ """Parsed Docker Hub URL information."""
+ namespace: str # e.g., "fishaudio" or "library" for official images
+ repository: str # e.g., "fish-speech"
+ tag: str = "latest"
+
+ @property
+ def full_image_name(self) -> str:
+ """Get full Docker image name."""
+ if self.namespace == "library":
+ # Official images don't need namespace prefix
+ return f"{self.repository}:{self.tag}"
+ return f"{self.namespace}/{self.repository}:{self.tag}"
+
+ @property
+ def api_url(self) -> str:
+ """Get Docker Hub API URL for image info."""
+ return f"https://hub.docker.com/v2/repositories/{self.namespace}/{self.repository}"
+
+ @property
+ def tags_url(self) -> str:
+ """Get Docker Hub API URL for tags."""
+ return f"https://hub.docker.com/v2/repositories/{self.namespace}/{self.repository}/tags"
+
+
class GitHubUrlInfo(BaseModel):
"""Parsed GitHub URL information."""
owner: str
@@ -96,17 +131,44 @@ class EnvVarConfigItem(BaseModel):
is_secret: bool = False
+class PortConfig(BaseModel):
+ """Configuration for a port mapping."""
+ host_port: int
+ container_port: int
+ protocol: str = "tcp"
+
+
+class VolumeConfig(BaseModel):
+ """Configuration for a volume mount."""
+ name: str
+ container_path: str
+ is_named_volume: bool = True # True for named volumes, False for bind mounts
+
+
class ImportedServiceConfig(BaseModel):
"""Full configuration for an imported service."""
service_name: str
display_name: Optional[str] = None
description: Optional[str] = None
- github_url: str
- compose_path: str
+ # Source can be GitHub or Docker Hub
+ source_type: ImportSourceType = "github"
+ source_url: str # GitHub URL or Docker Hub URL
+ # For GitHub imports
+ compose_path: Optional[str] = None
+ # For Docker Hub imports
+ docker_image: Optional[str] = None
+ ports: List[PortConfig] = Field(default_factory=list)
+ volumes: List[VolumeConfig] = Field(default_factory=list)
+ # Common config
shadow_header: ShadowHeaderConfig = Field(default_factory=ShadowHeaderConfig)
env_vars: List[EnvVarConfigItem] = Field(default_factory=list)
enabled: bool = True
+ # Backwards compatibility
+ @property
+ def github_url(self) -> str:
+ return self.source_url
+
class GitHubScanResponse(BaseModel):
"""Response from scanning a GitHub repository."""
@@ -204,3 +266,139 @@ def parse_github_url(url: str) -> GitHubUrlInfo:
)
raise ValueError(f"Could not parse GitHub URL: {url}")
+
+
+def parse_dockerhub_url(url: str) -> DockerHubImageInfo:
+ """
+ Parse a Docker Hub URL into its components.
+
+ Supports:
+ - https://hub.docker.com/r/namespace/repository
+ - https://hub.docker.com/r/namespace/repository/tags
+ - https://hub.docker.com/_/official-image (official images)
+ - namespace/repository (direct image reference)
+ - namespace/repository:tag
+ """
+ url = url.strip()
+
+ # Handle hub.docker.com URLs
+ dockerhub_match = re.match(
+ r'https?://hub\.docker\.com/r/([^/]+)/([^/]+)(?:/tags)?/?$',
+ url
+ )
+ if dockerhub_match:
+ namespace, repository = dockerhub_match.groups()
+ return DockerHubImageInfo(
+ namespace=namespace,
+ repository=repository,
+ tag="latest"
+ )
+
+ # Handle official images: https://hub.docker.com/_/image-name
+ official_match = re.match(
+ r'https?://hub\.docker\.com/_/([^/]+)/?$',
+ url
+ )
+ if official_match:
+ repository = official_match.group(1)
+ return DockerHubImageInfo(
+ namespace="library",
+ repository=repository,
+ tag="latest"
+ )
+
+ # Handle direct image reference: namespace/repository:tag
+ direct_match = re.match(
+ r'^([^/:]+)/([^/:]+)(?::([^/]+))?$',
+ url
+ )
+ if direct_match:
+ namespace, repository, tag = direct_match.groups()
+ return DockerHubImageInfo(
+ namespace=namespace,
+ repository=repository,
+ tag=tag or "latest"
+ )
+
+ # Handle official image direct reference: image-name:tag
+ official_direct_match = re.match(
+ r'^([^/:]+)(?::([^/]+))?$',
+ url
+ )
+ if official_direct_match:
+ repository, tag = official_direct_match.groups()
+ return DockerHubImageInfo(
+ namespace="library",
+ repository=repository,
+ tag=tag or "latest"
+ )
+
+ raise ValueError(f"Could not parse Docker Hub URL: {url}")
+
+
+def detect_import_source(url: str) -> ImportSourceType:
+ """Detect whether a URL is GitHub or Docker Hub."""
+ url = url.strip().lower()
+ if 'github.com' in url or 'raw.githubusercontent.com' in url:
+ return "github"
+ if 'hub.docker.com' in url or 'docker.io' in url:
+ return "dockerhub"
+ # Check if it looks like a docker image reference (namespace/repo or repo:tag)
+ if re.match(r'^[a-z0-9_-]+/[a-z0-9_-]+(?::[a-z0-9._-]+)?$', url):
+ return "dockerhub"
+ if re.match(r'^[a-z0-9_-]+(?::[a-z0-9._-]+)?$', url):
+ return "dockerhub"
+ # Default to github for URLs
+ if url.startswith('http'):
+ return "github"
+ return "dockerhub"
+
+
+class DockerHubScanResponse(BaseModel):
+ """Response from scanning a Docker Hub image."""
+ success: bool
+ image_info: Optional[DockerHubImageInfo] = None
+ description: Optional[str] = None
+ stars: int = 0
+ pulls: int = 0
+ available_tags: List[str] = Field(default_factory=list)
+ message: Optional[str] = None
+ error: Optional[str] = None
+
+
+class DockerHubImportRequest(BaseModel):
+ """Request to import from Docker Hub."""
+ dockerhub_url: str = Field(..., description="Docker Hub URL or image reference")
+ tag: Optional[str] = Field(None, description="Image tag (defaults to latest)")
+
+ @field_validator('dockerhub_url')
+ @classmethod
+ def validate_dockerhub_url(cls, v: str) -> str:
+ """Validate the Docker Hub URL or image reference."""
+ if not v:
+ raise ValueError("Docker Hub URL or image reference is required")
+ return v
+
+
+class UnifiedImportRequest(BaseModel):
+ """Unified request for importing from any supported source."""
+ url: str = Field(..., description="GitHub URL, Docker Hub URL, or image reference")
+ branch: Optional[str] = Field(None, description="Branch for GitHub (defaults to main)")
+ tag: Optional[str] = Field(None, description="Tag for Docker Hub (defaults to latest)")
+ compose_path: Optional[str] = Field(None, description="Path to docker-compose file if not auto-detected")
+
+
+class UnifiedScanResponse(BaseModel):
+ """Unified response from scanning any import source."""
+ success: bool
+ source_type: ImportSourceType
+ # GitHub-specific
+ github_info: Optional[GitHubUrlInfo] = None
+ compose_files: List[DetectedComposeFile] = Field(default_factory=list)
+ # Docker Hub-specific
+ dockerhub_info: Optional[DockerHubImageInfo] = None
+ available_tags: List[str] = Field(default_factory=list)
+ image_description: Optional[str] = None
+ # Common
+ message: Optional[str] = None
+ error: Optional[str] = None
diff --git a/ushadow/backend/src/routers/github_import.py b/ushadow/backend/src/routers/github_import.py
index c4c53ab7..026f8f8d 100644
--- a/ushadow/backend/src/routers/github_import.py
+++ b/ushadow/backend/src/routers/github_import.py
@@ -1,9 +1,11 @@
"""
-GitHub Docker Compose Import Router.
+Docker Import Router - GitHub and Docker Hub.
Provides endpoints for:
- Scanning GitHub repositories for docker-compose files
+- Scanning Docker Hub for image information
- Parsing compose files and extracting service/env info
+- Creating compose files from Docker Hub images
- Importing and registering services with shadow header configuration
"""
@@ -30,7 +32,17 @@
ImportedServiceConfig,
ShadowHeaderConfig,
EnvVarConfigItem,
+ PortConfig,
+ VolumeConfig,
parse_github_url,
+ # Docker Hub imports
+ DockerHubImageInfo,
+ DockerHubScanResponse,
+ DockerHubImportRequest,
+ UnifiedImportRequest,
+ UnifiedScanResponse,
+ parse_dockerhub_url,
+ detect_import_source,
)
from src.services.auth import get_current_user
from src.models.user import User
@@ -232,6 +244,164 @@ def extract_services_from_compose(data: Dict[str, Any]) -> List[ComposeServiceIn
return services
+# =============================================================================
+# Docker Hub Functions
+# =============================================================================
+
+async def fetch_dockerhub_info(image_info: DockerHubImageInfo) -> Dict[str, Any]:
+ """Fetch image information from Docker Hub API."""
+ headers = {
+ 'Accept': 'application/json',
+ 'User-Agent': 'Ushadow-Import'
+ }
+
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ # Fetch repository info
+ response = await client.get(image_info.api_url, headers=headers)
+ if response.status_code == 404:
+ raise HTTPException(status_code=404, detail="Docker Hub image not found")
+ response.raise_for_status()
+ return response.json()
+
+
+async def fetch_dockerhub_tags(image_info: DockerHubImageInfo, limit: int = 25) -> List[str]:
+ """Fetch available tags for a Docker Hub image."""
+ headers = {
+ 'Accept': 'application/json',
+ 'User-Agent': 'Ushadow-Import'
+ }
+
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ url = f"{image_info.tags_url}?page_size={limit}"
+ response = await client.get(url, headers=headers)
+ if response.status_code != 200:
+ return ["latest"]
+
+ data = response.json()
+ tags = [tag.get('name', 'latest') for tag in data.get('results', [])]
+ return tags if tags else ["latest"]
+
+
+def generate_compose_from_dockerhub(
+ image_info: DockerHubImageInfo,
+ service_name: str,
+ ports: List[PortConfig],
+ volumes: List[VolumeConfig],
+ env_vars: List[EnvVarConfigItem],
+ shadow_header: ShadowHeaderConfig,
+ display_name: Optional[str] = None,
+ description: Optional[str] = None,
+) -> str:
+ """Generate a docker-compose.yaml from Docker Hub image info."""
+ yaml = YAML()
+ yaml.default_flow_style = False
+
+ # Build compose structure
+ compose_data = {
+ 'x-ushadow': {
+ service_name: {
+ 'display_name': display_name or service_name.replace('-', ' ').title(),
+ 'description': description or f"Imported from Docker Hub: {image_info.full_image_name}",
+ 'requires': ['llm'], # Default capability requirement
+ 'optional': [],
+ 'dockerhub_source': {
+ 'namespace': image_info.namespace,
+ 'repository': image_info.repository,
+ 'tag': image_info.tag,
+ 'full_image': image_info.full_image_name
+ }
+ }
+ },
+ 'services': {
+ service_name: {
+ 'image': image_info.full_image_name,
+ 'container_name': f'${{COMPOSE_PROJECT_NAME:-ushadow}}-{service_name}',
+ }
+ },
+ 'networks': {
+ 'infra-network': {
+ 'external': True,
+ 'name': '${COMPOSE_PROJECT_NAME:-ushadow}_infra-network'
+ }
+ }
+ }
+
+ service_config = compose_data['services'][service_name]
+
+ # Add shadow header config
+ if shadow_header.enabled:
+ compose_data['x-ushadow'][service_name]['shadow_header'] = {
+ 'enabled': True,
+ 'header_name': shadow_header.header_name,
+ 'header_value': shadow_header.header_value or service_name
+ }
+ if shadow_header.route_path:
+ compose_data['x-ushadow'][service_name]['route_path'] = shadow_header.route_path
+
+ # Add ports
+ if ports:
+ service_config['ports'] = [
+ f"${{{service_name.upper().replace('-', '_')}_PORT:-{p.host_port}}}:{p.container_port}"
+ for p in ports
+ ]
+
+ # Add environment variables
+ if env_vars:
+ env_list = []
+ for ev in env_vars:
+ if ev.source == 'literal' and ev.value:
+ env_list.append(f"{ev.name}=${{{ev.name}:-{ev.value}}}")
+ else:
+ env_list.append(f"{ev.name}=${{{ev.name}:-}}")
+ if env_list:
+ service_config['environment'] = env_list
+
+ # Add volumes
+ if volumes:
+ service_config['volumes'] = []
+ volume_definitions = {}
+ for v in volumes:
+ if v.is_named_volume:
+ volume_name = f"{service_name}_{v.name}"
+ service_config['volumes'].append(f"{volume_name}:{v.container_path}")
+ volume_definitions[volume_name] = {
+ 'name': f'${{COMPOSE_PROJECT_NAME:-ushadow}}_{volume_name}'
+ }
+ else:
+ service_config['volumes'].append(f"{v.name}:{v.container_path}")
+
+ if volume_definitions:
+ compose_data['volumes'] = volume_definitions
+
+ # Add network
+ service_config['networks'] = ['infra-network']
+
+ # Add extra_hosts for host.docker.internal
+ service_config['extra_hosts'] = ['host.docker.internal:host-gateway']
+
+ # Add healthcheck placeholder
+ service_config['healthcheck'] = {
+ 'test': ['CMD', 'echo', 'ok'],
+ 'interval': '30s',
+ 'timeout': '10s',
+ 'retries': 3,
+ 'start_period': '30s'
+ }
+
+ # Add restart policy
+ service_config['restart'] = 'unless-stopped'
+
+ # Serialize to YAML
+ from io import StringIO
+ output = StringIO()
+ yaml.dump(compose_data, output)
+ return output.getvalue()
+
+
+# =============================================================================
+# GitHub Endpoints
+# =============================================================================
+
@router.post("/scan", response_model=GitHubScanResponse)
async def scan_github_repo(
request: GitHubImportRequest,
@@ -610,3 +780,317 @@ async def update_imported_service_config(
except Exception as e:
logger.error(f"Error updating imported service config: {e}")
raise HTTPException(status_code=500, detail=str(e))
+
+
+# =============================================================================
+# Docker Hub Endpoints
+# =============================================================================
+
+@router.post("/dockerhub/scan", response_model=DockerHubScanResponse)
+async def scan_dockerhub_image(
+ request: DockerHubImportRequest,
+ current_user: User = Depends(get_current_user)
+) -> DockerHubScanResponse:
+ """
+ Scan a Docker Hub image for information.
+
+ Accepts a Docker Hub URL or image reference and returns
+ image details and available tags.
+ """
+ try:
+ image_info = parse_dockerhub_url(request.dockerhub_url)
+
+ # Override tag if specified
+ if request.tag:
+ image_info.tag = request.tag
+
+ logger.info(f"Scanning Docker Hub image: {image_info.full_image_name}")
+
+ # Fetch image info
+ try:
+ repo_info = await fetch_dockerhub_info(image_info)
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.warning(f"Could not fetch Docker Hub info: {e}")
+ repo_info = {}
+
+ # Fetch available tags
+ try:
+ tags = await fetch_dockerhub_tags(image_info)
+ except Exception as e:
+ logger.warning(f"Could not fetch tags: {e}")
+ tags = ["latest"]
+
+ return DockerHubScanResponse(
+ success=True,
+ image_info=image_info,
+ description=repo_info.get('description', ''),
+ stars=repo_info.get('star_count', 0),
+ pulls=repo_info.get('pull_count', 0),
+ available_tags=tags,
+ message=f"Found Docker Hub image: {image_info.full_image_name}"
+ )
+
+ except ValueError as e:
+ return DockerHubScanResponse(
+ success=False,
+ error=str(e)
+ )
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error scanning Docker Hub image: {e}")
+ return DockerHubScanResponse(
+ success=False,
+ error=str(e)
+ )
+
+
+@router.post("/dockerhub/register", response_model=ImportServiceResponse)
+async def register_dockerhub_service(
+ service_name: str,
+ dockerhub_url: str,
+ tag: Optional[str] = None,
+ display_name: Optional[str] = None,
+ description: Optional[str] = None,
+ ports: Optional[List[Dict[str, int]]] = None,
+ volumes: Optional[List[Dict[str, Any]]] = None,
+ env_vars: Optional[List[Dict[str, Any]]] = None,
+ shadow_header_enabled: bool = True,
+ shadow_header_name: str = "X-Shadow-Service",
+ shadow_header_value: Optional[str] = None,
+ route_path: Optional[str] = None,
+ current_user: User = Depends(get_current_user)
+) -> ImportServiceResponse:
+ """
+ Register a service from Docker Hub by generating a compose file.
+
+ Creates a docker-compose file for the specified Docker Hub image
+ with the provided configuration.
+ """
+ try:
+ image_info = parse_dockerhub_url(dockerhub_url)
+ if tag:
+ image_info.tag = tag
+
+ logger.info(f"Registering Docker Hub service: {service_name} from {image_info.full_image_name}")
+
+ # Parse port configs
+ port_configs = []
+ if ports:
+ for p in ports:
+ port_configs.append(PortConfig(
+ host_port=p.get('host_port', p.get('host', 8080)),
+ container_port=p.get('container_port', p.get('container', 8080)),
+ protocol=p.get('protocol', 'tcp')
+ ))
+
+ # Parse volume configs
+ volume_configs = []
+ if volumes:
+ for v in volumes:
+ volume_configs.append(VolumeConfig(
+ name=v.get('name', 'data'),
+ container_path=v.get('container_path', v.get('path', '/data')),
+ is_named_volume=v.get('is_named_volume', True)
+ ))
+
+ # Parse env var configs
+ env_var_configs = []
+ if env_vars:
+ for ev in env_vars:
+ env_var_configs.append(EnvVarConfigItem(
+ name=ev.get('name', ''),
+ source=ev.get('source', 'literal'),
+ value=ev.get('value'),
+ setting_path=ev.get('setting_path'),
+ is_secret=ev.get('is_secret', False)
+ ))
+
+ # Build shadow header config
+ shadow_header = ShadowHeaderConfig(
+ enabled=shadow_header_enabled,
+ header_name=shadow_header_name,
+ header_value=shadow_header_value or service_name,
+ route_path=route_path or f"/{service_name}"
+ )
+
+ # Generate compose file content
+ compose_content = generate_compose_from_dockerhub(
+ image_info=image_info,
+ service_name=service_name,
+ ports=port_configs,
+ volumes=volume_configs,
+ env_vars=env_var_configs,
+ shadow_header=shadow_header,
+ display_name=display_name,
+ description=description
+ )
+
+ # Ensure compose directory exists
+ compose_dir = Path('/compose')
+ if not compose_dir.exists():
+ compose_dir = Path('compose')
+ compose_dir.mkdir(parents=True, exist_ok=True)
+
+ # Generate filename
+ safe_name = re.sub(r'[^a-zA-Z0-9_-]', '-', service_name)
+ compose_filename = f"{safe_name}-compose.yaml"
+ compose_path = compose_dir / compose_filename
+
+ # Write compose file
+ with open(compose_path, 'w') as f:
+ f.write(compose_content)
+
+ # Write env file if we have literal env vars
+ env_file_content = []
+ for ev in env_var_configs:
+ if ev.source == "literal" and ev.value:
+ env_file_content.append(f"{ev.name}={ev.value}")
+
+ if env_file_content:
+ env_file_path = compose_dir / f"{safe_name}.env"
+ with open(env_file_path, 'w') as f:
+ f.write('\n'.join(env_file_content) + '\n')
+
+ # Save service configuration to settings
+ from src.config.omegaconf_settings import get_settings_store
+ settings = get_settings_store()
+
+ service_config_key = f"imported_services.{safe_name}"
+ await settings.update({
+ service_config_key: {
+ 'source_type': 'dockerhub',
+ 'source_url': dockerhub_url,
+ 'docker_image': image_info.full_image_name,
+ 'compose_file': str(compose_path),
+ 'service_name': service_name,
+ 'display_name': display_name,
+ 'description': description,
+ 'shadow_header': shadow_header.model_dump(),
+ 'ports': [p.model_dump() for p in port_configs],
+ 'volumes': [v.model_dump() for v in volume_configs],
+ 'env_vars': [ev.model_dump() for ev in env_var_configs],
+ 'enabled': True
+ }
+ })
+
+ # Refresh compose registry to pick up new service
+ from src.services.compose_registry import get_compose_registry
+ registry = get_compose_registry()
+ registry.refresh()
+
+ logger.info(f"Imported service '{service_name}' from Docker Hub: {image_info.full_image_name}")
+
+ return ImportServiceResponse(
+ success=True,
+ service_id=f"{compose_filename.replace('.yaml', '')}:{service_name}",
+ service_name=service_name,
+ message=f"Successfully imported service '{service_name}' from Docker Hub",
+ compose_file_path=str(compose_path)
+ )
+
+ except ValueError as e:
+ return ImportServiceResponse(
+ success=False,
+ message=str(e)
+ )
+ except Exception as e:
+ logger.error(f"Error registering Docker Hub service: {e}")
+ return ImportServiceResponse(
+ success=False,
+ message=f"Failed to import service: {str(e)}"
+ )
+
+
+# =============================================================================
+# Unified Endpoints (Auto-detect source type)
+# =============================================================================
+
+@router.post("/unified/scan", response_model=UnifiedScanResponse)
+async def unified_scan(
+ request: UnifiedImportRequest,
+ current_user: User = Depends(get_current_user)
+) -> UnifiedScanResponse:
+ """
+ Scan any supported source (GitHub or Docker Hub).
+
+ Automatically detects the source type and returns appropriate information.
+ """
+ try:
+ source_type = detect_import_source(request.url)
+ logger.info(f"Unified scan: detected source type '{source_type}' for URL: {request.url}")
+
+ if source_type == "github":
+ # Handle GitHub
+ github_info = parse_github_url(request.url)
+ if request.branch:
+ github_info.branch = request.branch
+ if request.compose_path:
+ github_info.path = request.compose_path
+
+ # Fetch directory contents
+ contents = await fetch_github_contents(github_info.api_url)
+
+ # Handle single file case
+ if isinstance(contents, dict) and contents.get('type') == 'file':
+ compose_files = [DetectedComposeFile(
+ path=contents.get('path', ''),
+ name=contents.get('name', ''),
+ download_url=contents.get('download_url', ''),
+ size=contents.get('size', 0)
+ )]
+ else:
+ compose_files = detect_compose_files(contents if isinstance(contents, list) else [])
+
+ return UnifiedScanResponse(
+ success=True,
+ source_type="github",
+ github_info=github_info,
+ compose_files=compose_files,
+ message=f"Found {len(compose_files)} docker-compose file(s)" if compose_files else "No docker-compose files found"
+ )
+
+ else:
+ # Handle Docker Hub
+ image_info = parse_dockerhub_url(request.url)
+ if request.tag:
+ image_info.tag = request.tag
+
+ # Fetch image info
+ try:
+ repo_info = await fetch_dockerhub_info(image_info)
+ except Exception:
+ repo_info = {}
+
+ # Fetch available tags
+ try:
+ tags = await fetch_dockerhub_tags(image_info)
+ except Exception:
+ tags = ["latest"]
+
+ return UnifiedScanResponse(
+ success=True,
+ source_type="dockerhub",
+ dockerhub_info=image_info,
+ available_tags=tags,
+ image_description=repo_info.get('description', ''),
+ message=f"Found Docker Hub image: {image_info.full_image_name}"
+ )
+
+ except ValueError as e:
+ return UnifiedScanResponse(
+ success=False,
+ source_type="github",
+ error=str(e)
+ )
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error in unified scan: {e}")
+ return UnifiedScanResponse(
+ success=False,
+ source_type="github",
+ error=str(e)
+ )
diff --git a/ushadow/frontend/src/components/ImportFromGitHubModal.tsx b/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
index 6f2124ef..0d8bcf77 100644
--- a/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
+++ b/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
@@ -14,14 +14,19 @@ import {
Eye,
EyeOff,
RefreshCw,
+ Box,
+ Plus,
+ Trash2,
} from 'lucide-react'
import {
githubImportApi,
DetectedComposeFile,
ComposeServiceInfo,
- ComposeEnvVarInfo,
ShadowHeaderConfig,
EnvVarConfigItem,
+ DockerHubImageInfo,
+ PortConfig,
+ VolumeConfig,
} from '../services/api'
interface ImportFromGitHubModalProps {
@@ -30,6 +35,7 @@ interface ImportFromGitHubModalProps {
onServiceImported: () => void
}
+type ImportSource = 'github' | 'dockerhub'
type WizardStep = 'url' | 'compose' | 'service' | 'config' | 'complete'
export default function ImportFromGitHubModal({
@@ -42,15 +48,24 @@ export default function ImportFromGitHubModal({
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
+ // Source type
+ const [sourceType, setSourceType] = useState('github')
+
// URL step
- const [githubUrl, setGithubUrl] = useState('')
+ const [importUrl, setImportUrl] = useState('')
const [branch, setBranch] = useState('')
- // Compose file selection
+ // Docker Hub specific
+ const [dockerHubInfo, setDockerHubInfo] = useState(null)
+ const [availableTags, setAvailableTags] = useState([])
+ const [selectedTag, setSelectedTag] = useState('latest')
+ const [imageDescription, setImageDescription] = useState('')
+
+ // Compose file selection (GitHub)
const [composeFiles, setComposeFiles] = useState([])
const [selectedComposeFile, setSelectedComposeFile] = useState(null)
- // Service selection
+ // Service selection (GitHub)
const [services, setServices] = useState([])
const [selectedService, setSelectedService] = useState(null)
@@ -67,12 +82,21 @@ export default function ImportFromGitHubModal({
const [envVars, setEnvVars] = useState([])
const [showSecrets, setShowSecrets] = useState>({})
+ // Docker Hub specific config
+ const [ports, setPorts] = useState([])
+ const [volumes, setVolumes] = useState([])
+
// Reset state when modal opens/closes
useEffect(() => {
if (!isOpen) {
setStep('url')
- setGithubUrl('')
+ setSourceType('github')
+ setImportUrl('')
setBranch('')
+ setDockerHubInfo(null)
+ setAvailableTags([])
+ setSelectedTag('latest')
+ setImageDescription('')
setComposeFiles([])
setSelectedComposeFile(null)
setServices([])
@@ -87,14 +111,27 @@ export default function ImportFromGitHubModal({
route_path: '',
})
setEnvVars([])
+ setPorts([])
+ setVolumes([])
setError(null)
}
}, [isOpen])
- // Scan GitHub repository
- const handleScanRepo = async () => {
- if (!githubUrl.trim()) {
- setError('Please enter a GitHub URL')
+ // Auto-detect source type from URL
+ useEffect(() => {
+ const url = importUrl.toLowerCase().trim()
+ if (url.includes('github.com') || url.includes('githubusercontent.com')) {
+ setSourceType('github')
+ } else if (url.includes('hub.docker.com') || url.includes('docker.io') ||
+ (url && !url.startsWith('http') && url.includes('/'))) {
+ setSourceType('dockerhub')
+ }
+ }, [importUrl])
+
+ // Scan using unified endpoint
+ const handleScan = async () => {
+ if (!importUrl.trim()) {
+ setError('Please enter a URL or image reference')
return
}
@@ -102,36 +139,69 @@ export default function ImportFromGitHubModal({
setError(null)
try {
- const response = await githubImportApi.scan(githubUrl, branch || undefined)
+ const response = await githubImportApi.unifiedScan(
+ importUrl,
+ branch || undefined,
+ selectedTag !== 'latest' ? selectedTag : undefined
+ )
const data = response.data
if (!data.success) {
- setError(data.error || 'Failed to scan repository')
+ setError(data.error || 'Failed to scan')
return
}
- if (data.compose_files.length === 0) {
- setError('No docker-compose files found in the repository')
- return
- }
+ if (data.source_type === 'github') {
+ // GitHub flow
+ if (!data.compose_files || data.compose_files.length === 0) {
+ setError('No docker-compose files found in the repository')
+ return
+ }
+ setComposeFiles(data.compose_files)
+ setStep('compose')
+ } else {
+ // Docker Hub flow
+ if (data.dockerhub_info) {
+ setDockerHubInfo(data.dockerhub_info)
+ setAvailableTags(data.available_tags || ['latest'])
+ setImageDescription(data.image_description || '')
- setComposeFiles(data.compose_files)
- setStep('compose')
+ // Pre-populate service name from image
+ const name = data.dockerhub_info.repository.replace(/[^a-zA-Z0-9]/g, '-')
+ setServiceName(name)
+ setDisplayName(name.charAt(0).toUpperCase() + name.slice(1).replace(/-/g, ' '))
+ setDescription(data.image_description || `Docker Hub image: ${data.dockerhub_info.namespace}/${data.dockerhub_info.repository}`)
+ setShadowHeader({
+ enabled: true,
+ header_name: 'X-Shadow-Service',
+ header_value: name,
+ route_path: `/${name}`,
+ })
+
+ // Add default port and volume
+ setPorts([{ host_port: 8080, container_port: 8080, protocol: 'tcp' }])
+ setVolumes([{ name: 'data', container_path: '/data', is_named_volume: true }])
+
+ setStep('config')
+ } else {
+ setError('Could not parse Docker Hub image information')
+ }
+ }
} catch (err: any) {
- setError(err.response?.data?.detail || 'Failed to scan repository')
+ setError(err.response?.data?.detail || 'Failed to scan')
} finally {
setLoading(false)
}
}
- // Parse selected compose file
+ // Parse selected compose file (GitHub)
const handleSelectComposeFile = async (file: DetectedComposeFile) => {
setSelectedComposeFile(file)
setLoading(true)
setError(null)
try {
- const response = await githubImportApi.parse(githubUrl, file.path)
+ const response = await githubImportApi.parse(importUrl, file.path)
const data = response.data
if (!data.success) {
@@ -153,7 +223,7 @@ export default function ImportFromGitHubModal({
}
}
- // Select service and prepare configuration
+ // Select service and prepare configuration (GitHub)
const handleSelectService = (service: ComposeServiceInfo) => {
setSelectedService(service)
setServiceName(service.name)
@@ -186,38 +256,103 @@ export default function ImportFromGitHubModal({
)
}
+ // Add new env var
+ const addEnvVar = () => {
+ setEnvVars((prev) => [...prev, { name: '', source: 'literal', value: '', is_secret: false }])
+ }
+
+ // Remove env var
+ const removeEnvVar = (index: number) => {
+ setEnvVars((prev) => prev.filter((_, i) => i !== index))
+ }
+
+ // Add port
+ const addPort = () => {
+ setPorts((prev) => [...prev, { host_port: 8080, container_port: 8080, protocol: 'tcp' }])
+ }
+
+ // Remove port
+ const removePort = (index: number) => {
+ setPorts((prev) => prev.filter((_, i) => i !== index))
+ }
+
+ // Update port
+ const updatePort = (index: number, updates: Partial) => {
+ setPorts((prev) => prev.map((p, i) => (i === index ? { ...p, ...updates } : p)))
+ }
+
+ // Add volume
+ const addVolume = () => {
+ setVolumes((prev) => [...prev, { name: 'data', container_path: '/data', is_named_volume: true }])
+ }
+
+ // Remove volume
+ const removeVolume = (index: number) => {
+ setVolumes((prev) => prev.filter((_, i) => i !== index))
+ }
+
+ // Update volume
+ const updateVolume = (index: number, updates: Partial) => {
+ setVolumes((prev) => prev.map((v, i) => (i === index ? { ...v, ...updates } : v)))
+ }
+
// Import the service
const handleImport = async () => {
- if (!selectedComposeFile || !selectedService) {
- setError('Please select a compose file and service')
- return
- }
-
setLoading(true)
setError(null)
try {
- const response = await githubImportApi.register({
- github_url: githubUrl,
- compose_path: selectedComposeFile.path,
- service_name: serviceName,
- config: {
+ if (sourceType === 'dockerhub' || dockerHubInfo) {
+ // Docker Hub import
+ const response = await githubImportApi.registerDockerHub({
service_name: serviceName,
+ dockerhub_url: importUrl,
+ tag: selectedTag,
display_name: displayName,
description,
- github_url: githubUrl,
- compose_path: selectedComposeFile.path,
- shadow_header: shadowHeader,
+ ports,
+ volumes,
env_vars: envVars,
- enabled: true,
- },
- })
+ shadow_header_enabled: shadowHeader.enabled,
+ shadow_header_name: shadowHeader.header_name,
+ shadow_header_value: shadowHeader.header_value || serviceName,
+ route_path: shadowHeader.route_path || `/${serviceName}`,
+ })
- const data = response.data
+ const data = response.data
+ if (!data.success) {
+ setError(data.message || 'Failed to import service')
+ return
+ }
+ } else {
+ // GitHub import
+ if (!selectedComposeFile || !selectedService) {
+ setError('Please select a compose file and service')
+ return
+ }
- if (!data.success) {
- setError(data.message || 'Failed to import service')
- return
+ const response = await githubImportApi.register({
+ github_url: importUrl,
+ compose_path: selectedComposeFile.path,
+ service_name: serviceName,
+ config: {
+ service_name: serviceName,
+ display_name: displayName,
+ description,
+ source_type: 'github',
+ source_url: importUrl,
+ compose_path: selectedComposeFile.path,
+ shadow_header: shadowHeader,
+ env_vars: envVars,
+ enabled: true,
+ },
+ })
+
+ const data = response.data
+ if (!data.success) {
+ setError(data.message || 'Failed to import service')
+ return
+ }
}
setStep('complete')
@@ -236,15 +371,25 @@ export default function ImportFromGitHubModal({
if (!isOpen) return null
- const renderStepIndicator = () => {
- const steps: { key: WizardStep; label: string }[] = [
- { key: 'url', label: 'URL' },
+ const getSteps = (): { key: WizardStep; label: string }[] => {
+ if (sourceType === 'dockerhub' || dockerHubInfo) {
+ return [
+ { key: 'url', label: 'Source' },
+ { key: 'config', label: 'Configure' },
+ { key: 'complete', label: 'Done' },
+ ]
+ }
+ return [
+ { key: 'url', label: 'Source' },
{ key: 'compose', label: 'Compose' },
{ key: 'service', label: 'Service' },
{ key: 'config', label: 'Configure' },
{ key: 'complete', label: 'Done' },
]
+ }
+ const renderStepIndicator = () => {
+ const steps = getSteps()
const currentIndex = steps.findIndex((s) => s.key === step)
return (
@@ -279,34 +424,68 @@ export default function ImportFromGitHubModal({
const renderUrlStep = () => (
+ {/* Source Type Selector */}
+
+ setSourceType('github')}
+ className={`flex-1 p-3 rounded-lg border-2 flex items-center justify-center gap-2 transition-all ${
+ sourceType === 'github'
+ ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
+ : 'border-gray-200 dark:border-gray-700 hover:border-primary-300'
+ }`}
+ >
+
+ GitHub
+
+ setSourceType('dockerhub')}
+ className={`flex-1 p-3 rounded-lg border-2 flex items-center justify-center gap-2 transition-all ${
+ sourceType === 'dockerhub'
+ ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
+ : 'border-gray-200 dark:border-gray-700 hover:border-primary-300'
+ }`}
+ >
+
+ Docker Hub
+
+
+
-
-
- Branch (optional)
-
- setBranch(e.target.value)}
- placeholder="main"
- className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
- />
-
+ {sourceType === 'github' && (
+
+
+ Branch (optional)
+
+ setBranch(e.target.value)}
+ placeholder="main"
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
+ />
+
+ )}
)
@@ -389,7 +568,26 @@ export default function ImportFromGitHubModal({
)
const renderConfigStep = () => (
-
+
+ {/* Docker Hub Tag Selection */}
+ {dockerHubInfo && availableTags.length > 0 && (
+
+
+
+ Image Tag
+
+ setSelectedTag(e.target.value)}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
+ >
+ {availableTags.map((tag) => (
+ {tag}
+ ))}
+
+
+ )}
+
{/* Basic Info */}
@@ -433,6 +631,86 @@ export default function ImportFromGitHubModal({
+ {/* Ports (Docker Hub) */}
+ {dockerHubInfo && (
+
+
+
Port Mappings
+
+ Add Port
+
+
+ {ports.map((port, index) => (
+
+ updatePort(index, { host_port: parseInt(e.target.value) || 0 })}
+ placeholder="Host"
+ className="w-24 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
+ />
+ :
+ updatePort(index, { container_port: parseInt(e.target.value) || 0 })}
+ placeholder="Container"
+ className="w-24 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
+ />
+ removePort(index)}
+ className="p-1 text-gray-400 hover:text-red-500"
+ >
+
+
+
+ ))}
+
+ )}
+
+ {/* Volumes (Docker Hub) */}
+ {dockerHubInfo && (
+
+
+
Volumes
+
+ Add Volume
+
+
+ {volumes.map((vol, index) => (
+
+ updateVolume(index, { name: e.target.value })}
+ placeholder="Volume name"
+ className="w-32 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
+ />
+ :
+ updateVolume(index, { container_path: e.target.value })}
+ placeholder="/path/in/container"
+ className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
+ />
+ removeVolume(index)}
+ className="p-1 text-gray-400 hover:text-red-500"
+ >
+
+
+
+ ))}
+
+ )}
+
{/* Shadow Header Configuration */}
@@ -500,98 +778,122 @@ export default function ImportFromGitHubModal({
{/* Environment Variables */}
- {envVars.length > 0 && (
-
+
+
Environment Variables ({envVars.length})
-
- {envVars.map((env, index) => {
- const originalEnv = selectedService?.environment.find(
- (e) => e.name === env.name
- )
- return (
-
-
+
+ {envVars.map((env, index) => {
+ const originalEnv = selectedService?.environment.find(
+ (e) => e.name === env.name
+ )
+ return (
+
+
+ {dockerHubInfo ? (
+
updateEnvVar(index, { name: e.target.value.toUpperCase() })}
+ placeholder="VAR_NAME"
+ className="font-mono text-sm text-gray-900 dark:text-white bg-transparent border-none p-0 focus:ring-0"
+ />
+ ) : (
{env.name}
-
- {originalEnv?.is_required && (
- Required
- )}
- {env.is_secret && (
- Secret
- )}
-
-
+ )}
-
- updateEnvVar(index, {
- source: e.target.value as 'literal' | 'setting' | 'default',
- })
- }
- className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
- >
- Set Value
- Use Default
- From Settings
-
- {env.source === 'literal' && (
-
- updateEnvVar(index, { value: e.target.value })}
- placeholder={originalEnv?.default_value || 'Enter value'}
- className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
- />
- {env.is_secret && (
-
- setShowSecrets((prev) => ({
- ...prev,
- [env.name]: !prev[env.name],
- }))
- }
- className="p-1 text-gray-400 hover:text-gray-600"
- >
- {showSecrets[env.name] ? (
-
- ) : (
-
- )}
-
- )}
-
+ {originalEnv?.is_required && (
+
Required
+ )}
+ {env.is_secret && (
+
Secret
)}
- {env.source === 'default' && originalEnv?.default_value && (
-
- Default: {originalEnv.default_value}
-
+ {dockerHubInfo && (
+
removeEnvVar(index)}
+ className="p-1 text-gray-400 hover:text-red-500"
+ >
+
+
)}
- {env.source === 'setting' && (
+
+
+
+
+ updateEnvVar(index, {
+ source: e.target.value as 'literal' | 'setting' | 'default',
+ })
+ }
+ className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
+ >
+ Set Value
+ Use Default
+ From Settings
+
+ {env.source === 'literal' && (
+
updateEnvVar(index, { setting_path: e.target.value })}
- placeholder="api_keys.my_key"
+ type={env.is_secret && !showSecrets[env.name] ? 'password' : 'text'}
+ value={env.value || ''}
+ onChange={(e) => updateEnvVar(index, { value: e.target.value })}
+ placeholder={originalEnv?.default_value || 'Enter value'}
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
- )}
-
+ {env.is_secret && (
+
+ setShowSecrets((prev) => ({
+ ...prev,
+ [env.name]: !prev[env.name],
+ }))
+ }
+ className="p-1 text-gray-400 hover:text-gray-600"
+ >
+ {showSecrets[env.name] ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ )}
+ {env.source === 'default' && originalEnv?.default_value && (
+
+ Default: {originalEnv.default_value}
+
+ )}
+ {env.source === 'setting' && (
+
updateEnvVar(index, { setting_path: e.target.value })}
+ placeholder="api_keys.my_key"
+ className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
+ />
+ )}
- )
- })}
-
+
+ )
+ })}
- )}
+
)
@@ -633,23 +935,32 @@ export default function ImportFromGitHubModal({
const canGoNext = step !== 'complete'
const handleBack = () => {
- switch (step) {
- case 'compose':
+ if (dockerHubInfo) {
+ // Docker Hub flow
+ if (step === 'config') {
+ setDockerHubInfo(null)
setStep('url')
- break
- case 'service':
- setStep('compose')
- break
- case 'config':
- setStep('service')
- break
+ }
+ } else {
+ // GitHub flow
+ switch (step) {
+ case 'compose':
+ setStep('url')
+ break
+ case 'service':
+ setStep('compose')
+ break
+ case 'config':
+ setStep('service')
+ break
+ }
}
}
const handleNext = () => {
switch (step) {
case 'url':
- handleScanRepo()
+ handleScan()
break
case 'config':
handleImport()
@@ -660,7 +971,7 @@ export default function ImportFromGitHubModal({
const getNextButtonLabel = () => {
switch (step) {
case 'url':
- return 'Scan Repository'
+ return 'Scan'
case 'config':
return 'Import Service'
default:
@@ -680,9 +991,13 @@ export default function ImportFromGitHubModal({
{/* Header */}
-
+ {sourceType === 'github' && !dockerHubInfo ? (
+
+ ) : (
+
+ )}
- Import from GitHub
+ Import Docker Service
{loading ? (
diff --git a/ushadow/frontend/src/services/api.ts b/ushadow/frontend/src/services/api.ts
index 3e28a348..e25037a5 100644
--- a/ushadow/frontend/src/services/api.ts
+++ b/ushadow/frontend/src/services/api.ts
@@ -1544,17 +1544,82 @@ export interface ImportServiceResponse {
export interface ImportedService {
id: string
- github_url: string
- compose_path: string
+ source_type?: 'github' | 'dockerhub'
+ source_url?: string
+ github_url?: string
+ compose_path?: string
compose_file: string
+ docker_image?: string
service_name: string
display_name?: string
description?: string
shadow_header: ShadowHeaderConfig
env_vars: EnvVarConfigItem[]
+ ports?: PortConfig[]
+ volumes?: VolumeConfig[]
enabled: boolean
}
+export interface PortConfig {
+ host_port: number
+ container_port: number
+ protocol?: string
+}
+
+export interface VolumeConfig {
+ name: string
+ container_path: string
+ is_named_volume?: boolean
+}
+
+export interface DockerHubImageInfo {
+ namespace: string
+ repository: string
+ tag: string
+ full_image_name?: string
+}
+
+export interface DockerHubScanResponse {
+ success: boolean
+ image_info?: DockerHubImageInfo
+ description?: string
+ stars?: number
+ pulls?: number
+ available_tags: string[]
+ message?: string
+ error?: string
+}
+
+export interface UnifiedScanResponse {
+ success: boolean
+ source_type: 'github' | 'dockerhub'
+ // GitHub-specific
+ github_info?: GitHubUrlInfo
+ compose_files?: DetectedComposeFile[]
+ // Docker Hub-specific
+ dockerhub_info?: DockerHubImageInfo
+ available_tags?: string[]
+ image_description?: string
+ // Common
+ message?: string
+ error?: string
+}
+
+export interface DockerHubRegisterRequest {
+ service_name: string
+ dockerhub_url: string
+ tag?: string
+ display_name?: string
+ description?: string
+ ports?: PortConfig[]
+ volumes?: VolumeConfig[]
+ env_vars?: EnvVarConfigItem[]
+ shadow_header_enabled?: boolean
+ shadow_header_name?: string
+ shadow_header_value?: string
+ route_path?: string
+}
+
export const githubImportApi = {
/** Scan a GitHub repository for docker-compose files */
scan: (github_url: string, branch?: string, compose_path?: string) =>
@@ -1585,4 +1650,41 @@ export const githubImportApi = {
/** Update configuration for an imported service */
updateConfig: (serviceId: string, config: ImportedServiceConfig) =>
api.put<{ success: boolean; message: string }>(`/api/github-import/imported/${serviceId}/config`, config),
+
+ // Docker Hub endpoints
+ /** Scan a Docker Hub image for information */
+ scanDockerHub: (dockerhub_url: string, tag?: string) =>
+ api.post('/api/github-import/dockerhub/scan', {
+ dockerhub_url,
+ tag
+ }),
+
+ /** Register a service from Docker Hub */
+ registerDockerHub: (request: DockerHubRegisterRequest) =>
+ api.post('/api/github-import/dockerhub/register', null, {
+ params: {
+ service_name: request.service_name,
+ dockerhub_url: request.dockerhub_url,
+ tag: request.tag,
+ display_name: request.display_name,
+ description: request.description,
+ shadow_header_enabled: request.shadow_header_enabled ?? true,
+ shadow_header_name: request.shadow_header_name ?? 'X-Shadow-Service',
+ shadow_header_value: request.shadow_header_value,
+ route_path: request.route_path,
+ ports: request.ports ? JSON.stringify(request.ports) : undefined,
+ volumes: request.volumes ? JSON.stringify(request.volumes) : undefined,
+ env_vars: request.env_vars ? JSON.stringify(request.env_vars) : undefined,
+ }
+ }),
+
+ // Unified endpoints (auto-detect source type)
+ /** Scan any supported source (GitHub or Docker Hub) */
+ unifiedScan: (url: string, branch?: string, tag?: string, compose_path?: string) =>
+ api.post('/api/github-import/unified/scan', {
+ url,
+ branch,
+ tag,
+ compose_path
+ }),
}
From cb28a483003bb5111791c06393555654daa82272 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 17 Jan 2026 14:55:38 +0000
Subject: [PATCH 04/11] fix: Update import button to show both GitHub and
Docker Hub support
Change button icon from GitHub to Download and text from "Import from
GitHub" to "Import Service" since the modal now supports both GitHub
and Docker Hub imports.
---
ushadow/frontend/src/pages/ServicesPage.tsx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/ushadow/frontend/src/pages/ServicesPage.tsx b/ushadow/frontend/src/pages/ServicesPage.tsx
index 0c0320a8..3f7766f2 100644
--- a/ushadow/frontend/src/pages/ServicesPage.tsx
+++ b/ushadow/frontend/src/pages/ServicesPage.tsx
@@ -19,7 +19,7 @@ import {
Package,
Trash2,
BookOpen,
- Github
+ Download
} from 'lucide-react'
import {
settingsApi,
@@ -631,11 +631,11 @@ export default function ServicesPage() {
setShowGitHubImport(true)}
- data-testid="import-github-button"
+ data-testid="import-service-button"
className="btn-secondary flex items-center gap-2"
>
-
- Import from GitHub
+
+ Import Service
Date: Sat, 17 Jan 2026 15:38:52 +0000
Subject: [PATCH 05/11] feat: Add capability selection to service import wizard
- Add capabilities field to ImportedServiceConfig model in backend
- Update register endpoints to write provides field to compose x-ushadow metadata
- Add capabilities to frontend API types (DockerHubRegisterRequest, ImportedServiceConfig)
- Add capability selector UI with common options (LLM, TTS, STT, Embedding, Memory, Vision, Image Gen)
- Support selecting multiple capabilities per service
---
ushadow/backend/src/models/github_import.py | 2 +
ushadow/backend/src/routers/github_import.py | 46 ++++++++++-----
.../src/components/ImportFromGitHubModal.tsx | 58 ++++++++++++++++++-
ushadow/frontend/src/services/api.ts | 4 ++
4 files changed, 95 insertions(+), 15 deletions(-)
diff --git a/ushadow/backend/src/models/github_import.py b/ushadow/backend/src/models/github_import.py
index c80b6a83..08947751 100644
--- a/ushadow/backend/src/models/github_import.py
+++ b/ushadow/backend/src/models/github_import.py
@@ -163,6 +163,8 @@ class ImportedServiceConfig(BaseModel):
shadow_header: ShadowHeaderConfig = Field(default_factory=ShadowHeaderConfig)
env_vars: List[EnvVarConfigItem] = Field(default_factory=list)
enabled: bool = True
+ # Capabilities this service provides (e.g., ['llm', 'tts'])
+ capabilities: List[str] = Field(default_factory=list, description="Capabilities this service provides")
# Backwards compatibility
@property
diff --git a/ushadow/backend/src/routers/github_import.py b/ushadow/backend/src/routers/github_import.py
index 026f8f8d..d1c43d4a 100644
--- a/ushadow/backend/src/routers/github_import.py
+++ b/ushadow/backend/src/routers/github_import.py
@@ -291,26 +291,36 @@ def generate_compose_from_dockerhub(
shadow_header: ShadowHeaderConfig,
display_name: Optional[str] = None,
description: Optional[str] = None,
+ capabilities: Optional[List[str]] = None,
) -> str:
"""Generate a docker-compose.yaml from Docker Hub image info."""
yaml = YAML()
yaml.default_flow_style = False
# Build compose structure
+ service_metadata = {
+ 'display_name': display_name or service_name.replace('-', ' ').title(),
+ 'description': description or f"Imported from Docker Hub: {image_info.full_image_name}",
+ 'requires': [],
+ 'optional': [],
+ 'dockerhub_source': {
+ 'namespace': image_info.namespace,
+ 'repository': image_info.repository,
+ 'tag': image_info.tag,
+ 'full_image': image_info.full_image_name
+ }
+ }
+
+ # Add capabilities if provided
+ if capabilities and len(capabilities) > 0:
+ if len(capabilities) == 1:
+ service_metadata['provides'] = capabilities[0]
+ else:
+ service_metadata['provides'] = capabilities
+
compose_data = {
'x-ushadow': {
- service_name: {
- 'display_name': display_name or service_name.replace('-', ' ').title(),
- 'description': description or f"Imported from Docker Hub: {image_info.full_image_name}",
- 'requires': ['llm'], # Default capability requirement
- 'optional': [],
- 'dockerhub_source': {
- 'namespace': image_info.namespace,
- 'repository': image_info.repository,
- 'tag': image_info.tag,
- 'full_image': image_info.full_image_name
- }
- }
+ service_name: service_metadata
},
'services': {
service_name: {
@@ -564,6 +574,7 @@ async def register_imported_service(
service_meta = {
'display_name': config.display_name or request.service_name,
'description': config.description or f"Imported from {github_info.owner}/{github_info.repo}",
+ 'requires': [],
'github_source': {
'url': request.github_url,
'owner': github_info.owner,
@@ -573,6 +584,13 @@ async def register_imported_service(
}
}
+ # Add capabilities if provided
+ if config.capabilities and len(config.capabilities) > 0:
+ if len(config.capabilities) == 1:
+ service_meta['provides'] = config.capabilities[0]
+ else:
+ service_meta['provides'] = config.capabilities
+
# Add shadow header config
if config.shadow_header.enabled:
service_meta['shadow_header'] = {
@@ -861,6 +879,7 @@ async def register_dockerhub_service(
shadow_header_name: str = "X-Shadow-Service",
shadow_header_value: Optional[str] = None,
route_path: Optional[str] = None,
+ capabilities: Optional[List[str]] = None,
current_user: User = Depends(get_current_user)
) -> ImportServiceResponse:
"""
@@ -925,7 +944,8 @@ async def register_dockerhub_service(
env_vars=env_var_configs,
shadow_header=shadow_header,
display_name=display_name,
- description=description
+ description=description,
+ capabilities=capabilities
)
# Ensure compose directory exists
diff --git a/ushadow/frontend/src/components/ImportFromGitHubModal.tsx b/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
index 0d8bcf77..e19fea77 100644
--- a/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
+++ b/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
@@ -86,6 +86,20 @@ export default function ImportFromGitHubModal({
const [ports, setPorts] = useState([])
const [volumes, setVolumes] = useState([])
+ // Capabilities this service provides
+ const [capabilities, setCapabilities] = useState([])
+
+ // Common capability options
+ const CAPABILITY_OPTIONS = [
+ { id: 'llm', label: 'LLM', description: 'Language model inference' },
+ { id: 'tts', label: 'TTS', description: 'Text to speech' },
+ { id: 'stt', label: 'STT', description: 'Speech to text' },
+ { id: 'embedding', label: 'Embedding', description: 'Text embeddings' },
+ { id: 'memory', label: 'Memory', description: 'Persistent memory storage' },
+ { id: 'vision', label: 'Vision', description: 'Image understanding' },
+ { id: 'image_gen', label: 'Image Gen', description: 'Image generation' },
+ ]
+
// Reset state when modal opens/closes
useEffect(() => {
if (!isOpen) {
@@ -113,6 +127,7 @@ export default function ImportFromGitHubModal({
setEnvVars([])
setPorts([])
setVolumes([])
+ setCapabilities([])
setError(null)
}
}, [isOpen])
@@ -317,6 +332,7 @@ export default function ImportFromGitHubModal({
shadow_header_name: shadowHeader.header_name,
shadow_header_value: shadowHeader.header_value || serviceName,
route_path: shadowHeader.route_path || `/${serviceName}`,
+ capabilities: capabilities.length > 0 ? capabilities : undefined,
})
const data = response.data
@@ -339,12 +355,12 @@ export default function ImportFromGitHubModal({
service_name: serviceName,
display_name: displayName,
description,
- source_type: 'github',
- source_url: importUrl,
+ github_url: importUrl,
compose_path: selectedComposeFile.path,
shadow_header: shadowHeader,
env_vars: envVars,
enabled: true,
+ capabilities: capabilities.length > 0 ? capabilities : undefined,
},
})
@@ -631,6 +647,44 @@ export default function ImportFromGitHubModal({
+ {/* Capabilities */}
+
+
+
+ Capabilities Provided
+
+
+ Select the capabilities this service provides (optional)
+
+
+ {CAPABILITY_OPTIONS.map((cap) => (
+ {
+ if (capabilities.includes(cap.id)) {
+ setCapabilities(capabilities.filter((c) => c !== cap.id))
+ } else {
+ setCapabilities([...capabilities, cap.id])
+ }
+ }}
+ className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
+ capabilities.includes(cap.id)
+ ? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 border-2 border-primary-500'
+ : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border-2 border-transparent hover:border-gray-300 dark:hover:border-gray-600'
+ }`}
+ title={cap.description}
+ >
+ {cap.label}
+
+ ))}
+
+ {capabilities.length > 0 && (
+
+ Selected: {capabilities.join(', ')}
+
+ )}
+
+
{/* Ports (Docker Hub) */}
{dockerHubInfo && (
diff --git a/ushadow/frontend/src/services/api.ts b/ushadow/frontend/src/services/api.ts
index e25037a5..d677882d 100644
--- a/ushadow/frontend/src/services/api.ts
+++ b/ushadow/frontend/src/services/api.ts
@@ -1525,6 +1525,7 @@ export interface ImportedServiceConfig {
shadow_header: ShadowHeaderConfig
env_vars: EnvVarConfigItem[]
enabled: boolean
+ capabilities?: string[] // Capabilities this service provides (e.g., ['llm', 'tts'])
}
export interface ImportServiceRequest {
@@ -1558,6 +1559,7 @@ export interface ImportedService {
ports?: PortConfig[]
volumes?: VolumeConfig[]
enabled: boolean
+ capabilities?: string[] // Capabilities this service provides
}
export interface PortConfig {
@@ -1618,6 +1620,7 @@ export interface DockerHubRegisterRequest {
shadow_header_name?: string
shadow_header_value?: string
route_path?: string
+ capabilities?: string[] // Capabilities this service provides
}
export const githubImportApi = {
@@ -1675,6 +1678,7 @@ export const githubImportApi = {
ports: request.ports ? JSON.stringify(request.ports) : undefined,
volumes: request.volumes ? JSON.stringify(request.volumes) : undefined,
env_vars: request.env_vars ? JSON.stringify(request.env_vars) : undefined,
+ capabilities: request.capabilities ? JSON.stringify(request.capabilities) : undefined,
}
}),
From 16b729cf247017a88145e1c7673c6501d5b0ccb6 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 17 Jan 2026 18:01:32 +0000
Subject: [PATCH 06/11] fix: Multi-service selection and improved error
handling in import wizard
- Enable selecting multiple services from a compose file with checkboxes
- Add "Select All" / "Deselect All" toggle
- Import all selected services sequentially with per-service error handling
- Add extractErrorMessage helper to properly handle Pydantic validation errors
- Fix React error when backend returns validation error objects
---
.../src/components/ImportFromGitHubModal.tsx | 294 +++++++++++++-----
1 file changed, 209 insertions(+), 85 deletions(-)
diff --git a/ushadow/frontend/src/components/ImportFromGitHubModal.tsx b/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
index e19fea77..2621841e 100644
--- a/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
+++ b/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
@@ -67,7 +67,7 @@ export default function ImportFromGitHubModal({
// Service selection (GitHub)
const [services, setServices] = useState([])
- const [selectedService, setSelectedService] = useState(null)
+ const [selectedServices, setSelectedServices] = useState([])
// Configuration
const [serviceName, setServiceName] = useState('')
@@ -114,7 +114,7 @@ export default function ImportFromGitHubModal({
setComposeFiles([])
setSelectedComposeFile(null)
setServices([])
- setSelectedService(null)
+ setSelectedServices([])
setServiceName('')
setDisplayName('')
setDescription('')
@@ -132,6 +132,37 @@ export default function ImportFromGitHubModal({
}
}, [isOpen])
+ // Helper to extract error message from various error formats
+ const extractErrorMessage = (err: any, fallback: string): string => {
+ // Handle axios error response
+ const detail = err?.response?.data?.detail
+ if (detail) {
+ // If detail is a string, use it directly
+ if (typeof detail === 'string') {
+ return detail
+ }
+ // If detail is an array (Pydantic validation errors), extract messages
+ if (Array.isArray(detail)) {
+ return detail
+ .map((e: any) => e.msg || e.message || JSON.stringify(e))
+ .join(', ')
+ }
+ // If detail is an object with a message field
+ if (typeof detail === 'object' && detail.msg) {
+ return detail.msg
+ }
+ }
+ // Check for direct message field
+ if (err?.response?.data?.message) {
+ return err.response.data.message
+ }
+ // Check for error message on the error object itself
+ if (err?.message && typeof err.message === 'string') {
+ return err.message
+ }
+ return fallback
+ }
+
// Auto-detect source type from URL
useEffect(() => {
const url = importUrl.toLowerCase().trim()
@@ -203,7 +234,7 @@ export default function ImportFromGitHubModal({
}
}
} catch (err: any) {
- setError(err.response?.data?.detail || 'Failed to scan')
+ setError(extractErrorMessage(err, 'Failed to scan'))
} finally {
setLoading(false)
}
@@ -232,35 +263,66 @@ export default function ImportFromGitHubModal({
setServices(data.services)
setStep('service')
} catch (err: any) {
- setError(err.response?.data?.detail || 'Failed to parse compose file')
+ setError(extractErrorMessage(err, 'Failed to parse compose file'))
} finally {
setLoading(false)
}
}
- // Select service and prepare configuration (GitHub)
- const handleSelectService = (service: ComposeServiceInfo) => {
- setSelectedService(service)
- setServiceName(service.name)
- setDisplayName(service.name.charAt(0).toUpperCase() + service.name.slice(1).replace(/-/g, ' '))
+ // Toggle service selection (GitHub)
+ const handleToggleService = (service: ComposeServiceInfo) => {
+ setSelectedServices((prev) => {
+ const isSelected = prev.some((s) => s.name === service.name)
+ if (isSelected) {
+ return prev.filter((s) => s.name !== service.name)
+ } else {
+ return [...prev, service]
+ }
+ })
+ }
+
+ // Select/deselect all services
+ const handleSelectAllServices = () => {
+ if (selectedServices.length === services.length) {
+ setSelectedServices([])
+ } else {
+ setSelectedServices([...services])
+ }
+ }
+
+ // Proceed to config after service selection
+ const handleProceedToConfig = () => {
+ if (selectedServices.length === 0) return
+
+ // Use first selected service for basic config defaults
+ const firstService = selectedServices[0]
+ setServiceName(firstService.name)
+ setDisplayName(firstService.name.charAt(0).toUpperCase() + firstService.name.slice(1).replace(/-/g, ' '))
setShadowHeader({
enabled: true,
header_name: 'X-Shadow-Service',
- header_value: service.name,
- route_path: `/${service.name}`,
+ header_value: firstService.name,
+ route_path: `/${firstService.name}`,
})
- // Initialize env vars from service
- const envConfig: EnvVarConfigItem[] = service.environment.map((env) => ({
- name: env.name,
- source: env.has_default ? 'default' : 'literal',
- value: env.default_value || '',
- is_secret: env.name.toLowerCase().includes('key') ||
- env.name.toLowerCase().includes('secret') ||
- env.name.toLowerCase().includes('password') ||
- env.name.toLowerCase().includes('token'),
- }))
- setEnvVars(envConfig)
+ // Combine env vars from all selected services (deduplicated)
+ const allEnvVars = new Map()
+ for (const service of selectedServices) {
+ for (const env of service.environment) {
+ if (!allEnvVars.has(env.name)) {
+ allEnvVars.set(env.name, {
+ name: env.name,
+ source: env.has_default ? 'default' : 'literal',
+ value: env.default_value || '',
+ is_secret: env.name.toLowerCase().includes('key') ||
+ env.name.toLowerCase().includes('secret') ||
+ env.name.toLowerCase().includes('password') ||
+ env.name.toLowerCase().includes('token'),
+ })
+ }
+ }
+ }
+ setEnvVars(Array.from(allEnvVars.values()))
setStep('config')
}
@@ -341,39 +403,70 @@ export default function ImportFromGitHubModal({
return
}
} else {
- // GitHub import
- if (!selectedComposeFile || !selectedService) {
- setError('Please select a compose file and service')
+ // GitHub import - supports multiple services
+ if (!selectedComposeFile || selectedServices.length === 0) {
+ setError('Please select a compose file and at least one service')
return
}
- const response = await githubImportApi.register({
- github_url: importUrl,
- compose_path: selectedComposeFile.path,
- service_name: serviceName,
- config: {
- service_name: serviceName,
- display_name: displayName,
- description,
- github_url: importUrl,
- compose_path: selectedComposeFile.path,
- shadow_header: shadowHeader,
- env_vars: envVars,
- enabled: true,
- capabilities: capabilities.length > 0 ? capabilities : undefined,
- },
- })
+ const errors: string[] = []
+ let successCount = 0
+
+ // Import each selected service
+ for (const service of selectedServices) {
+ try {
+ const svcName = service.name
+ const svcDisplayName = svcName.charAt(0).toUpperCase() + svcName.slice(1).replace(/-/g, ' ')
+
+ const response = await githubImportApi.register({
+ github_url: importUrl,
+ compose_path: selectedComposeFile.path,
+ service_name: svcName,
+ config: {
+ service_name: svcName,
+ display_name: svcDisplayName,
+ description: description || `Imported from GitHub`,
+ github_url: importUrl,
+ compose_path: selectedComposeFile.path,
+ shadow_header: {
+ ...shadowHeader,
+ header_value: svcName,
+ route_path: `/${svcName}`,
+ },
+ env_vars: envVars,
+ enabled: true,
+ capabilities: capabilities.length > 0 ? capabilities : undefined,
+ },
+ })
+
+ const data = response.data
+ if (data.success) {
+ successCount++
+ } else {
+ errors.push(`${svcName}: ${data.message || 'Failed'}`)
+ }
+ } catch (err: any) {
+ errors.push(`${service.name}: ${extractErrorMessage(err, 'Failed')}`)
+ }
+ }
- const data = response.data
- if (!data.success) {
- setError(data.message || 'Failed to import service')
+ // Check results
+ if (successCount === 0) {
+ setError(`Failed to import services: ${errors.join('; ')}`)
return
}
+
+ if (errors.length > 0) {
+ // Partial success
+ setError(`Imported ${successCount} service(s), but some failed: ${errors.join('; ')}`)
+ }
+
+ // Proceed to complete (even with partial success)
}
setStep('complete')
} catch (err: any) {
- setError(err.response?.data?.detail || 'Failed to import service')
+ setError(extractErrorMessage(err, 'Failed to import service'))
} finally {
setLoading(false)
}
@@ -539,47 +632,77 @@ export default function ImportFromGitHubModal({
const renderServiceStep = () => (
-
- Found {services.length} service(s) in the compose file. Select one to import:
-
+
+
+ Found {services.length} service(s). Select the services to import:
+
+
+ {selectedServices.length === services.length ? 'Deselect All' : 'Select All'}
+
+
- {services.map((service) => (
-
handleSelectService(service)}
- className={`w-full p-3 rounded-lg border-2 text-left transition-all ${
- selectedService?.name === service.name
- ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
- : 'border-gray-200 dark:border-gray-700 hover:border-primary-300'
- }`}
- >
-
-
-
-
- {service.name}
-
- {service.image && (
-
- Image: {service.image}
-
- )}
- {service.ports.length > 0 && (
-
- Ports: {service.ports.map((p) => p.container).join(', ')}
+ {services.map((service) => {
+ const isSelected = selectedServices.some((s) => s.name === service.name)
+ return (
+ handleToggleService(service)}
+ className={`w-full p-3 rounded-lg border-2 text-left transition-all ${
+ isSelected
+ ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
+ : 'border-gray-200 dark:border-gray-700 hover:border-primary-300'
+ }`}
+ >
+
+
+ {isSelected && }
+
+
+
+ {service.name}
- )}
- {service.environment.filter((e) => e.is_required).length > 0 && (
-
- {service.environment.filter((e) => e.is_required).length} required env vars
-
- )}
+ {service.image && (
+
+ Image: {service.image}
+
+ )}
+ {service.ports.length > 0 && (
+
+ Ports: {service.ports.map((p) => p.container).join(', ')}
+
+ )}
+ {service.environment.filter((e) => e.is_required).length > 0 && (
+
+ {service.environment.filter((e) => e.is_required).length} required env vars
+
+ )}
+
-
-
-
- ))}
+
+ )
+ })}
+ {selectedServices.length > 0 && (
+
+
+ {selectedServices.length} service(s) selected
+
+
+ Continue to Configuration
+
+
+
+ )}
)
@@ -847,9 +970,10 @@ export default function ImportFromGitHubModal({
{envVars.map((env, index) => {
- const originalEnv = selectedService?.environment.find(
- (e) => e.name === env.name
- )
+ // Find original env across all selected services
+ const originalEnv = selectedServices
+ .flatMap((s) => s.environment)
+ .find((e) => e.name === env.name)
return (
Date: Sat, 17 Jan 2026 18:10:27 +0000
Subject: [PATCH 07/11] fix: Use source_url instead of github_url in
ImportedServiceConfig
The backend model expects source_url but frontend was sending github_url,
causing a 422 validation error.
---
ushadow/frontend/src/components/ImportFromGitHubModal.tsx | 2 +-
ushadow/frontend/src/services/api.ts | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/ushadow/frontend/src/components/ImportFromGitHubModal.tsx b/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
index 2621841e..064aa6cb 100644
--- a/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
+++ b/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
@@ -426,7 +426,7 @@ export default function ImportFromGitHubModal({
service_name: svcName,
display_name: svcDisplayName,
description: description || `Imported from GitHub`,
- github_url: importUrl,
+ source_url: importUrl,
compose_path: selectedComposeFile.path,
shadow_header: {
...shadowHeader,
diff --git a/ushadow/frontend/src/services/api.ts b/ushadow/frontend/src/services/api.ts
index d677882d..84f6d9ea 100644
--- a/ushadow/frontend/src/services/api.ts
+++ b/ushadow/frontend/src/services/api.ts
@@ -1520,8 +1520,8 @@ export interface ImportedServiceConfig {
service_name: string
display_name?: string
description?: string
- github_url: string
- compose_path: string
+ source_url: string // GitHub URL or Docker Hub URL
+ compose_path?: string
shadow_header: ShadowHeaderConfig
env_vars: EnvVarConfigItem[]
enabled: boolean
From cc2370c783a0f394eb90a8bbcf7df40c944d8558 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 17 Jan 2026 18:22:02 +0000
Subject: [PATCH 08/11] feat: Auto-install imported services and add env
template paste
- Auto-mark imported services as installed (added=true, enabled=true)
so they appear in installed services list immediately
- Add "Paste Template" button in env vars section to bulk-add
environment variables from KEY=value format text
- Supports comments (#) and handles duplicates
- Detects secrets by name pattern (key, secret, password, token)
---
ushadow/backend/src/routers/github_import.py | 18 +++-
.../src/components/ImportFromGitHubModal.tsx | 99 +++++++++++++++++--
2 files changed, 109 insertions(+), 8 deletions(-)
diff --git a/ushadow/backend/src/routers/github_import.py b/ushadow/backend/src/routers/github_import.py
index d1c43d4a..78a5055f 100644
--- a/ushadow/backend/src/routers/github_import.py
+++ b/ushadow/backend/src/routers/github_import.py
@@ -660,7 +660,14 @@ async def register_imported_service(
registry = get_compose_registry()
registry.refresh()
- logger.info(f"Imported service '{request.service_name}' from GitHub: {request.github_url}")
+ # Auto-install the service (mark as added so it shows in installed services)
+ installed_key = f"installed_services.{request.service_name}"
+ await settings.update({
+ f"{installed_key}.added": True,
+ f"{installed_key}.enabled": True
+ })
+
+ logger.info(f"Imported and installed service '{request.service_name}' from GitHub: {request.github_url}")
return ImportServiceResponse(
success=True,
@@ -1001,7 +1008,14 @@ async def register_dockerhub_service(
registry = get_compose_registry()
registry.refresh()
- logger.info(f"Imported service '{service_name}' from Docker Hub: {image_info.full_image_name}")
+ # Auto-install the service (mark as added so it shows in installed services)
+ installed_key = f"installed_services.{service_name}"
+ await settings.update({
+ f"{installed_key}.added": True,
+ f"{installed_key}.enabled": True
+ })
+
+ logger.info(f"Imported and installed service '{service_name}' from Docker Hub: {image_info.full_image_name}")
return ImportServiceResponse(
success=True,
diff --git a/ushadow/frontend/src/components/ImportFromGitHubModal.tsx b/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
index 064aa6cb..791a0b95 100644
--- a/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
+++ b/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
@@ -89,6 +89,10 @@ export default function ImportFromGitHubModal({
// Capabilities this service provides
const [capabilities, setCapabilities] = useState([])
+ // Env template paste
+ const [showEnvPaste, setShowEnvPaste] = useState(false)
+ const [envPasteText, setEnvPasteText] = useState('')
+
// Common capability options
const CAPABILITY_OPTIONS = [
{ id: 'llm', label: 'LLM', description: 'Language model inference' },
@@ -128,6 +132,8 @@ export default function ImportFromGitHubModal({
setPorts([])
setVolumes([])
setCapabilities([])
+ setShowEnvPaste(false)
+ setEnvPasteText('')
setError(null)
}
}, [isOpen])
@@ -343,6 +349,45 @@ export default function ImportFromGitHubModal({
setEnvVars((prev) => prev.filter((_, i) => i !== index))
}
+ // Parse env template (KEY=value format, one per line)
+ const parseEnvTemplate = () => {
+ const lines = envPasteText.split('\n')
+ const newEnvVars: EnvVarConfigItem[] = []
+
+ for (const line of lines) {
+ const trimmed = line.trim()
+ // Skip empty lines and comments
+ if (!trimmed || trimmed.startsWith('#')) continue
+
+ // Parse KEY=value or KEY= or just KEY
+ const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)(?:=(.*))?$/)
+ if (match) {
+ const name = match[1]
+ const value = match[2] ?? ''
+
+ // Check if already exists
+ const exists = envVars.some((e) => e.name === name)
+ if (!exists) {
+ newEnvVars.push({
+ name,
+ source: 'literal',
+ value,
+ is_secret: name.toLowerCase().includes('key') ||
+ name.toLowerCase().includes('secret') ||
+ name.toLowerCase().includes('password') ||
+ name.toLowerCase().includes('token'),
+ })
+ }
+ }
+ }
+
+ if (newEnvVars.length > 0) {
+ setEnvVars((prev) => [...prev, ...newEnvVars])
+ }
+ setEnvPasteText('')
+ setShowEnvPaste(false)
+ }
+
// Add port
const addPort = () => {
setPorts((prev) => [...prev, { host_port: 8080, container_port: 8080, protocol: 'tcp' }])
@@ -961,13 +1006,55 @@ export default function ImportFromGitHubModal({
Environment Variables ({envVars.length})
-
- Add Variable
-
+
+
setShowEnvPaste(!showEnvPaste)}
+ className="text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 flex items-center gap-1"
+ >
+ Paste Template
+
+
+ Add Variable
+
+
+
+ {/* Env Template Paste Area */}
+ {showEnvPaste && (
+
+
+ Paste environment variables (KEY=value format, one per line):
+
+
+ )}
{envVars.map((env, index) => {
// Find original env across all selected services
From 0d82130da0c5ba664cdb9aedb46c5994c4469689 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 17 Jan 2026 18:44:27 +0000
Subject: [PATCH 09/11] feat: Add BETA tag above Import Service button
---
ushadow/frontend/src/pages/ServicesPage.tsx | 21 +++++++++++++--------
1 file changed, 13 insertions(+), 8 deletions(-)
diff --git a/ushadow/frontend/src/pages/ServicesPage.tsx b/ushadow/frontend/src/pages/ServicesPage.tsx
index 3f7766f2..456afacd 100644
--- a/ushadow/frontend/src/pages/ServicesPage.tsx
+++ b/ushadow/frontend/src/pages/ServicesPage.tsx
@@ -629,14 +629,19 @@ export default function ServicesPage() {
-
setShowGitHubImport(true)}
- data-testid="import-service-button"
- className="btn-secondary flex items-center gap-2"
- >
-
- Import Service
-
+
+
+ BETA
+
+ setShowGitHubImport(true)}
+ data-testid="import-service-button"
+ className="btn-secondary flex items-center gap-2"
+ >
+
+ Import Service
+
+
Date: Sat, 17 Jan 2026 18:46:43 +0000
Subject: [PATCH 10/11] fix: Use createPortal for import modal to fix
positioning
Renders modal directly to document.body using React Portal,
ensuring it appears centered on screen regardless of parent
container overflow settings.
---
.../src/components/ImportFromGitHubModal.tsx | 14 +++++++++++---
1 file changed, 11 insertions(+), 3 deletions(-)
diff --git a/ushadow/frontend/src/components/ImportFromGitHubModal.tsx b/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
index 791a0b95..7e0e1839 100644
--- a/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
+++ b/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
+import { createPortal } from 'react-dom'
import {
X,
Github,
@@ -1244,13 +1245,17 @@ export default function ImportFromGitHubModal({
}
}
- return (
+ const modalContent = (
+ {/* Backdrop */}
+
+
+ {/* Modal */}
e.stopPropagation()}
>
{/* Header */}
@@ -1339,4 +1344,7 @@ export default function ImportFromGitHubModal({
)
+
+ // Render to body using portal for proper positioning
+ return createPortal(modalContent, document.body)
}
From e6a7733adece69e70c8c50e8b2af1f2d2191f4cd Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 17 Jan 2026 18:56:49 +0000
Subject: [PATCH 11/11] fix: Use createPortal for catalog modal to fix
positioning
Updated the Service Catalog modal to use React's createPortal for
proper positioning, matching the Import Service modal behavior.
Changes include:
- Render modal to document.body via createPortal
- Increase z-index to z-[9999] for proper stacking
- Add backdrop-blur-sm for consistent blur effect
- Improve close button styling with hover states
---
ushadow/frontend/src/pages/ServicesPage.tsx | 23 ++++++++++++++++-----
1 file changed, 18 insertions(+), 5 deletions(-)
diff --git a/ushadow/frontend/src/pages/ServicesPage.tsx b/ushadow/frontend/src/pages/ServicesPage.tsx
index 456afacd..c8e7a9de 100644
--- a/ushadow/frontend/src/pages/ServicesPage.tsx
+++ b/ushadow/frontend/src/pages/ServicesPage.tsx
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
+import { createPortal } from 'react-dom'
import {
Server,
CheckCircle,
@@ -1276,9 +1277,20 @@ export default function ServicesPage() {
{/* Service Catalog Modal */}
- {showCatalog && (
-
-
+ {showCatalog && createPortal(
+
setShowCatalog(false)}
+ >
+ {/* Backdrop */}
+
+
+ {/* Modal Content */}
+
e.stopPropagation()}
+ >
{/* Modal Header */}
@@ -1289,7 +1301,7 @@ export default function ServicesPage() {
setShowCatalog(false)}
- className="p-1 text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300"
+ className="p-2 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
>
@@ -1396,7 +1408,8 @@ export default function ServicesPage() {
-
+
,
+ document.body
)}
{/* Confirmation Dialog */}