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) => ( + + ))} +
+ + {/* 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`} + /> + +
+ )} + + {/* Selected capabilities as removable chips */} + {selected.length > 0 && ( +
+ {selected.map((cap) => ( + + {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}) +

+
+ + +
+
+ + {/* Paste template area */} + {showPaste && ( +
+

+ Paste environment variables (KEY=value format, one per line): +

+