diff --git a/ushadow/backend/src/routers/github_import.py b/ushadow/backend/src/routers/github_import.py
index 78a5055f..87108b42 100644
--- a/ushadow/backend/src/routers/github_import.py
+++ b/ushadow/backend/src/routers/github_import.py
@@ -292,6 +292,7 @@ def generate_compose_from_dockerhub(
display_name: Optional[str] = None,
description: Optional[str] = None,
capabilities: Optional[List[str]] = None,
+ requires: Optional[List[str]] = None,
) -> str:
"""Generate a docker-compose.yaml from Docker Hub image info."""
yaml = YAML()
@@ -301,7 +302,7 @@ def generate_compose_from_dockerhub(
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': [],
+ 'requires': requires or [],
'optional': [],
'dockerhub_source': {
'namespace': image_info.namespace,
@@ -331,7 +332,7 @@ def generate_compose_from_dockerhub(
'networks': {
'infra-network': {
'external': True,
- 'name': '${COMPOSE_PROJECT_NAME:-ushadow}_infra-network'
+ 'name': 'infra-network'
}
}
}
@@ -887,6 +888,7 @@ async def register_dockerhub_service(
shadow_header_value: Optional[str] = None,
route_path: Optional[str] = None,
capabilities: Optional[List[str]] = None,
+ requires: Optional[List[str]] = None,
current_user: User = Depends(get_current_user)
) -> ImportServiceResponse:
"""
@@ -952,7 +954,8 @@ async def register_dockerhub_service(
shadow_header=shadow_header,
display_name=display_name,
description=description,
- capabilities=capabilities
+ capabilities=capabilities,
+ requires=requires
)
# Ensure compose directory exists
diff --git a/ushadow/frontend/package-lock.json b/ushadow/frontend/package-lock.json
index 848b5c3a..e7bddf12 100644
--- a/ushadow/frontend/package-lock.json
+++ b/ushadow/frontend/package-lock.json
@@ -8,8 +8,6 @@
"name": "ushadow-frontend",
"version": "0.1.0",
"dependencies": {
- "@dnd-kit/core": "^6.3.1",
- "@dnd-kit/utilities": "^3.2.2",
"@assistant-ui/react": "^0.11.53",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
diff --git a/ushadow/frontend/src/components/CapabilitySelector.tsx b/ushadow/frontend/src/components/CapabilitySelector.tsx
new file mode 100644
index 00000000..abc90f3f
--- /dev/null
+++ b/ushadow/frontend/src/components/CapabilitySelector.tsx
@@ -0,0 +1,201 @@
+import { useState } from 'react'
+import { Plus, X, Server } from 'lucide-react'
+
+/**
+ * Standard capability options used across the application.
+ * These represent the core capabilities that services can provide or require.
+ */
+export 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: 'transcription', label: 'Transcription', description: 'Speech to text transcription' },
+ { 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' },
+] as const
+
+export type CapabilityOption = typeof CAPABILITY_OPTIONS[number]
+
+export interface CapabilitySelectorProps {
+ /** Currently selected capabilities */
+ selected: string[]
+ /** Callback when selection changes */
+ onChange: (capabilities: string[]) => void
+ /** Visual mode - 'provides' uses primary/blue, 'requires' uses amber/orange */
+ mode: 'provides' | 'requires'
+ /** Optional title override */
+ title?: string
+ /** Optional description override */
+ description?: string
+ /** Whether to show the custom capability input */
+ allowCustom?: boolean
+ /** Test ID prefix for automation */
+ testIdPrefix?: string
+}
+
+/**
+ * Reusable component for selecting service capabilities.
+ *
+ * Used for both:
+ * - "Capabilities Provided" - what a service offers (LLM, memory, etc.)
+ * - "Capabilities Required" - what a service depends on
+ *
+ * @example
+ * // For capabilities a service provides
+ *
+ *
+ * // For capabilities a service requires
+ *
+ */
+export default function CapabilitySelector({
+ selected,
+ onChange,
+ mode,
+ title,
+ description,
+ allowCustom = true,
+ testIdPrefix = 'capability',
+}: CapabilitySelectorProps) {
+ const [customInput, setCustomInput] = useState('')
+
+ // Style variants based on mode
+ const styles = {
+ provides: {
+ selectedButton: 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 border-2 border-primary-500',
+ chip: 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300',
+ },
+ requires: {
+ selectedButton: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 border-2 border-amber-500',
+ chip: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300',
+ },
+ }
+
+ const currentStyles = styles[mode]
+
+ const defaultTitle = mode === 'provides' ? 'Capabilities Provided' : 'Capabilities Required'
+ const defaultDescription = mode === 'provides'
+ ? 'Select the capabilities this service provides (optional)'
+ : 'Select capabilities this service depends on (e.g., LLM, memory)'
+
+ const toggleCapability = (capId: string) => {
+ if (selected.includes(capId)) {
+ onChange(selected.filter((c) => c !== capId))
+ } else {
+ onChange([...selected, capId])
+ }
+ }
+
+ const addCustomCapability = () => {
+ const normalized = customInput.toLowerCase().replace(/[^a-z0-9_-]/g, '')
+ if (normalized && !selected.includes(normalized)) {
+ onChange([...selected, normalized])
+ setCustomInput('')
+ }
+ }
+
+ const removeCapability = (capId: string) => {
+ onChange(selected.filter((c) => c !== capId))
+ }
+
+ // Handle Enter key in custom input
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ addCustomCapability()
+ }
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ {title || defaultTitle}
+
+
+
+ {description || defaultDescription}
+
+
+ {/* Preset capability buttons */}
+
+ {CAPABILITY_OPTIONS.map((cap) => (
+ toggleCapability(cap.id)}
+ className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
+ selected.includes(cap.id)
+ ? currentStyles.selectedButton
+ : '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}
+ data-testid={`${testIdPrefix}-${mode}-${cap.id}`}
+ >
+ {cap.label}
+
+ ))}
+
+
+ {/* Custom capability input */}
+ {allowCustom && (
+
+
setCustomInput(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ''))}
+ onKeyDown={handleKeyDown}
+ placeholder="Add custom capability..."
+ className="flex-1 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
+ data-testid={`${testIdPrefix}-${mode}-custom-input`}
+ />
+
+
+ Add
+
+
+ )}
+
+ {/* Selected capabilities as removable chips */}
+ {selected.length > 0 && (
+
+ {selected.map((cap) => (
+
+ {cap}
+ removeCapability(cap)}
+ className="hover:text-red-500"
+ data-testid={`${testIdPrefix}-${mode}-remove-${cap}`}
+ >
+
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/ushadow/frontend/src/components/EnvVarListEditor.tsx b/ushadow/frontend/src/components/EnvVarListEditor.tsx
new file mode 100644
index 00000000..8bc46026
--- /dev/null
+++ b/ushadow/frontend/src/components/EnvVarListEditor.tsx
@@ -0,0 +1,515 @@
+import { useState } from 'react'
+import { Plus, Trash2, Eye, EyeOff, FileCode, Key } from 'lucide-react'
+
+/**
+ * Environment variable configuration for import/creation flows.
+ * Simpler than EnvVarConfig used for service configuration.
+ */
+export interface EnvVarItem {
+ name: string
+ source: 'literal' | 'setting' | 'default'
+ value?: string
+ setting_path?: string
+ is_secret: boolean
+}
+
+/**
+ * A setting suggestion that can be mapped to an env var.
+ * Mirrors EnvVarSuggestion from api.ts but simplified.
+ */
+export interface SettingSuggestion {
+ path: string // e.g., "api_keys.openai_api_key"
+ label: string // e.g., "OpenAI API Key"
+ has_value: boolean // Whether a value is already configured
+ value?: string // Masked value for display (optional)
+}
+
+export interface EnvVarListEditorProps {
+ /** Current list of env vars */
+ envVars: EnvVarItem[]
+ /** Callback when list changes */
+ onChange: (envVars: EnvVarItem[]) => void
+ /** Whether names are editable (true for new vars, false for predefined) */
+ allowNameEdit?: boolean
+ /** Placeholder for env var name input */
+ namePlaceholder?: string
+ /** Test ID prefix */
+ testIdPrefix?: string
+ /** Available settings to suggest for mapping (optional) */
+ suggestions?: SettingSuggestion[]
+}
+
+/**
+ * Detects if an env var name likely contains a secret.
+ */
+export function isSecretName(name: string): boolean {
+ const lower = name.toLowerCase()
+ return (
+ lower.includes('key') ||
+ lower.includes('secret') ||
+ lower.includes('password') ||
+ lower.includes('token') ||
+ lower.includes('credential')
+ )
+}
+
+/**
+ * Reusable component for editing a list of environment variables.
+ *
+ * Features:
+ * - Add/remove variables
+ * - Name editing (optional, for new vars)
+ * - Value source selection (literal, default, from settings)
+ * - Secret detection and masking
+ * - Bulk paste from .env format
+ *
+ * @example
+ *
+ */
+/**
+ * Finds matching suggestions for an env var name.
+ *
+ * This is the matching logic that determines which settings to suggest.
+ *
+ * @param envName - The environment variable name (e.g., "OPENAI_API_KEY")
+ * @param suggestions - Available settings to match against
+ * @returns Suggestions sorted by relevance (best matches first)
+ *
+ * TODO: Implement your matching logic here!
+ *
+ * Example matches to consider:
+ * - OPENAI_API_KEY -> api_keys.openai_api_key (exact match after normalization)
+ * - DATABASE_URL -> settings.database_url (partial match)
+ * - API_KEY -> api_keys.* (any API key setting)
+ */
+export function findMatchingSuggestions(
+ envName: string,
+ suggestions: SettingSuggestion[]
+): SettingSuggestion[] {
+ if (!envName || suggestions.length === 0) return []
+
+ const normalizedEnvName = envName.toLowerCase().replace(/_/g, '')
+
+ // Score and sort suggestions by relevance
+ const scored = suggestions.map(suggestion => {
+ const pathParts = suggestion.path.split('.')
+ const lastPart = pathParts[pathParts.length - 1].toLowerCase().replace(/_/g, '')
+
+ let score = 0
+
+ // Exact match on the last part of the path
+ if (lastPart === normalizedEnvName) {
+ score = 100
+ }
+ // Last part contains the env name or vice versa
+ else if (lastPart.includes(normalizedEnvName) || normalizedEnvName.includes(lastPart)) {
+ score = 50
+ }
+ // Check if they share significant substrings (e.g., "openai" in both)
+ else {
+ const envWords = envName.toLowerCase().split('_').filter(w => w.length > 2)
+ const pathWords = lastPart.split(/[_-]/).filter(w => w.length > 2)
+ const sharedWords = envWords.filter(w => pathWords.some(pw => pw.includes(w) || w.includes(pw)))
+ if (sharedWords.length > 0) {
+ score = 25 * sharedWords.length
+ }
+ }
+
+ // Boost score if the setting already has a value configured
+ if (suggestion.has_value && score > 0) {
+ score += 10
+ }
+
+ return { suggestion, score }
+ })
+
+ // Return suggestions with score > 0, sorted by score descending
+ return scored
+ .filter(s => s.score > 0)
+ .sort((a, b) => b.score - a.score)
+ .map(s => s.suggestion)
+}
+
+export default function EnvVarListEditor({
+ envVars,
+ onChange,
+ allowNameEdit = true,
+ namePlaceholder = 'VAR_NAME',
+ testIdPrefix = 'env-var',
+ suggestions = [],
+}: EnvVarListEditorProps) {
+ const [showSecrets, setShowSecrets] = useState>({})
+ const [showPaste, setShowPaste] = useState(false)
+ const [pasteText, setPasteText] = useState('')
+
+ // Update a single env var
+ const updateEnvVar = (index: number, updates: Partial) => {
+ onChange(envVars.map((ev, i) => (i === index ? { ...ev, ...updates } : ev)))
+ }
+
+ // Add a new empty env var
+ const addEnvVar = () => {
+ onChange([...envVars, { name: '', source: 'literal', value: '', is_secret: false }])
+ }
+
+ // Remove an env var
+ const removeEnvVar = (index: number) => {
+ onChange(envVars.filter((_, i) => i !== index))
+ }
+
+ // Toggle secret visibility
+ const toggleSecretVisibility = (name: string) => {
+ setShowSecrets((prev) => ({ ...prev, [name]: !prev[name] }))
+ }
+
+ // Parse pasted .env content
+ const parsePasteContent = () => {
+ const lines = pasteText.split('\n')
+ const newVars: EnvVarItem[] = []
+
+ 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] ?? ''
+
+ // Skip if already exists
+ if (!envVars.some((e) => e.name === name)) {
+ newVars.push({
+ name,
+ source: 'literal',
+ value,
+ is_secret: isSecretName(name),
+ })
+ }
+ }
+ }
+
+ if (newVars.length > 0) {
+ onChange([...envVars, ...newVars])
+ }
+ setPasteText('')
+ setShowPaste(false)
+ }
+
+ return (
+
+ {/* Header with actions */}
+
+
+
+ Environment Variables ({envVars.length})
+
+
+
setShowPaste(!showPaste)}
+ className="text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 flex items-center gap-1"
+ data-testid={`${testIdPrefix}-paste-toggle`}
+ >
+ Paste Template
+
+
+ Add Variable
+
+
+
+
+ {/* Paste template area */}
+ {showPaste && (
+
+
+ Paste environment variables (KEY=value format, one per line):
+
+
+ )}
+
+ {/* Env var list */}
+
+ {envVars.map((env, index) => (
+ updateEnvVar(index, updates)}
+ onRemove={() => removeEnvVar(index)}
+ onToggleSecret={() => toggleSecretVisibility(env.name)}
+ testIdPrefix={testIdPrefix}
+ suggestions={suggestions}
+ />
+ ))}
+
+
+ {envVars.length === 0 && (
+
+ No environment variables configured. Click "Add Variable" or "Paste Template" to add some.
+
+ )}
+
+ )
+}
+
+// =============================================================================
+// EnvVarRow - Individual row component
+// =============================================================================
+
+interface EnvVarRowProps {
+ env: EnvVarItem
+ index: number
+ allowNameEdit: boolean
+ namePlaceholder: string
+ showSecret: boolean
+ onUpdate: (updates: Partial) => void
+ onRemove: () => void
+ onToggleSecret: () => void
+ testIdPrefix: string
+ suggestions: SettingSuggestion[]
+}
+
+function EnvVarRow({
+ env,
+ index,
+ allowNameEdit,
+ namePlaceholder,
+ showSecret,
+ onUpdate,
+ onRemove,
+ onToggleSecret,
+ testIdPrefix,
+ suggestions,
+}: EnvVarRowProps) {
+ // Auto-detect secret when name changes
+ const handleNameChange = (name: string) => {
+ const upperName = name.toUpperCase()
+ onUpdate({
+ name: upperName,
+ is_secret: isSecretName(upperName),
+ })
+ }
+
+ return (
+
+ {/* Name row */}
+
+ {allowNameEdit ? (
+
handleNameChange(e.target.value)}
+ placeholder={namePlaceholder}
+ className="font-mono text-sm text-gray-900 dark:text-white bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded px-2 py-1 focus:ring-2 focus:ring-primary-500"
+ data-testid={`${testIdPrefix}-name-${index}`}
+ />
+ ) : (
+
+ {env.name}
+
+ )}
+
+
+ {env.is_secret && (
+ Secret
+ )}
+
+
+
+
+
+
+ {/* Value row */}
+
+ {/* Source selector */}
+
onUpdate({ source: e.target.value as EnvVarItem['source'] })}
+ 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"
+ data-testid={`${testIdPrefix}-source-${index}`}
+ >
+ Set Value
+ Use Default
+ From Settings
+
+
+ {/* Value input based on source */}
+ {env.source === 'literal' && (
+
+ onUpdate({ value: e.target.value })}
+ placeholder="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"
+ data-testid={`${testIdPrefix}-value-${index}`}
+ />
+ {env.is_secret && (
+
+ {showSecret ? : }
+
+ )}
+
+ )}
+
+ {env.source === 'default' && (
+
+ Will use default value from compose file
+
+ )}
+
+ {env.source === 'setting' && (
+
onUpdate({ setting_path: path })}
+ testId={`${testIdPrefix}-setting-path-${index}`}
+ />
+ )}
+
+
+ )
+}
+
+// =============================================================================
+// SettingPathSelector - Dropdown for selecting from settings suggestions
+// =============================================================================
+
+interface SettingPathSelectorProps {
+ envName: string
+ settingPath: string
+ suggestions: SettingSuggestion[]
+ onSelect: (path: string) => void
+ testId: string
+}
+
+/**
+ * A dropdown that shows matching settings suggestions based on the env var name.
+ * Falls back to a text input if no suggestions are available.
+ */
+function SettingPathSelector({
+ envName,
+ settingPath,
+ suggestions,
+ onSelect,
+ testId,
+}: SettingPathSelectorProps) {
+ // Get suggestions sorted by relevance to this env var name
+ const matchedSuggestions = findMatchingSuggestions(envName, suggestions)
+
+ // If we have suggestions, show a dropdown; otherwise show text input
+ if (suggestions.length > 0) {
+ return (
+
+ onSelect(e.target.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"
+ data-testid={testId}
+ >
+ Select a setting...
+
+ {/* Show matched suggestions first with a "Suggested" group */}
+ {matchedSuggestions.length > 0 && (
+
+ {matchedSuggestions.map((s) => (
+
+ {s.label || s.path}
+ {s.has_value ? ' ✓' : ''}
+
+ ))}
+
+ )}
+
+ {/* Show all other suggestions */}
+
+ {suggestions
+ .filter((s) => !matchedSuggestions.includes(s))
+ .map((s) => (
+
+ {s.label || s.path}
+ {s.has_value ? ' ✓' : ''}
+
+ ))}
+
+
+
+ {/* Show indicator if a configured setting is selected */}
+ {settingPath && suggestions.find((s) => s.path === settingPath)?.has_value && (
+
+ Configured
+
+ )}
+
+ )
+ }
+
+ // Fallback: text input for manual entry when no suggestions available
+ return (
+ onSelect(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"
+ data-testid={testId}
+ />
+ )
+}
diff --git a/ushadow/frontend/src/components/ImportFromGitHubModal.tsx b/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
index 7e0e1839..ad422b18 100644
--- a/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
+++ b/ushadow/frontend/src/components/ImportFromGitHubModal.tsx
@@ -8,12 +8,8 @@ import {
ChevronRight,
ChevronLeft,
Settings,
- Key,
Check,
AlertCircle,
- Server,
- Eye,
- EyeOff,
RefreshCw,
Box,
Plus,
@@ -29,6 +25,8 @@ import {
PortConfig,
VolumeConfig,
} from '../services/api'
+import CapabilitySelector from './CapabilitySelector'
+import EnvVarListEditor, { EnvVarItem } from './EnvVarListEditor'
interface ImportFromGitHubModalProps {
isOpen: boolean
@@ -80,8 +78,7 @@ export default function ImportFromGitHubModal({
header_value: '',
route_path: '',
})
- const [envVars, setEnvVars] = useState([])
- const [showSecrets, setShowSecrets] = useState>({})
+ const [envVars, setEnvVars] = useState([])
// Docker Hub specific config
const [ports, setPorts] = useState([])
@@ -90,20 +87,8 @@ 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' },
- { 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' },
- ]
+ // Capabilities this service requires (dependencies)
+ const [requiredCapabilities, setRequiredCapabilities] = useState([])
// Reset state when modal opens/closes
useEffect(() => {
@@ -133,8 +118,7 @@ export default function ImportFromGitHubModal({
setPorts([])
setVolumes([])
setCapabilities([])
- setShowEnvPaste(false)
- setEnvPasteText('')
+ setRequiredCapabilities([])
setError(null)
}
}, [isOpen])
@@ -313,18 +297,19 @@ export default function ImportFromGitHubModal({
})
// Combine env vars from all selected services (deduplicated)
- const allEnvVars = new Map()
+ const allEnvVars = new Map()
for (const service of selectedServices) {
for (const env of service.environment) {
if (!allEnvVars.has(env.name)) {
+ const lowerName = env.name.toLowerCase()
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'),
+ is_secret: lowerName.includes('key') ||
+ lowerName.includes('secret') ||
+ lowerName.includes('password') ||
+ lowerName.includes('token'),
})
}
}
@@ -333,62 +318,6 @@ export default function ImportFromGitHubModal({
setStep('config')
}
- // Update environment variable
- const updateEnvVar = (index: number, updates: Partial) => {
- setEnvVars((prev) =>
- prev.map((ev, i) => (i === index ? { ...ev, ...updates } : ev))
- )
- }
-
- // 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))
- }
-
- // 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' }])
@@ -441,6 +370,7 @@ export default function ImportFromGitHubModal({
shadow_header_value: shadowHeader.header_value || serviceName,
route_path: shadowHeader.route_path || `/${serviceName}`,
capabilities: capabilities.length > 0 ? capabilities : undefined,
+ requires: requiredCapabilities.length > 0 ? requiredCapabilities : undefined,
})
const data = response.data
@@ -482,6 +412,7 @@ export default function ImportFromGitHubModal({
env_vars: envVars,
enabled: true,
capabilities: capabilities.length > 0 ? capabilities : undefined,
+ requires: requiredCapabilities.length > 0 ? requiredCapabilities : undefined,
},
})
@@ -510,6 +441,8 @@ export default function ImportFromGitHubModal({
// Proceed to complete (even with partial success)
}
+ // Trigger immediate refresh of parent data
+ onServiceImported()
setStep('complete')
} catch (err: any) {
setError(extractErrorMessage(err, 'Failed to import service'))
@@ -816,42 +749,24 @@ 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(', ')}
-
- )}
+ {/* Capabilities Provided */}
+
+
+
+
+ {/* Capabilities Required */}
+
+
{/* Ports (Docker Hub) */}
@@ -1001,164 +916,13 @@ export default function ImportFromGitHubModal({
{/* Environment Variables */}
-
-
-
-
- Environment Variables ({envVars.length})
-
-
-
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
- const originalEnv = selectedServices
- .flatMap((s) => s.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
- )}
- {dockerHubInfo && (
- removeEnvVar(index)}
- className="p-1 text-gray-400 hover:text-red-500"
- >
-
-
- )}
-
-
-
-
- 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"
- />
- )}
-
-
- )
- })}
-
+
+
)
diff --git a/ushadow/frontend/src/services/api.ts b/ushadow/frontend/src/services/api.ts
index ebb1923b..ce316eff 100644
--- a/ushadow/frontend/src/services/api.ts
+++ b/ushadow/frontend/src/services/api.ts
@@ -1665,6 +1665,7 @@ export interface DockerHubRegisterRequest {
shadow_header_value?: string
route_path?: string
capabilities?: string[] // Capabilities this service provides
+ requires?: string[] // Capabilities this service requires (dependencies)
}
export const githubImportApi = {
@@ -1723,6 +1724,7 @@ export const githubImportApi = {
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,
+ requires: request.requires ? JSON.stringify(request.requires) : undefined,
}
}),